// main.cpp - Freenove ESP32-S3 all-in-one runtime loop.
#include <Arduino.h>
#include <ArduinoJson.h>
#include <FS.h>
#include <LittleFS.h>
#include <Preferences.h>
#include <WebServer.h>
#include <cerrno>
#include <cctype>
#include <cmath>
#include <cstdio>
#include <cstdlib>
#include <cstring>
#include <vector>

#if defined(ARDUINO_ARCH_ESP32)
#include <esp_heap_caps.h>
#include <freertos/task.h>
#if __has_include(<SD_MMC.h>)
#include <SD_MMC.h>
#define ZACUS_HAS_SD_AUDIO 1
#else
#define ZACUS_HAS_SD_AUDIO 0
#endif
#else
#define ZACUS_HAS_SD_AUDIO 0
#endif

#include "audio_manager.h"
#include "button_manager.h"
#include "camera_manager.h"
#include "hardware_manager.h"
#include "media_manager.h"
#include "ui_freenove_config.h"
#include "network_manager.h"
#include "app/app_registry.h"
#include "app/app_runtime_manager.h"
#include "app/file_share_service.h"
#include "app/runtime_scene_service.h"
#include "app/runtime_serial_service.h"
#include "app/runtime_web_service.h"
#include "runtime/app_coordinator.h"
#include "runtime/la_trigger_service.h"
#include "runtime/perf/perf_monitor.h"
#include "runtime/provisioning/boot_mode_store.h"
#include "runtime/provisioning/credential_store.h"
#include "runtime/resource/resource_coordinator.h"
#include "runtime/scene_fx_orchestrator.h"
#include "runtime/simd/simd_accel.h"
#include "runtime/simd/simd_accel_bench.h"
#include "runtime/runtime_config_service.h"
#include "runtime/runtime_services.h"
#include "runtime/runtime_config_types.h"
#include "scenarios/default_scenario_v2.h"
#include "storage_manager.h"
#include "system/boot_report.h"
#include "system/rate_limited_log.h"
#include "system/runtime_metrics.h"
#include "touch_manager.h"
#include "ui/audio_player/amiga_audio_player.h"
#include "ui/camera_capture/win311_camera_ui.h"
#include "ui_manager.h"
#include "ui/ui_amiga_shell.h"

#ifndef ZACUS_FW_VERSION
#define ZACUS_FW_VERSION "dev"
#endif

void runRuntimeIteration(uint32_t now_ms);

namespace {

constexpr const char* kDefaultScenarioFile = "/story/scenarios/DEFAULT.json";
constexpr const char* kFirmwareName = "freenove_esp32s3";
constexpr const char* kDiagAudioFile = "/music/boot_radio.mp3";
constexpr const char* kUsonAmbientTrack = "/music/boot_radio.mp3";
constexpr uint8_t kUsonAmbientVolumeFixed = 21U;
constexpr uint8_t kUsonAmbientVolumeMin =
    (kUsonAmbientVolumeFixed > FREENOVE_AUDIO_MAX_VOLUME) ? FREENOVE_AUDIO_MAX_VOLUME : kUsonAmbientVolumeFixed;
constexpr uint8_t kUsonAmbientVolumeMax = kUsonAmbientVolumeMin;
// Keep U-SON ambient playlist continuous for the full scene runtime.
constexpr uint32_t kUsonAmbientDelayMinMs = 1000U;
constexpr uint32_t kUsonAmbientDelayMaxMs = 2000U;
constexpr uint32_t kWarningSirenBeatIntervalMs = 700U;
constexpr size_t kSerialLineCapacity = 192U;
constexpr bool kBootDiagnosticTone = true;
constexpr bool kAutoSyncStoryFromSdOnBoot = false;
#if defined(ZACUS_SPRINT_DIAG_MODE) && (ZACUS_SPRINT_DIAG_MODE != 0)
constexpr bool kSprintDiagMode = true;
#else
constexpr bool kSprintDiagMode = false;
#endif
constexpr const char* kEspNowBroadcastTarget = "broadcast";
constexpr const char* kStepWinEtape = "RTC_ESP_ETAPE1";
constexpr const char* kPackWin = "PACK_WIN";
constexpr const char* kWebAuthHeaderName = "Authorization";
constexpr const char* kWebAuthBearerPrefix = "Bearer ";
constexpr const char* kProvisionStatusPath = "/api/provision/status";
constexpr const char* kSetupWifiConnectPath = "/api/wifi/connect";
constexpr const char* kSetupNetworkWifiConnectPath = "/api/network/wifi/connect";
constexpr size_t kWebAuthTokenCapacity = 65U;
constexpr size_t kEspNowDeviceNameCapacity = 33U;
constexpr const char* kEspNowDeviceNameDefault = "U_SON";
constexpr const char* kCredentialsNvsNamespace = "zacus_net";
constexpr const char* kEspNowDeviceNameNvsKey = "esp_name";
constexpr uint32_t kEspNowDiscoveryIntervalMs = kSprintDiagMode ? 15000U : 1000U;
constexpr uint32_t kMetricsLogPeriodMs = kSprintDiagMode ? 15000U : 5000U;
constexpr uint32_t kSprintDiagTelemetryMinMs = 8000U;
constexpr size_t kHotlineSceneSyncPayloadCapacity = 224U;
#if defined(USE_AUDIO) && (USE_AUDIO != 0)
constexpr const char* kAmpMusicPathPrimary = "/music";
constexpr const char* kAmpMusicPathFallback1 = "/audio/music";
constexpr const char* kAmpMusicPathFallback2 = "/audio";
#endif
constexpr const char* kCameraSceneId = "SCENE_PHOTO_MANAGER";
constexpr const char* kMediaManagerSceneId = "ZACUS_U-SON";
constexpr const char* kTestLabSceneId = "SCENE_TEST_LAB";
constexpr const char* kDefaultBootSceneId = "ZACUS_U-SON";
constexpr bool kBootEspNowOnlyMode = kSprintDiagMode ? false : true;
constexpr const char* kTestLabLockStepId = "TEST_LAB_LOCK";
constexpr bool kLockNvsMediaManagerMode = true;
constexpr bool kForceTestLabSceneLock = false;

struct SceneBacklightFxState {
  bool enabled = false;
  bool level_sync = false;
  uint8_t min_level = 0U;
  uint8_t max_level = 125U;
  uint8_t base_level = 20U;
  uint8_t spike_pct = 18U;
  uint8_t glitch_pct = 65U;
  uint32_t next_spike_ms = 0U;
  uint32_t spike_end_ms = 0U;
  uint8_t spike_level = 0U;
};

struct SceneWs2812FxState {
  bool single_random_blink = false;
  bool dominant_band_single = false;
  bool one_led_at_a_time = true;
  bool level_sync = false;
  bool dynamic_color = false;
  uint8_t r = 0U;
  uint8_t g = 0U;
  uint8_t b = 0U;
  uint8_t brightness = 52U;
  uint8_t min_brightness = 6U;
  uint8_t max_brightness = 95U;
};

struct SceneAmbientAudioFxState {
  bool enabled = false;
  bool non_interruptive = true;
  char mode[24] = "single_track";
  char track[64] = "/music/boot_radio.mp3";
  char playlist_root[96] = "";
  bool playlist_recursive = true;
  uint8_t volume_min = kUsonAmbientVolumeMin;
  uint8_t volume_max = kUsonAmbientVolumeMax;
  uint32_t delay_min_ms = kUsonAmbientDelayMinMs;
  uint32_t delay_max_ms = kUsonAmbientDelayMaxMs;
  bool eq_soft = true;
  bool eq_warm = true;
  bool eq_bright = true;
};

struct SceneWarningSirenFxState {
  bool enabled = false;
  bool remote_started = false;
  bool local_backend_available = false;
  bool local_fallback_logged = false;
  uint32_t started_ms = 0U;
  uint32_t next_beat_ms = 0U;
  uint32_t next_retry_ms = 0U;
};

AudioManager g_audio;
ScenarioManager g_scenario;
UiManager g_ui;
StorageManager g_storage;
ButtonManager g_buttons;
TouchManager g_touch;
NetworkManager g_network;
HardwareManager g_hardware;
CameraManager g_camera;
MediaManager g_media;
CredentialStore g_credential_store;
BootModeStore g_boot_mode_store;
RuntimeNetworkConfig g_network_cfg;
RuntimeHardwareConfig g_hardware_cfg;
CameraManager::Config g_camera_cfg;
MediaManager::Config g_media_cfg;
WebServer g_web_server(80);
bool g_web_started = false;
bool g_web_disconnect_sta_pending = false;
uint32_t g_web_disconnect_sta_at_ms = 0U;
bool g_diag_ap_stopped = false;
bool g_hardware_started = false;
uint32_t g_next_hw_telemetry_ms = 0U;
bool g_mic_tuner_stream_enabled = false;
uint16_t g_mic_tuner_stream_period_ms = 250U;
uint32_t g_next_mic_tuner_stream_ms = 0U;
bool g_mic_event_armed = true;
bool g_battery_low_latched = false;
LaTriggerRuntimeState g_la_trigger;
bool g_la_dispatch_in_progress = false;
bool g_has_ring_sent_for_win_etape = false;
bool g_win_etape_ui_refresh_pending = false;
bool g_boot_media_manager_mode = false;
uint8_t g_lcd_backlight_level = 80U;
uint8_t g_lcd_backlight_base_level = 80U;
bool g_setup_mode = true;
bool g_web_auth_required = false;
bool g_resource_profile_auto = true;
char g_web_auth_token[kWebAuthTokenCapacity] = {0};
char g_espnow_device_name[kEspNowDeviceNameCapacity] = "U_SON";
bool g_espnow_discovery_runtime_enabled = !kSprintDiagMode;
uint32_t g_next_espnow_discovery_ms = 0U;
char g_last_action_step_key[72] = {0};
char g_serial_line[kSerialLineCapacity] = {0};
size_t g_serial_line_len = 0U;
RuntimeServices g_runtime_services;
AppCoordinator g_app_coordinator;
runtime::resource::ResourceCoordinator g_resource_coordinator;
AppRegistry g_app_registry;
AppRuntimeManager g_app_runtime_manager;
FileShareService g_file_share_service;
SceneFxOrchestrator g_scene_fx_orchestrator;
RuntimeSerialService g_runtime_serial_service;
RuntimeSceneService g_runtime_scene_service;
RuntimeWebService g_runtime_web_service;
SceneBacklightFxState g_scene_backlight_fx;
SceneWs2812FxState g_scene_ws2812_fx;
SceneAmbientAudioFxState g_scene_ambient_audio_fx;
SceneWarningSirenFxState g_scene_warning_siren_fx;
char g_scene_backlight_mode[24] = "static";
uint8_t g_scene_ws2812_runtime_brightness = 0U;
uint32_t g_scene_ws2812_runtime_next_update_ms = 0U;
bool g_uson_ambient_audio_active = false;
uint8_t g_uson_ambient_restore_volume = FREENOVE_AUDIO_MAX_VOLUME;
uint32_t g_uson_ambient_next_play_ms = 0U;
uint32_t g_next_credits_audio_retry_ms = 0U;
#if defined(USE_AUDIO) && (USE_AUDIO != 0)
ui::audio::AmigaAudioPlayer g_amp_player;
bool g_amp_ready = false;
bool g_amp_scene_active = false;
char g_amp_base_dir[40] = "/music";
#endif
ui::camera::Win311CameraUI g_camera_player;
bool g_camera_scene_active = false;
bool g_camera_scene_ready = false;
StaticJsonDocument<512> g_story_action_doc;

bool dispatchScenarioEventByName(const char* event_name, uint32_t now_ms);
bool parseBoolToken(const char* text, bool* out_value);
void refreshSceneIfNeeded(bool force_render);
bool sendWarningSirenSync(const char* phase, uint8_t strength);
void updateWarningSirenFx(uint32_t now_ms);

void logLoopStackWatermark(const char* stage, const char* scene_id) {
#if defined(ARDUINO_ARCH_ESP32)
  const UBaseType_t high_water_words = uxTaskGetStackHighWaterMark(nullptr);
  const uint32_t high_water_bytes = static_cast<uint32_t>(high_water_words) * sizeof(StackType_t);
  Serial.printf("[STACK] stage=%s scene=%s hwm_words=%u hwm_bytes=%lu heap_free=%u heap_largest=%u\n",
                (stage != nullptr) ? stage : "n/a",
                (scene_id != nullptr) ? scene_id : "n/a",
                static_cast<unsigned int>(high_water_words),
                static_cast<unsigned long>(high_water_bytes),
                static_cast<unsigned int>(ESP.getFreeHeap()),
                static_cast<unsigned int>(ESP.getMaxAllocHeap()));
#else
  (void)stage;
  (void)scene_id;
#endif
}

const char* audioPackToFile(const char* pack_id) {
  if (pack_id == nullptr || pack_id[0] == '\0') {
    return nullptr;
  }
  if (std::strcmp(pack_id, "PACK_BOOT_RADIO") == 0) {
    return "/music/boot_radio.mp3";
  }
  if (std::strcmp(pack_id, "PACK_SONAR_HINT") == 0) {
    return "/music/sonar_hint.mp3";
  }
  if (std::strcmp(pack_id, "PACK_MORSE_HINT") == 0) {
    return "/music/morse_hint.mp3";
  }
  if (std::strcmp(pack_id, "PACK_WIN") == 0) {
    return "/music/win.mp3";
  }
  if (std::strcmp(pack_id, "PACK_CONFIRM_WIN_ETAPE1") == 0) {
    return "/music/confirm_win_etape1.mp3";
  }
  if (std::strcmp(pack_id, "PACK_CONFIRM_WIN_ETAPE2") == 0) {
    return "/music/confirm_win_etape2.mp3";
  }
  return "/music/placeholder.mp3";
}

bool isCreditsSceneId(const char* scene_id) {
  if (scene_id == nullptr || scene_id[0] == '\0') {
    return false;
  }
  return std::strcmp(scene_id, "SCENE_CREDITS") == 0 ||
         std::strcmp(scene_id, "SCENE_CREDIT") == 0;
}

bool playCreditsWinTrack(const char** selected_path_out = nullptr) {
  static const char* kCreditsWinCandidates[] = {
      "/music/SCENE_WIN.mp3",
      "/music/SCENE_WIN_ETAPE.mp3",
      "/music/sonar_hint.mp3",
  };

  const char* selected = kCreditsWinCandidates[0];
  bool ok = false;
  for (const char* candidate : kCreditsWinCandidates) {
    if (candidate == nullptr || candidate[0] == '\0') {
      continue;
    }
    selected = candidate;
    if (g_audio.play(candidate)) {
      ok = true;
      break;
    }
  }

  if (selected_path_out != nullptr) {
    *selected_path_out = selected;
  }
  return ok;
}

bool isCreditsWinTrackPath(const char* path) {
  if (path == nullptr || path[0] == '\0') {
    return false;
  }
  return std::strcmp(path, "/music/SCENE_WIN.mp3") == 0 ||
         std::strcmp(path, "/music/SCENE_WIN_ETAPE.mp3") == 0 ||
         std::strcmp(path, "/music/sonar_hint.mp3") == 0;
}

const char* scenarioIdFromSnapshot(const ScenarioSnapshot& snapshot) {
  return (snapshot.scenario != nullptr && snapshot.scenario->id != nullptr) ? snapshot.scenario->id : "n/a";
}

const char* stepIdFromSnapshot(const ScenarioSnapshot& snapshot) {
  return (snapshot.step != nullptr && snapshot.step->id != nullptr) ? snapshot.step->id : "n/a";
}

const char* inferHotlineValidationStateTag(const ScenarioSnapshot& snapshot) {
  const char* step_id = (snapshot.step != nullptr && snapshot.step->id != nullptr) ? snapshot.step->id : "";
  const char* scene_id = (snapshot.screen_scene_id != nullptr) ? snapshot.screen_scene_id : "";

  if (std::strstr(scene_id, "WARNING") != nullptr || std::strstr(step_id, "WARNING") != nullptr) {
    return "refused";
  }
  if (std::strstr(step_id, "RTC_ESP_ETAPE") != nullptr ||
      std::strcmp(scene_id, "SCENE_WIN_ETAPE") == 0 ||
      std::strcmp(scene_id, "SCENE_WIN_ETAPE2") == 0) {
    return "waiting";
  }
  if (std::strstr(scene_id, "WIN") != nullptr || std::strstr(step_id, "WIN") != nullptr ||
      std::strstr(scene_id, "FINAL") != nullptr || std::strstr(step_id, "FINAL") != nullptr ||
      std::strstr(scene_id, "REWARD") != nullptr) {
    return "granted";
  }
  return "none";
}

void buildHotlineCueSelector(const char* scene_id,
                             const char* validation_state,
                             char* out_cue,
                             size_t out_cue_capacity) {
  if (out_cue == nullptr || out_cue_capacity == 0U) {
    return;
  }
  const char* base_scene = (scene_id != nullptr && scene_id[0] != '\0') ? scene_id : "SCENE_UNKNOWN";
  const char* suffix = "indice_*";
  if (validation_state != nullptr) {
    if (std::strcmp(validation_state, "waiting") == 0) {
      suffix = "attente_validation*";
    } else if (std::strcmp(validation_state, "granted") == 0) {
      suffix = "validation_ok*";
    } else if (std::strcmp(validation_state, "refused") == 0) {
      suffix = "validation_ko*";
    }
  }
  std::snprintf(out_cue, out_cue_capacity, "%s/%s", base_scene, suffix);
  out_cue[out_cue_capacity - 1U] = '\0';
}

bool sendEspNowWithRetry(const char* target,
                         const char* payload,
                         uint8_t max_attempts,
                         uint16_t retry_delay_ms,
                         const char* log_tag) {
  if (target == nullptr || target[0] == '\0' || payload == nullptr || payload[0] == '\0') {
    return false;
  }
  if (max_attempts == 0U) {
    max_attempts = 1U;
  }

  for (uint8_t attempt = 1U; attempt <= max_attempts; ++attempt) {
    const bool ok = g_network.sendEspNowTarget(target, payload);
    if (ok) {
      if (attempt > 1U) {
        Serial.printf("[%s] send retry success attempt=%u target=%s payload=%s\n",
                      (log_tag != nullptr) ? log_tag : "ESPNOW_TX",
                      static_cast<unsigned int>(attempt),
                      target,
                      payload);
      }
      return true;
    }
    if (attempt < max_attempts && retry_delay_ms > 0U) {
      delay(retry_delay_ms);
    }
  }
  return false;
}

void syncHotlineSceneState(const ScenarioSnapshot& snapshot) {
  if (snapshot.screen_scene_id == nullptr || snapshot.screen_scene_id[0] == '\0') {
    return;
  }

  const char* step_id =
      (snapshot.step != nullptr && snapshot.step->id != nullptr && snapshot.step->id[0] != '\0')
          ? snapshot.step->id
          : "n/a";
  const char* validation_state = inferHotlineValidationStateTag(snapshot);
  char cue_selector[72] = {0};
  buildHotlineCueSelector(snapshot.screen_scene_id, validation_state, cue_selector, sizeof(cue_selector));
  char payload[kHotlineSceneSyncPayloadCapacity] = {0};
  const int written = std::snprintf(payload,
                                    sizeof(payload),
                                    "SCENE {\"id\":\"%s\",\"step_id\":\"%s\",\"validation_state\":\"%s\",\"cue\":\"%s\"}",
                                    snapshot.screen_scene_id,
                                    step_id,
                                    validation_state,
                                    cue_selector);
  if (written <= 0 || static_cast<size_t>(written) >= sizeof(payload)) {
    Serial.println("[HOTLINE_SYNC] payload truncated");
    return;
  }

  const bool ok = sendEspNowWithRetry(kEspNowBroadcastTarget, payload, 2U, 40U, "HOTLINE_SYNC");
  Serial.printf("[HOTLINE_SYNC] scene=%s step=%s validation=%s cue=%s ok=%u\n",
                snapshot.screen_scene_id,
                step_id,
                validation_state,
                cue_selector,
                ok ? 1U : 0U);
}

bool sendWarningSirenSync(const char* phase, uint8_t strength) {
  if (phase == nullptr || phase[0] == '\0') {
    return false;
  }
  char payload[48] = {0};
  int written = 0;
  if (std::strcmp(phase, "STOP") == 0) {
    written = std::snprintf(payload, sizeof(payload), "WARNING_SIREN %s", phase);
  } else {
    written = std::snprintf(payload, sizeof(payload), "WARNING_SIREN %s %u", phase, strength);
  }
  if (written <= 0 || static_cast<size_t>(written) >= sizeof(payload)) {
    return false;
  }
  return g_network.sendEspNowTarget(kEspNowBroadcastTarget, payload);
}

bool warningSirenRemoteSyncReady() {
  const NetworkManager::Snapshot net = g_network.snapshot();
  return net.ready && net.espnow_enabled;
}

bool isFixedTestSceneActive(const ScenarioSnapshot& snapshot) {
  return snapshot.screen_scene_id != nullptr && std::strcmp(snapshot.screen_scene_id, kTestLabSceneId) == 0;
}

bool loadScenarioByIdPreferStoryFile(const char* scenario_id, String* out_source, String* out_path) {
  if (scenario_id == nullptr || scenario_id[0] == '\0') {
    return false;
  }
  String normalized_id = scenario_id;
  normalized_id.trim();
  if (normalized_id.isEmpty()) {
    return false;
  }

  String story_path = "/story/scenarios/";
  story_path += normalized_id;
  story_path += ".json";
  if (g_storage.fileExists(story_path.c_str())) {
    if (g_scenario.begin(story_path.c_str())) {
      if (out_source != nullptr) {
        *out_source = "story_file";
      }
      if (out_path != nullptr) {
        *out_path = story_path;
      }
      return true;
    }
  }

  if (g_scenario.beginById(normalized_id.c_str())) {
    if (out_source != nullptr) {
      *out_source = "builtin";
    }
    if (out_path != nullptr) {
      out_path->remove(0);
    }
    return true;
  }
  return false;
}

void toLowerAsciiInPlace(char* text) {
  if (text == nullptr) {
    return;
  }
  for (size_t index = 0; text[index] != '\0'; ++index) {
    text[index] = static_cast<char>(std::tolower(static_cast<unsigned char>(text[index])));
  }
}

void toUpperAsciiInPlace(char* text) {
  if (text == nullptr) {
    return;
  }
  for (size_t index = 0; text[index] != '\0'; ++index) {
    text[index] = static_cast<char>(std::toupper(static_cast<unsigned char>(text[index])));
  }
}

void trimAsciiInPlace(char* text) {
  if (text == nullptr) {
    return;
  }
  const size_t len = std::strlen(text);
  size_t begin = 0U;
  while (begin < len && std::isspace(static_cast<unsigned char>(text[begin]))) {
    ++begin;
  }
  size_t end = len;
  while (end > begin && std::isspace(static_cast<unsigned char>(text[end - 1U]))) {
    --end;
  }
  const size_t out_len = (end > begin) ? (end - begin) : 0U;
  if (begin > 0U && out_len > 0U) {
    std::memmove(text, &text[begin], out_len);
  }
  text[out_len] = '\0';
}

void copyText(char* out, size_t out_size, const char* text) {
  if (out == nullptr || out_size == 0U) {
    return;
  }
  if (text == nullptr) {
    out[0] = '\0';
    return;
  }
  std::strncpy(out, text, out_size - 1U);
  out[out_size - 1U] = '\0';
}

void normalizeEspNowDeviceNameInPlace(char* text) {
  if (text == nullptr) {
    return;
  }
  trimAsciiInPlace(text);
  for (size_t index = 0U; text[index] != '\0'; ++index) {
    unsigned char value = static_cast<unsigned char>(text[index]);
    if (std::isspace(value)) {
      text[index] = '_';
      continue;
    }
    if (std::isalnum(value)) {
      text[index] = static_cast<char>(std::toupper(value));
      continue;
    }
    if (text[index] != '_' && text[index] != '-') {
      text[index] = '_';
    }
  }
  trimAsciiInPlace(text);
}

void loadEspNowDeviceNameFromNvs() {
  copyText(g_espnow_device_name, sizeof(g_espnow_device_name), kEspNowDeviceNameDefault);
  Preferences prefs;
  if (!prefs.begin(kCredentialsNvsNamespace, true)) {
    return;
  }
  char stored[kEspNowDeviceNameCapacity] = {0};
  prefs.getString(kEspNowDeviceNameNvsKey, stored, sizeof(stored));
  prefs.end();
  normalizeEspNowDeviceNameInPlace(stored);
  if (stored[0] != '\0') {
    copyText(g_espnow_device_name, sizeof(g_espnow_device_name), stored);
  }
}

bool saveEspNowDeviceNameToNvs(const char* device_name) {
  if (device_name == nullptr || device_name[0] == '\0') {
    return false;
  }
  char normalized[kEspNowDeviceNameCapacity] = {0};
  copyText(normalized, sizeof(normalized), device_name);
  normalizeEspNowDeviceNameInPlace(normalized);
  if (normalized[0] == '\0') {
    return false;
  }
  Preferences prefs;
  if (!prefs.begin(kCredentialsNvsNamespace, false)) {
    return false;
  }
  const size_t written = prefs.putString(kEspNowDeviceNameNvsKey, normalized);
  prefs.end();
  if (written == 0U) {
    return false;
  }
  copyText(g_espnow_device_name, sizeof(g_espnow_device_name), normalized);
  return true;
}

bool sendEspNowDiscoveryProbe() {
  StaticJsonDocument<256> probe;
  char msg_id[32] = {0};
  snprintf(msg_id, sizeof(msg_id), "disc-%08lX", static_cast<unsigned long>(millis()));
  probe["msg_id"] = msg_id;
  probe["seq"] = static_cast<uint32_t>(millis());
  probe["type"] = "command";
  probe["ack"] = true;
  JsonObject payload = probe["payload"].to<JsonObject>();
  payload["cmd"] = "ESPNOW_STATUS";
  payload["source"] = g_espnow_device_name;

  String frame;
  serializeJson(probe, frame);
  return g_network.sendEspNowTarget(kEspNowBroadcastTarget, frame.c_str());
}

uint8_t runEspNowDiscoverySweep(uint8_t probes = 3U, uint16_t probe_gap_ms = 120U, uint16_t settle_ms = 450U) {
  if (probes == 0U) {
    probes = 1U;
  }
  for (uint8_t index = 0U; index < probes; ++index) {
    sendEspNowDiscoveryProbe();
    if (index + 1U < probes) {
      delay(probe_gap_ms);
    }
  }
  const uint32_t deadline = millis() + settle_ms;
  while (static_cast<int32_t>(millis() - deadline) < 0) {
    delay(20U);
  }
  return g_network.espNowPeerCount();
}

void maybeRunEspNowDiscoveryRuntime(uint32_t now_ms) {
  if (!g_espnow_discovery_runtime_enabled) {
    return;
  }
  if (g_next_espnow_discovery_ms != 0U && static_cast<int32_t>(now_ms - g_next_espnow_discovery_ms) < 0) {
    return;
  }
  const NetworkManager::Snapshot net = g_network.snapshot();
  if (!net.espnow_enabled) {
    g_next_espnow_discovery_ms = now_ms + 1000U;
    return;
  }
  const bool ok = sendEspNowDiscoveryProbe();
  g_next_espnow_discovery_ms = now_ms + kEspNowDiscoveryIntervalMs;
  if (ok) {
    ZACUS_RL_LOG_MS(4000U,
                    "[NET] ESPNOW discovery tick peers=%u\n",
                    static_cast<unsigned int>(g_network.espNowPeerCount()));
  }
}

BootModeStore::StartupMode currentStartupMode() {
  return g_boot_media_manager_mode ? BootModeStore::StartupMode::kMediaManager : BootModeStore::StartupMode::kStory;
}

void applyStartupMode(BootModeStore::StartupMode mode) {
  g_boot_media_manager_mode = (mode == BootModeStore::StartupMode::kMediaManager);
}

void printBootModeStatus() {
  const BootModeStore::StartupMode mode = currentStartupMode();
  Serial.printf("BOOT_MODE_STATUS mode=%s media_validated=%u\n",
                BootModeStore::modeLabel(mode),
                g_boot_mode_store.isMediaValidated() ? 1U : 0U);
}

uint32_t hashSceneFxSeed(uint32_t value) {
  value ^= value >> 16U;
  value *= 0x7feb352dUL;
  value ^= value >> 15U;
  value *= 0x846ca68bUL;
  value ^= value >> 16U;
  return value;
}

bool parseHexRgbColor(const char* text, uint32_t* out_rgb) {
  if (text == nullptr || text[0] == '\0' || out_rgb == nullptr) {
    return false;
  }
  const char* begin = text;
  if (begin[0] == '#') {
    ++begin;
  }
  char* end = nullptr;
  const unsigned long value = strtoul(begin, &end, 16);
  if (end == begin || *end != '\0' || value > 0xFFFFFFUL) {
    return false;
  }
  *out_rgb = static_cast<uint32_t>(value);
  return true;
}

bool isAmbientRandomFileModeToken(const char* token) {
  if (token == nullptr || token[0] == '\0') {
    return false;
  }
  String mode = token;
  mode.trim();
  mode.toLowerCase();
  return (mode == "random_file" || mode == "playlist" || mode == "random");
}

bool isAmbientAudioFilePath(const String& path) {
  String lower = path;
  lower.toLowerCase();
  return lower.endsWith(".mp3") || lower.endsWith(".wav") || lower.endsWith(".aac") ||
         lower.endsWith(".m4a") || lower.endsWith(".flac");
}

bool resolveAmbientRootFs(const char* raw_root, fs::FS** out_fs, String* out_root, bool* out_use_sd) {
  if (out_fs == nullptr || out_root == nullptr || out_use_sd == nullptr) {
    return false;
  }
  *out_fs = &LittleFS;
  *out_use_sd = false;
  out_root->remove(0);

  if (raw_root == nullptr || raw_root[0] == '\0') {
    return false;
  }

  String root = raw_root;
  root.trim();
  if (root.isEmpty()) {
    return false;
  }

#if ZACUS_HAS_SD_AUDIO
  if (root.startsWith("sd:/")) {
    *out_fs = &SD_MMC;
    *out_use_sd = true;
    root.remove(0, 3);
  } else if (root.startsWith("/sd/")) {
    *out_fs = &SD_MMC;
    *out_use_sd = true;
    root.remove(0, 3);
  }
#endif
  if (root.startsWith("littlefs:/")) {
    root.remove(0, 9);
  } else if (root.startsWith("/littlefs/")) {
    root.remove(0, 9);
  }

  if (!root.startsWith("/")) {
    root = "/" + root;
  }
  while (root.length() > 1U && root.endsWith("/")) {
    root.remove(root.length() - 1U);
  }
  if (root.isEmpty()) {
    root = "/";
  }

  *out_root = root;
  return true;
}

void collectAmbientPlaylistFiles(fs::FS& file_system,
                                 const String& root,
                                 bool recursive,
                                 uint8_t depth,
                                 std::vector<String>* out_files) {
  if (out_files == nullptr) {
    return;
  }
  if (out_files->size() >= 128U) {
    return;
  }
  File dir = file_system.open(root.c_str(), "r");
  if (!dir || !dir.isDirectory()) {
    if (dir) {
      dir.close();
    }
    // LittleFS can expose nested paths without directory handles.
    // Fallback to root scan and filter by the requested prefix.
    if (depth == 0U && root != "/") {
      File fs_root = file_system.open("/", "r");
      if (!fs_root || !fs_root.isDirectory()) {
        if (fs_root) {
          fs_root.close();
        }
        return;
      }
      String prefix = root;
      if (!prefix.endsWith("/")) {
        prefix += "/";
      }
      const unsigned int prefix_len = static_cast<unsigned int>(prefix.length());
      File entry = fs_root.openNextFile();
      while (entry) {
        String entry_name = entry.name();
        if (!entry_name.startsWith("/")) {
          entry_name = "/" + entry_name;
        }
        if (!entry.isDirectory() && isAmbientAudioFilePath(entry_name)) {
          int prefix_pos = entry_name.indexOf(prefix);
          if (prefix_pos < 0) {
            String prefix_no_leading_slash = prefix;
            if (prefix_no_leading_slash.startsWith("/")) {
              prefix_no_leading_slash.remove(0, 1);
            }
            prefix_pos = entry_name.indexOf(prefix_no_leading_slash);
          }
          if (prefix_pos >= 0) {
            bool include = true;
            if (!recursive) {
              const unsigned int child_start = static_cast<unsigned int>(prefix_pos) + prefix_len;
              include = entry_name.indexOf('/', child_start) < 0;
            }
            if (include) {
              out_files->push_back(entry_name);
              if (out_files->size() >= 128U) {
                entry.close();
                break;
              }
            }
          }
        }
        entry.close();
        entry = fs_root.openNextFile();
      }
      fs_root.close();
    }
    return;
  }

  File entry = dir.openNextFile();
  while (entry) {
    String entry_name = entry.name();
    if (!entry_name.startsWith("/")) {
      entry_name = "/" + entry_name;
    }
    if (entry.isDirectory()) {
      if (recursive && depth < 6U) {
        collectAmbientPlaylistFiles(file_system, entry_name, recursive, static_cast<uint8_t>(depth + 1U), out_files);
      }
    } else if (isAmbientAudioFilePath(entry_name)) {
      out_files->push_back(entry_name);
      if (out_files->size() >= 128U) {
        entry.close();
        break;
      }
    }
    entry.close();
    entry = dir.openNextFile();
  }
  dir.close();
}

bool chooseAmbientPlaylistTrack(const SceneAmbientAudioFxState& ambient, uint32_t now_ms, String* out_track) {
  if (out_track == nullptr) {
    return false;
  }
  out_track->remove(0);
  if (!isAmbientRandomFileModeToken(ambient.mode)) {
    return false;
  }

  fs::FS* file_system = nullptr;
  String root;
  bool use_sd = false;
  if (!resolveAmbientRootFs(ambient.playlist_root, &file_system, &root, &use_sd) || file_system == nullptr) {
    return false;
  }

  std::vector<String> files;
  files.reserve(24U);
  collectAmbientPlaylistFiles(*file_system, root, ambient.playlist_recursive, 0U, &files);
  if (files.empty()) {
    return false;
  }

  const uint32_t seed = hashSceneFxSeed(now_ms ^ 0x59A31BUL ^ static_cast<uint32_t>(files.size()));
  const size_t index = static_cast<size_t>(seed % static_cast<uint32_t>(files.size()));
  const String selected = files[index];
  if (use_sd) {
    *out_track = String("/sd") + selected;
  } else {
    *out_track = selected;
  }
  return true;
}

void applyLcdBacklightEffective(uint8_t level) {
#if (FREENOVE_TFT_BL >= 0)
  g_lcd_backlight_level = level;
#if defined(TFT_BACKLIGHT_ON) && (TFT_BACKLIGHT_ON == LOW)
  const uint8_t duty = static_cast<uint8_t>(255U - level);
#else
  const uint8_t duty = level;
#endif
  pinMode(FREENOVE_TFT_BL, OUTPUT);
  analogWrite(FREENOVE_TFT_BL, duty);
#else
  (void)level;
#endif
}

void applyLcdBacklight(uint8_t level) {
  g_lcd_backlight_base_level = level;
  applyLcdBacklightEffective(level);
}

void configureSceneVisualHardwareHints(const char* scene_id, const String& payload_json) {
  const bool is_u_son_proto = (scene_id != nullptr) && (std::strcmp(scene_id, "SCENE_U_SON_PROTO") == 0);
  const bool is_warning_scene = (scene_id != nullptr) && (std::strcmp(scene_id, "SCENE_WARNING") == 0);
  const bool is_lefou_scene = (scene_id != nullptr) && (std::strcmp(scene_id, "SCENE_LEFOU_DETECTOR") == 0);
  static constexpr uint16_t kLefouDefaultSequenceHz[] = {440U, 880U, 330U, 349U, 147U, 98U};

  SceneBacklightFxState backlight = {};
  backlight.enabled = is_u_son_proto;
  backlight.level_sync = is_u_son_proto;
  backlight.min_level = 0U;
  backlight.max_level = 95U;
  backlight.base_level = 18U;
  backlight.spike_pct = 18U;
  backlight.glitch_pct = 65U;

  SceneWs2812FxState ws = {};
  ws.single_random_blink = is_u_son_proto;
  ws.dominant_band_single = is_u_son_proto;
  ws.one_led_at_a_time = true;
  ws.level_sync = is_u_son_proto;
  ws.dynamic_color = is_u_son_proto;
  ws.r = 0x6FU;
  ws.g = 0xD8U;
  ws.b = 0xFFU;
  ws.brightness = 48U;
  ws.min_brightness = 4U;
  ws.max_brightness = 95U;

  SceneAmbientAudioFxState ambient = {};
  ambient.enabled = is_u_son_proto;
  ambient.non_interruptive = true;
  std::strncpy(ambient.mode, is_u_son_proto ? "random_file" : "single_track", sizeof(ambient.mode) - 1U);
  ambient.mode[sizeof(ambient.mode) - 1U] = '\0';
  std::strncpy(ambient.track, kUsonAmbientTrack, sizeof(ambient.track) - 1U);
  ambient.track[sizeof(ambient.track) - 1U] = '\0';
  std::strncpy(ambient.playlist_root, is_u_son_proto ? "/music/v8_pack" : "", sizeof(ambient.playlist_root) - 1U);
  ambient.playlist_root[sizeof(ambient.playlist_root) - 1U] = '\0';
  ambient.playlist_recursive = true;
  uint16_t lefou_sequence_hz[RuntimeHardwareConfig::kLaSequenceMaxNotes] = {};
  uint8_t lefou_sequence_count = 0U;
  uint16_t lefou_note_hold_ms = 350U;
  bool warning_siren = is_warning_scene;
  if (is_lefou_scene) {
    lefou_sequence_count = static_cast<uint8_t>(sizeof(kLefouDefaultSequenceHz) / sizeof(kLefouDefaultSequenceHz[0]));
    if (lefou_sequence_count > RuntimeHardwareConfig::kLaSequenceMaxNotes) {
      lefou_sequence_count = RuntimeHardwareConfig::kLaSequenceMaxNotes;
    }
    for (uint8_t idx = 0U; idx < lefou_sequence_count; ++idx) {
      lefou_sequence_hz[idx] = kLefouDefaultSequenceHz[idx];
    }
  }

  uint32_t accent_rgb = 0x6FD8FFUL;
  bool ws_use_theme_accent = ws.single_random_blink;

  if (!payload_json.isEmpty()) {
    DynamicJsonDocument document(4096);
    const DeserializationError error = deserializeJson(document, payload_json);
    if (!error) {
      parseHexRgbColor(document["theme"]["accent"] | document["visual"]["theme"]["accent"] | "", &accent_rgb);

      if (document["text"]["glitch"].is<unsigned int>()) {
        backlight.glitch_pct = static_cast<uint8_t>(document["text"]["glitch"].as<unsigned int>());
      } else if (document["text"]["glitch_pct"].is<unsigned int>()) {
        backlight.glitch_pct = static_cast<uint8_t>(document["text"]["glitch_pct"].as<unsigned int>());
      }
      if (backlight.glitch_pct > 100U) {
        backlight.glitch_pct = 100U;
      }

      const char* backlight_mode = document["hardware"]["backlight"]["mode"] | "";
      if (backlight_mode[0] != '\0') {
        if (std::strcmp(backlight_mode, "level_sync") == 0 || std::strcmp(backlight_mode, "level") == 0) {
          backlight.enabled = true;
          backlight.level_sync = true;
        } else if (std::strcmp(backlight_mode, "glitch_sync") == 0 || std::strcmp(backlight_mode, "glitch") == 0) {
          backlight.enabled = true;
          backlight.level_sync = false;
        } else {
          backlight.enabled = false;
          backlight.level_sync = false;
        }
      }
      if (document["hardware"]["backlight"]["min"].is<int>()) {
        const int parsed = document["hardware"]["backlight"]["min"].as<int>();
        backlight.min_level = static_cast<uint8_t>((parsed < 0) ? 0 : ((parsed > 125) ? 125 : parsed));
      }
      if (document["hardware"]["backlight"]["max"].is<int>()) {
        const int parsed = document["hardware"]["backlight"]["max"].as<int>();
        backlight.max_level = static_cast<uint8_t>((parsed < 0) ? 0 : ((parsed > 125) ? 125 : parsed));
      }
      if (backlight.min_level > backlight.max_level) {
        const uint8_t swapped = backlight.min_level;
        backlight.min_level = backlight.max_level;
        backlight.max_level = swapped;
      }
      if (document["hardware"]["backlight"]["base"].is<int>()) {
        const int parsed = document["hardware"]["backlight"]["base"].as<int>();
        backlight.base_level = static_cast<uint8_t>((parsed < 0) ? 0 : ((parsed > 125) ? 125 : parsed));
      }
      if (backlight.base_level < backlight.min_level) {
        backlight.base_level = backlight.min_level;
      } else if (backlight.base_level > backlight.max_level) {
        backlight.base_level = backlight.max_level;
      }
      if (document["hardware"]["backlight"]["spike_pct"].is<unsigned int>()) {
        backlight.spike_pct = static_cast<uint8_t>(document["hardware"]["backlight"]["spike_pct"].as<unsigned int>());
      }
      if (backlight.spike_pct > 100U) {
        backlight.spike_pct = 100U;
      }

      const char* ws_mode = document["hardware"]["ws2812"]["mode"] | "";
      if (ws_mode[0] != '\0') {
        if (std::strcmp(ws_mode, "single_random_blink") == 0) {
          ws.single_random_blink = true;
          ws.dominant_band_single = false;
        } else if (std::strcmp(ws_mode, "dominant_band_single") == 0 ||
                   std::strcmp(ws_mode, "single_band_dominant") == 0) {
          ws.single_random_blink = true;
          ws.dominant_band_single = true;
        } else {
          ws.single_random_blink = false;
          ws.dominant_band_single = false;
        }
      }
      if (document["hardware"]["ws2812"]["level_sync"].is<bool>()) {
        ws.level_sync = document["hardware"]["ws2812"]["level_sync"].as<bool>();
      }
      if (document["hardware"]["ws2812"]["one_led_at_a_time"].is<bool>()) {
        ws.one_led_at_a_time = document["hardware"]["ws2812"]["one_led_at_a_time"].as<bool>();
      }
      const char* ws_color_source = document["hardware"]["ws2812"]["color_source"] | "";
      if (ws_color_source[0] != '\0') {
        if (std::strcmp(ws_color_source, "theme.accent") == 0) {
          ws_use_theme_accent = true;
          ws.dynamic_color = false;
        } else if (std::strcmp(ws_color_source, "mic.dynamic") == 0 ||
                   std::strcmp(ws_color_source, "mic") == 0) {
          ws.dynamic_color = true;
          ws_use_theme_accent = false;
        }
      }
      if (document["hardware"]["ws2812"]["dynamic_color"].is<bool>()) {
        ws.dynamic_color = document["hardware"]["ws2812"]["dynamic_color"].as<bool>();
      }
      uint32_t ws_color_rgb = accent_rgb;
      if (parseHexRgbColor(document["hardware"]["ws2812"]["color"] | "", &ws_color_rgb)) {
        ws_use_theme_accent = false;
        ws.dynamic_color = false;
      }
      if (document["hardware"]["ws2812"]["brightness"].is<unsigned int>()) {
        ws.brightness = static_cast<uint8_t>(document["hardware"]["ws2812"]["brightness"].as<unsigned int>());
      }
      if (ws.brightness > 125U) {
        ws.brightness = 125U;
      }
      if (document["hardware"]["ws2812"]["min_brightness"].is<unsigned int>()) {
        ws.min_brightness = static_cast<uint8_t>(document["hardware"]["ws2812"]["min_brightness"].as<unsigned int>());
      }
      if (document["hardware"]["ws2812"]["max_brightness"].is<unsigned int>()) {
        ws.max_brightness = static_cast<uint8_t>(document["hardware"]["ws2812"]["max_brightness"].as<unsigned int>());
      }
      if (ws.max_brightness > 125U) {
        ws.max_brightness = 125U;
      }
      if (ws.min_brightness > ws.max_brightness) {
        ws.min_brightness = ws.max_brightness;
      }
      if (ws_use_theme_accent) {
        ws_color_rgb = accent_rgb;
      }
      ws.r = static_cast<uint8_t>((ws_color_rgb >> 16U) & 0xFFU);
      ws.g = static_cast<uint8_t>((ws_color_rgb >> 8U) & 0xFFU);
      ws.b = static_cast<uint8_t>(ws_color_rgb & 0xFFU);

      if (document["audio"]["ambient_random"].is<JsonObjectConst>()) {
        const JsonObjectConst ambient_cfg = document["audio"]["ambient_random"].as<JsonObjectConst>();
        if (ambient_cfg["enabled"].is<bool>()) {
          ambient.enabled = ambient_cfg["enabled"].as<bool>();
        }
        if (ambient_cfg["non_interruptive"].is<bool>()) {
          ambient.non_interruptive = ambient_cfg["non_interruptive"].as<bool>();
        }
        const char* mode = ambient_cfg["mode"] | "";
        if (mode[0] != '\0') {
          std::strncpy(ambient.mode, mode, sizeof(ambient.mode) - 1U);
          ambient.mode[sizeof(ambient.mode) - 1U] = '\0';
        }
        const char* track = ambient_cfg["track"] | "";
        if (track[0] != '\0') {
          std::strncpy(ambient.track, track, sizeof(ambient.track) - 1U);
          ambient.track[sizeof(ambient.track) - 1U] = '\0';
        }
        const char* playlist_root = ambient_cfg["playlist_root"] | "";
        if (playlist_root[0] != '\0') {
          std::strncpy(ambient.playlist_root, playlist_root, sizeof(ambient.playlist_root) - 1U);
          ambient.playlist_root[sizeof(ambient.playlist_root) - 1U] = '\0';
        }
        if (ambient_cfg["playlist_recursive"].is<bool>()) {
          ambient.playlist_recursive = ambient_cfg["playlist_recursive"].as<bool>();
        }
        if (ambient_cfg["volume_min"].is<unsigned int>()) {
          uint32_t parsed = ambient_cfg["volume_min"].as<uint32_t>();
          if (parsed > FREENOVE_AUDIO_MAX_VOLUME) {
            parsed = FREENOVE_AUDIO_MAX_VOLUME;
          }
          ambient.volume_min = static_cast<uint8_t>(parsed);
        }
        if (ambient_cfg["volume_max"].is<unsigned int>()) {
          uint32_t parsed = ambient_cfg["volume_max"].as<uint32_t>();
          if (parsed > FREENOVE_AUDIO_MAX_VOLUME) {
            parsed = FREENOVE_AUDIO_MAX_VOLUME;
          }
          ambient.volume_max = static_cast<uint8_t>(parsed);
        }
        if (ambient_cfg["delay_min_ms"].is<unsigned int>()) {
          ambient.delay_min_ms = ambient_cfg["delay_min_ms"].as<uint32_t>();
        }
        if (ambient_cfg["delay_max_ms"].is<unsigned int>()) {
          ambient.delay_max_ms = ambient_cfg["delay_max_ms"].as<uint32_t>();
        }
      }

      if (is_lefou_scene && document["render"]["lefou_detector"].is<JsonObjectConst>()) {
        const JsonObjectConst lefou_render = document["render"]["lefou_detector"].as<JsonObjectConst>();
        if (lefou_render["note_sequence_hz"].is<JsonArrayConst>()) {
          const JsonArrayConst note_sequence = lefou_render["note_sequence_hz"].as<JsonArrayConst>();
          uint8_t note_count = 0U;
          for (uint8_t idx = 0U; idx < RuntimeHardwareConfig::kLaSequenceMaxNotes; ++idx) {
            lefou_sequence_hz[idx] = 0U;
          }
          for (JsonVariantConst note_node : note_sequence) {
            if (!note_node.is<unsigned int>()) {
              continue;
            }
            const uint32_t parsed_hz = note_node.as<uint32_t>();
            if (parsed_hz < 40U || parsed_hz > 3000U) {
              continue;
            }
            if (note_count >= RuntimeHardwareConfig::kLaSequenceMaxNotes) {
              break;
            }
            lefou_sequence_hz[note_count++] = static_cast<uint16_t>(parsed_hz);
          }
          lefou_sequence_count = note_count;
        }
        if (lefou_render["note_hold_ms"].is<unsigned int>()) {
          lefou_note_hold_ms = static_cast<uint16_t>(lefou_render["note_hold_ms"].as<uint32_t>());
        }
      }
      if (document["render"]["warning"]["siren"].is<bool>()) {
        warning_siren = document["render"]["warning"]["siren"].as<bool>();
      }
    }
  }

  if (ws.single_random_blink) {
    ws.one_led_at_a_time = true;
  }
  if (ws.level_sync && !ws.single_random_blink) {
    ws.level_sync = false;
  }
  if (backlight.level_sync) {
    if (backlight.max_level > 99U) {
      backlight.max_level = 99U;
    }
    if (is_u_son_proto && backlight.min_level < 30U) {
      backlight.min_level = 30U;
    }
    if (is_u_son_proto && backlight.base_level < 30U) {
      backlight.base_level = 30U;
    }
    if (backlight.base_level > backlight.max_level) {
      backlight.base_level = backlight.max_level;
    }
    if (backlight.min_level > backlight.max_level) {
      backlight.min_level = backlight.max_level;
    }
    if (backlight.base_level < backlight.min_level) {
      backlight.base_level = backlight.min_level;
    }
  }
  if (ws.level_sync) {
    if (ws.max_brightness > 99U) {
      ws.max_brightness = 99U;
    }
    if (ws.min_brightness > ws.max_brightness) {
      ws.min_brightness = ws.max_brightness;
    }
    if (ws.brightness < ws.min_brightness) {
      ws.brightness = ws.min_brightness;
    } else if (ws.brightness > ws.max_brightness) {
      ws.brightness = ws.max_brightness;
    }
  }
  if (ambient.volume_min > FREENOVE_AUDIO_MAX_VOLUME) {
    ambient.volume_min = FREENOVE_AUDIO_MAX_VOLUME;
  }
  if (ambient.volume_max > FREENOVE_AUDIO_MAX_VOLUME) {
    ambient.volume_max = FREENOVE_AUDIO_MAX_VOLUME;
  }
  if (ambient.volume_min > ambient.volume_max) {
    ambient.volume_min = ambient.volume_max;
  }
  {
    String mode = ambient.mode;
    mode.trim();
    mode.toLowerCase();
    if (mode.isEmpty()) {
      mode = "single_track";
    }
    std::strncpy(ambient.mode, mode.c_str(), sizeof(ambient.mode) - 1U);
    ambient.mode[sizeof(ambient.mode) - 1U] = '\0';
  }
  if (is_u_son_proto) {
    ambient.enabled = true;
    ambient.non_interruptive = true;
    ambient.playlist_recursive = true;
    if (ambient.track[0] == '\0') {
      std::strncpy(ambient.track, kUsonAmbientTrack, sizeof(ambient.track) - 1U);
      ambient.track[sizeof(ambient.track) - 1U] = '\0';
    }
    if (!isAmbientRandomFileModeToken(ambient.mode)) {
      std::strncpy(ambient.mode, "random_file", sizeof(ambient.mode) - 1U);
      ambient.mode[sizeof(ambient.mode) - 1U] = '\0';
    }
    if (ambient.playlist_root[0] == '\0') {
      std::strncpy(ambient.playlist_root, "/music/v8_pack", sizeof(ambient.playlist_root) - 1U);
      ambient.playlist_root[sizeof(ambient.playlist_root) - 1U] = '\0';
    }
    ambient.volume_min = kUsonAmbientVolumeMin;
    ambient.volume_max = kUsonAmbientVolumeMax;
    ambient.delay_min_ms = kUsonAmbientDelayMinMs;
    ambient.delay_max_ms = kUsonAmbientDelayMaxMs;
  }
  if (ambient.delay_min_ms < 1000U) {
    ambient.delay_min_ms = 1000U;
  }
  if (ambient.delay_max_ms < ambient.delay_min_ms) {
    ambient.delay_max_ms = ambient.delay_min_ms;
  }
  if (ambient.delay_max_ms > kUsonAmbientDelayMaxMs) {
    ambient.delay_max_ms = kUsonAmbientDelayMaxMs;
  }
  if (!isAmbientRandomFileModeToken(ambient.mode)) {
    ambient.playlist_root[0] = '\0';
  }

  g_hardware_cfg.mic_la_sequence_enabled = false;
  g_hardware_cfg.mic_la_sequence_count = 0U;
  g_hardware_cfg.mic_la_sequence_note_hold_ms = 350U;
  for (uint8_t idx = 0U; idx < RuntimeHardwareConfig::kLaSequenceMaxNotes; ++idx) {
    g_hardware_cfg.mic_la_sequence_hz[idx] = 0U;
  }
  if (is_lefou_scene) {
    g_hardware_cfg.mic_la_timeout_ms = 0U;
    if (lefou_note_hold_ms < 100U) {
      lefou_note_hold_ms = 100U;
    } else if (lefou_note_hold_ms > 3000U) {
      lefou_note_hold_ms = 3000U;
    }
    g_hardware_cfg.mic_la_sequence_note_hold_ms = lefou_note_hold_ms;
    if (lefou_sequence_count > RuntimeHardwareConfig::kLaSequenceMaxNotes) {
      lefou_sequence_count = RuntimeHardwareConfig::kLaSequenceMaxNotes;
    }
    g_hardware_cfg.mic_la_sequence_count = lefou_sequence_count;
    g_hardware_cfg.mic_la_sequence_enabled = (lefou_sequence_count > 0U);
    for (uint8_t idx = 0U; idx < lefou_sequence_count; ++idx) {
      g_hardware_cfg.mic_la_sequence_hz[idx] = lefou_sequence_hz[idx];
    }
    if (g_hardware_cfg.mic_la_sequence_count > 0U) {
      g_hardware_cfg.mic_la_target_hz = g_hardware_cfg.mic_la_sequence_hz[0];
    }
  } else if (g_hardware_cfg.mic_la_timeout_ms == 0U) {
    // SCENE_LEFOU disables timeout; restore the default timeout on other scenes.
    g_hardware_cfg.mic_la_timeout_ms = RuntimeHardwareConfig().mic_la_timeout_ms;
  }

  g_scene_backlight_fx = backlight;
  g_scene_ws2812_fx = ws;
  g_scene_ambient_audio_fx = ambient;
  g_scene_warning_siren_fx.enabled = is_warning_scene && warning_siren;
  if (!g_scene_warning_siren_fx.enabled) {
    g_scene_warning_siren_fx.local_fallback_logged = false;
  }
  g_scene_ws2812_runtime_brightness = ws.brightness;
  g_scene_ws2812_runtime_next_update_ms = 0U;
  const char* backlight_mode = "static";
  if (g_scene_backlight_fx.enabled) {
    backlight_mode = g_scene_backlight_fx.level_sync ? "level_sync" : "glitch_sync";
  }
  std::strncpy(g_scene_backlight_mode,
               backlight_mode,
               sizeof(g_scene_backlight_mode) - 1U);
  g_scene_backlight_mode[sizeof(g_scene_backlight_mode) - 1U] = '\0';
  g_hardware.setSceneSingleRandomBlink(g_scene_ws2812_fx.single_random_blink,
                                       g_scene_ws2812_fx.r,
                                       g_scene_ws2812_fx.g,
                                       g_scene_ws2812_fx.b,
                                       g_scene_ws2812_runtime_brightness,
                                       g_scene_ws2812_fx.dominant_band_single);
}

void updateSceneBacklightFx(uint32_t now_ms) {
  if (!g_scene_backlight_fx.enabled) {
    if (g_lcd_backlight_level != g_lcd_backlight_base_level) {
      applyLcdBacklightEffective(g_lcd_backlight_base_level);
    }
    return;
  }

  SceneBacklightFxState& fx = g_scene_backlight_fx;
  if (fx.min_level > fx.max_level) {
    const uint8_t swap = fx.min_level;
    fx.min_level = fx.max_level;
    fx.max_level = swap;
  }
  if (fx.max_level > 125U) {
    fx.max_level = 125U;
  }
  if (fx.base_level < fx.min_level) {
    fx.base_level = fx.min_level;
  } else if (fx.base_level > fx.max_level) {
    fx.base_level = fx.max_level;
  }

  if (fx.level_sync) {
    const HardwareManager::Snapshot& hw = g_hardware.snapshotRef();
    uint8_t level_pct = hw.mic_level_percent;
    if (level_pct > 100U) {
      level_pct = 100U;
    }
    int level = static_cast<int>(fx.base_level);
    if (fx.max_level > fx.base_level) {
      const uint16_t span = static_cast<uint16_t>(fx.max_level - fx.base_level);
      level = static_cast<int>(fx.base_level + static_cast<uint8_t>((static_cast<uint16_t>(span) * level_pct) / 100U));
    }
    if (level_pct < 5U) {
      level = fx.min_level;
    }
    if (fx.spike_pct > 0U && level_pct > 12U) {
      const uint32_t noise = hashSceneFxSeed(now_ms ^ (static_cast<uint32_t>(level_pct) << 12U) ^ 0x42A7UL);
      const uint8_t spike_chance =
          static_cast<uint8_t>((static_cast<uint16_t>(fx.spike_pct) * static_cast<uint16_t>(level_pct)) / 100U);
      if ((noise % 100U) < spike_chance) {
        const uint8_t boost = static_cast<uint8_t>(2U + ((noise >> 8U) % 14U));
        level += boost;
      }
    }
    if (level < fx.min_level) {
      level = fx.min_level;
    } else if (level > fx.max_level) {
      level = fx.max_level;
    }
    const uint8_t target = static_cast<uint8_t>(level);
    if (target != g_lcd_backlight_level) {
      applyLcdBacklightEffective(target);
    }
    return;
  }

  if (fx.next_spike_ms == 0U || static_cast<int32_t>(now_ms - fx.next_spike_ms) >= 0) {
    const uint32_t noise = hashSceneFxSeed(now_ms ^ (static_cast<uint32_t>(fx.glitch_pct) << 8U) ^
                                           (static_cast<uint32_t>(fx.spike_pct) << 16U));
    uint8_t spike_chance = static_cast<uint8_t>(
        (static_cast<uint16_t>(fx.spike_pct) * static_cast<uint16_t>(50U + fx.glitch_pct)) / 100U);
    if (spike_chance > 95U) {
      spike_chance = 95U;
    }
    if ((noise % 100U) < spike_chance) {
      fx.spike_end_ms = now_ms + 12U + ((noise >> 8U) % 38U);
      const uint8_t span = static_cast<uint8_t>(fx.max_level - fx.min_level);
      const uint8_t spike_add = static_cast<uint8_t>(4U + ((noise >> 16U) % (span > 0U ? span : 1U)));
      uint16_t level = static_cast<uint16_t>(fx.base_level) + spike_add;
      if (level > fx.max_level) {
        level = fx.max_level;
      }
      fx.spike_level = static_cast<uint8_t>(level);
    } else {
      fx.spike_end_ms = 0U;
      fx.spike_level = fx.base_level;
    }
    fx.next_spike_ms = now_ms + 180U + ((noise >> 21U) % 1300U);
  }

  int level = static_cast<int>(fx.base_level);
  const int jitter_span = (fx.glitch_pct < 20U) ? 0 : static_cast<int>(fx.glitch_pct / 25U);
  if (jitter_span > 0) {
    const uint32_t noise = hashSceneFxSeed(now_ms ^ 0x45A2B31UL);
    const uint32_t range = static_cast<uint32_t>(jitter_span * 2 + 1);
    level += static_cast<int>(noise % range) - jitter_span;
  }
  if (fx.spike_end_ms != 0U && static_cast<int32_t>(now_ms - fx.spike_end_ms) < 0) {
    level = fx.spike_level;
  }

  if (level < fx.min_level) {
    level = fx.min_level;
  } else if (level > fx.max_level) {
    level = fx.max_level;
  }
  const uint8_t target = static_cast<uint8_t>(level);
  if (target != g_lcd_backlight_level) {
    applyLcdBacklightEffective(target);
  }
}

void updateSceneWs2812Fx(uint32_t now_ms) {
  if (!g_scene_ws2812_fx.single_random_blink || !g_scene_ws2812_fx.level_sync || !g_hardware_started) {
    return;
  }
  if (now_ms < g_scene_ws2812_runtime_next_update_ms) {
    return;
  }
  g_scene_ws2812_runtime_next_update_ms = now_ms + 70U;

  uint8_t min_brightness = g_scene_ws2812_fx.min_brightness;
  uint8_t max_brightness = g_scene_ws2812_fx.max_brightness;
  if (max_brightness > 99U) {
    max_brightness = 99U;
  }
  if (min_brightness > max_brightness) {
    min_brightness = max_brightness;
  }

  const HardwareManager::Snapshot& hw = g_hardware.snapshotRef();
  uint8_t level_pct = hw.mic_level_percent;
  if (level_pct > 100U) {
    level_pct = 100U;
  }
  uint8_t target = min_brightness;
  if (max_brightness > min_brightness) {
    const uint16_t span = static_cast<uint16_t>(max_brightness - min_brightness);
    target = static_cast<uint8_t>(min_brightness + static_cast<uint8_t>((span * level_pct) / 100U));
  }
  if (level_pct > 55U) {
    const uint32_t noise = hashSceneFxSeed(now_ms ^ 0x9A5F13UL ^ (static_cast<uint32_t>(level_pct) << 6U));
    const uint8_t chance = static_cast<uint8_t>(6U + (g_scene_backlight_fx.glitch_pct / 8U));
    if ((noise % 100U) < chance) {
      const uint8_t boost = static_cast<uint8_t>(4U + ((noise >> 8U) % 10U));
      const uint16_t boosted = static_cast<uint16_t>(target) + boost;
      target = static_cast<uint8_t>((boosted > max_brightness) ? max_brightness : boosted);
    }
  }

  uint8_t out_r = g_scene_ws2812_fx.r;
  uint8_t out_g = g_scene_ws2812_fx.g;
  uint8_t out_b = g_scene_ws2812_fx.b;
  if (g_scene_ws2812_fx.dynamic_color) {
    const uint16_t peak_hz = (hw.mic_spectrum_peak_hz > 0U) ? hw.mic_spectrum_peak_hz : hw.mic_freq_hz;
    if (peak_hz > 0U) {
      if (peak_hz < 430U) {
        out_r = 0xFFU;
        out_g = 0x38U;
        out_b = 0x28U;
      } else if (peak_hz > 450U) {
        out_r = 0x36U;
        out_g = 0x7EU;
        out_b = 0xFFU;
      } else {
        out_r = 0x4FU;
        out_g = 0xFFU;
        out_b = 0x7CU;
      }
    } else {
      out_r = static_cast<uint8_t>(32U + ((223U * level_pct) / 100U));
      out_g = static_cast<uint8_t>(32U + ((191U * level_pct) / 100U));
      out_b = static_cast<uint8_t>(255U - ((191U * level_pct) / 100U));
    }
  }

  if (target == g_scene_ws2812_runtime_brightness &&
      out_r == g_scene_ws2812_fx.r &&
      out_g == g_scene_ws2812_fx.g &&
      out_b == g_scene_ws2812_fx.b) {
    return;
  }
  g_scene_ws2812_fx.r = out_r;
  g_scene_ws2812_fx.g = out_g;
  g_scene_ws2812_fx.b = out_b;
  g_scene_ws2812_runtime_brightness = target;
  g_hardware.setSceneSingleRandomBlink(true,
                                       out_r,
                                       out_g,
                                       out_b,
                                       g_scene_ws2812_runtime_brightness,
                                       g_scene_ws2812_fx.dominant_band_single);
}

void updateWarningSirenFx(uint32_t now_ms) {
  SceneWarningSirenFxState& siren = g_scene_warning_siren_fx;

  if (!siren.enabled) {
    if (siren.remote_started) {
      const bool stop_ok = sendWarningSirenSync("STOP", 0U);
      ZACUS_RL_LOG_MS(1200U, "[WARN_SIREN] remote stop ok=%u\n", stop_ok ? 1U : 0U);
    }
    siren.remote_started = false;
    siren.started_ms = 0U;
    siren.next_beat_ms = 0U;
    siren.next_retry_ms = 0U;
    siren.local_fallback_logged = false;
    return;
  }

  if (!siren.local_backend_available && !siren.local_fallback_logged) {
    Serial.println("[WARN_SIREN] local backend unavailable, syncing siren to A252 only");
    siren.local_fallback_logged = true;
  }

  if (!siren.remote_started) {
    if (!warningSirenRemoteSyncReady()) {
      siren.next_retry_ms = now_ms + 2000U;
      ZACUS_RL_LOG_MS(2000U, "[WARN_SIREN] remote sync waiting peer\n");
      return;
    }
    if (siren.next_retry_ms != 0U && static_cast<int32_t>(now_ms - siren.next_retry_ms) < 0) {
      return;
    }
    const bool start_ok = sendWarningSirenSync("START", 220U);
    if (!start_ok) {
      siren.next_retry_ms = now_ms + 2000U;
      ZACUS_RL_LOG_MS(1000U, "[WARN_SIREN] remote start failed, retrying\n");
      return;
    }
    siren.remote_started = true;
    siren.started_ms = now_ms;
    siren.next_retry_ms = 0U;
    siren.next_beat_ms = now_ms + kWarningSirenBeatIntervalMs;
    Serial.println("[WARN_SIREN] remote start");
  }

  if (siren.next_beat_ms != 0U && static_cast<int32_t>(now_ms - siren.next_beat_ms) < 0) {
    return;
  }

  if (!warningSirenRemoteSyncReady()) {
    siren.remote_started = false;
    siren.next_beat_ms = 0U;
    siren.next_retry_ms = now_ms + 2000U;
    ZACUS_RL_LOG_MS(2000U, "[WARN_SIREN] remote link lost, rearm\n");
    return;
  }

  const uint32_t period_ms = 1400U;
  const uint32_t half_period_ms = period_ms / 2U;
  const uint32_t elapsed_ms = now_ms - siren.started_ms;
  const uint32_t phase_ms = elapsed_ms % period_ms;
  const uint32_t ramp = (phase_ms <= half_period_ms) ? phase_ms : (period_ms - phase_ms);
  int strength = 160 + static_cast<int>((95U * ramp) / (half_period_ms == 0U ? 1U : half_period_ms));
  if (strength > 255) {
    strength = 255;
  }
  const bool beat_ok = sendWarningSirenSync("BEAT", static_cast<uint8_t>(strength));
  if (!beat_ok) {
    ZACUS_RL_LOG_MS(1000U, "[WARN_SIREN] remote beat send failed\n");
  }
  siren.next_beat_ms = now_ms + kWarningSirenBeatIntervalMs;
}

void updateUsonProtoAmbientAudio(const ScenarioSnapshot& snapshot, uint32_t now_ms) {
  auto chooseAmbientDelayMs = [now_ms](uint32_t salt) -> uint32_t {
    uint32_t min_delay = g_scene_ambient_audio_fx.delay_min_ms;
    uint32_t max_delay = g_scene_ambient_audio_fx.delay_max_ms;
    if (max_delay < min_delay) {
      const uint32_t swap = min_delay;
      min_delay = max_delay;
      max_delay = swap;
    }
    if (min_delay < 1000U) {
      min_delay = 1000U;
    }
    if (max_delay < min_delay) {
      max_delay = min_delay;
    }
    if (max_delay == min_delay) {
      return min_delay;
    }
    const uint32_t noise = hashSceneFxSeed(now_ms ^ salt ^ g_uson_ambient_next_play_ms ^ 0x4A5C93UL);
    return min_delay + (noise % (max_delay - min_delay + 1U));
  };
  auto chooseAmbientVolume = [now_ms](uint32_t salt) -> uint8_t {
    uint8_t min_vol = g_scene_ambient_audio_fx.volume_min;
    uint8_t max_vol = g_scene_ambient_audio_fx.volume_max;
    if (min_vol > FREENOVE_AUDIO_MAX_VOLUME) {
      min_vol = FREENOVE_AUDIO_MAX_VOLUME;
    }
    if (max_vol > FREENOVE_AUDIO_MAX_VOLUME) {
      max_vol = FREENOVE_AUDIO_MAX_VOLUME;
    }
    if (max_vol < min_vol) {
      max_vol = min_vol;
    }
    if (max_vol == min_vol) {
      return min_vol;
    }
    const uint32_t noise = hashSceneFxSeed(now_ms ^ salt ^ 0x5A17B4UL);
    const uint8_t span = static_cast<uint8_t>(max_vol - min_vol);
    return static_cast<uint8_t>(min_vol + (noise % (static_cast<uint32_t>(span) + 1U)));
  };

  const bool uson_scene_active =
      (snapshot.screen_scene_id != nullptr) &&
      (std::strcmp(snapshot.screen_scene_id, "SCENE_U_SON_PROTO") == 0) &&
      g_scene_ambient_audio_fx.enabled;
  if (!uson_scene_active) {
    if (g_uson_ambient_audio_active) {
      g_audio.setVolume(g_uson_ambient_restore_volume);
      g_uson_ambient_audio_active = false;
      g_uson_ambient_next_play_ms = 0U;
    }
    return;
  }

  if (!g_uson_ambient_audio_active) {
    g_uson_ambient_audio_active = true;
    g_uson_ambient_restore_volume = g_audio.volume();
    if (!g_scene_ambient_audio_fx.non_interruptive && g_audio.isPlaying()) {
      g_audio.stop();
    }
    g_uson_ambient_next_play_ms = now_ms;
  }

  if (now_ms < g_uson_ambient_next_play_ms) {
    return;
  }
  if (g_scene_ambient_audio_fx.non_interruptive && g_audio.isPlaying()) {
    g_uson_ambient_next_play_ms = now_ms + 600U;
    return;
  }
  if (!g_scene_ambient_audio_fx.non_interruptive && g_audio.isPlaying()) {
    g_audio.stop();
  }

  const uint8_t volume = chooseAmbientVolume(0x0091A24UL);
  g_audio.setVolume(volume);
  String ambient_track_path;
  if (!chooseAmbientPlaylistTrack(g_scene_ambient_audio_fx, now_ms, &ambient_track_path)) {
    ambient_track_path =
        (g_scene_ambient_audio_fx.track[0] != '\0') ? String(g_scene_ambient_audio_fx.track) : String(kUsonAmbientTrack);
  }
  const bool ok = g_audio.play(ambient_track_path.c_str());
  if (!ok) {
    g_uson_ambient_next_play_ms = now_ms + 4000U;
    Serial.printf("[AUDIO] uson ambient missing/failed track=%s\n", ambient_track_path.c_str());
    return;
  }

  const uint32_t next_delay_ms = chooseAmbientDelayMs(0x007A3B2UL);
  g_uson_ambient_next_play_ms = now_ms + next_delay_ms;
  Serial.printf("[AUDIO] uson ambient play track=%s volume=%u non_interruptive=%u next_in_ms=%lu\n",
                ambient_track_path.c_str(),
                static_cast<unsigned int>(volume),
                g_scene_ambient_audio_fx.non_interruptive ? 1U : 0U,
                static_cast<unsigned long>(next_delay_ms));
}

bool parseBootModeToken(const char* token, BootModeStore::StartupMode* out_mode) {
  if (token == nullptr || out_mode == nullptr) {
    return false;
  }
  if (std::strcmp(token, "STORY") == 0) {
    *out_mode = BootModeStore::StartupMode::kStory;
    return true;
  }
  if (std::strcmp(token, "MEDIA_MANAGER") == 0 || std::strcmp(token, "MEDIA") == 0) {
    *out_mode = BootModeStore::StartupMode::kMediaManager;
    return true;
  }
  return false;
}

void applySceneResourcePolicy(const ScenarioSnapshot& snapshot) {
  if (!g_resource_profile_auto) {
    return;
  }
  const char* screen_scene_id = (snapshot.screen_scene_id != nullptr) ? snapshot.screen_scene_id : "n/a";
  const bool is_win_etape_step =
      (snapshot.step != nullptr && snapshot.step->id != nullptr &&
       std::strcmp(snapshot.step->id, kStepWinEtape) == 0 &&
       snapshot.audio_pack_id != nullptr && std::strcmp(snapshot.audio_pack_id, kPackWin) == 0);
  const bool is_win_etape_scene = (screen_scene_id != nullptr &&
                                   (std::strcmp(screen_scene_id, "SCENE_WIN_ETAPE") == 0 ||
                                    std::strcmp(screen_scene_id, "SCENE_WIN_ETAPE1") == 0 ||
                                    std::strcmp(screen_scene_id, "SCENE_WIN_ETAPE2") == 0));
  const bool is_win_etape = is_win_etape_step || is_win_etape_scene;
  const bool is_la_scene =
      (screen_scene_id != nullptr && std::strstr(screen_scene_id, "LA") != nullptr) ||
      (snapshot.step != nullptr && snapshot.step->id != nullptr && std::strstr(snapshot.step->id, "LA") != nullptr);
  const bool is_uson_scene =
      (screen_scene_id != nullptr && std::strcmp(screen_scene_id, "SCENE_U_SON_PROTO") == 0);
  const bool requires_mic =
      g_hardware_cfg.mic_enabled &&
      (is_la_scene || is_uson_scene || g_la_trigger.gate_active || g_la_trigger.timeout_pending);
  const runtime::resource::ResourceProfile target =
      is_win_etape ? runtime::resource::ResourceProfile::kGfxFocus
                   : (requires_mic ? runtime::resource::ResourceProfile::kGfxPlusMic
                                   : runtime::resource::ResourceProfile::kGfxPlusCamSnapshot);
  if (g_resource_coordinator.profile() != target) {
    g_resource_coordinator.setProfile(target);
    Serial.printf("[RESOURCE] auto profile=%s scene=%s screen=%s pack=%s\n",
                  g_resource_coordinator.profileName(),
                  snapshot.step != nullptr && snapshot.step->id != nullptr ? snapshot.step->id : "n/a",
                  screen_scene_id,
                  snapshot.audio_pack_id != nullptr ? snapshot.audio_pack_id : "n/a");
  }
}

void applyResourceProfileAutoCommand(const char* arg, bool* out_parse_ok) {
  bool enabled = false;
  bool parse_ok = parseBoolToken(arg, &enabled);
  if (out_parse_ok != nullptr) {
    *out_parse_ok = parse_ok;
  }
  if (!parse_ok) {
    return;
  }
  g_resource_profile_auto = enabled;
  if (g_resource_profile_auto) {
    applySceneResourcePolicy(g_scenario.snapshot());
  }
}

void applyMicRuntimePolicy() {
  if (!g_hardware_started) {
    return;
  }
  const bool should_run =
      g_hardware_cfg.mic_enabled &&
      (g_resource_coordinator.shouldRunMic() || g_la_trigger.gate_active || g_la_trigger.timeout_pending);
  g_hardware.setMicRuntimeEnabled(should_run);
}

void logBootMemoryProfile() {
#if defined(ARDUINO_ARCH_ESP32)
  const BootHeapSnapshot heap = bootCaptureHeapSnapshot();
  Serial.printf("[MEM] free_heap=%u min_free_heap=%u total_heap=%u\n",
                static_cast<unsigned int>(ESP.getFreeHeap()),
                static_cast<unsigned int>(ESP.getMinFreeHeap()),
                static_cast<unsigned int>(ESP.getHeapSize()));
  Serial.printf("[MEM] internal_free=%lu internal_largest=%lu\n",
                static_cast<unsigned long>(heap.heap_internal_free),
                static_cast<unsigned long>(heap.heap_internal_largest));
  Serial.printf("[MEM] psram_found=%u total_psram=%lu free_psram=%lu largest_psram=%lu\n",
                heap.psram_found ? 1U : 0U,
                static_cast<unsigned long>(heap.psram_total),
                static_cast<unsigned long>(heap.heap_psram_free),
                static_cast<unsigned long>(heap.heap_psram_largest));
  if (heap.psram_total == 0U) {
    Serial.println("[MEM] PSRAM expected by build flags but not detected");
  }
#endif
}

void logBuildMemoryPolicy() {
  Serial.printf("[CFG] UI_DRAW_BUF_IN_PSRAM=%d FREENOVE_PSRAM_UI_DRAW_BUFFER=%d UI_CAMERA_FB_IN_PSRAM=%d FREENOVE_PSRAM_CAMERA_FRAMEBUFFER=%d UI_AUDIO_RINGBUF_IN_PSRAM=%d UI_DMA_TX_IN_DRAM=%d\n",
                UI_DRAW_BUF_IN_PSRAM,
                FREENOVE_PSRAM_UI_DRAW_BUFFER,
                UI_CAMERA_FB_IN_PSRAM,
                FREENOVE_PSRAM_CAMERA_FRAMEBUFFER,
                UI_AUDIO_RINGBUF_IN_PSRAM,
                UI_DMA_TX_IN_DRAM);
#if defined(FREENOVE_PSRAM_UI_DRAW_BUFFER)
  if (UI_DRAW_BUF_IN_PSRAM != FREENOVE_PSRAM_UI_DRAW_BUFFER) {
    Serial.println("[CFG] WARN draw-buffer PSRAM flags mismatch");
  }
#endif
#if defined(FREENOVE_PSRAM_CAMERA_FRAMEBUFFER)
  if (UI_CAMERA_FB_IN_PSRAM != FREENOVE_PSRAM_CAMERA_FRAMEBUFFER) {
    Serial.println("[CFG] WARN camera framebuffer PSRAM flags mismatch");
  }
#endif
}

bool parseBoolToken(const char* text, bool* out_value) {
  if (text == nullptr || out_value == nullptr) {
    return false;
  }
  char normalized[16] = {0};
  copyText(normalized, sizeof(normalized), text);
  trimAsciiInPlace(normalized);
  toLowerAsciiInPlace(normalized);
  if (std::strcmp(normalized, "1") == 0 || std::strcmp(normalized, "true") == 0 || std::strcmp(normalized, "on") == 0 ||
      std::strcmp(normalized, "yes") == 0) {
    *out_value = true;
    return true;
  }
  if (std::strcmp(normalized, "0") == 0 || std::strcmp(normalized, "false") == 0 || std::strcmp(normalized, "off") == 0 ||
      std::strcmp(normalized, "no") == 0) {
    *out_value = false;
    return true;
  }
  return false;
}

void resetLaTriggerState(bool keep_cooldown = true) {
  LaTriggerService::resetState(&g_la_trigger, keep_cooldown);
}

bool shouldEnforceLaMatchOnly(const ScenarioSnapshot& snapshot) {
  return LaTriggerService::shouldEnforceMatchOnly(g_hardware_cfg, snapshot);
}

bool notifyScenarioButtonGuarded(uint8_t key, bool long_press, uint32_t now_ms, const char* source_tag) {
  const ScenarioSnapshot snapshot = g_scenario.snapshot();
  const bool is_la_trigger_step = LaTriggerService::isTriggerStep(snapshot);
  const bool is_reset_key = (key >= 1U && key <= 5U);
  const bool enforce_la_match_only = shouldEnforceLaMatchOnly(snapshot);
  if (is_la_trigger_step && is_reset_key) {
    const char* resolved_source = (source_tag != nullptr && source_tag[0] != '\0') ? source_tag : "unknown";
    LaTriggerService::resetTimeout(&g_la_trigger, now_ms, resolved_source);
    g_scenario.notifyButton(key, long_press, now_ms);
    if (enforce_la_match_only) {
      Serial.printf("[LA_TRIGGER] reset LA timeout for key=%u long=%u source=%s while waiting LA match\n",
                    static_cast<unsigned int>(key),
                    long_press ? 1U : 0U,
                    resolved_source);
      return false;
    }
    return true;
  }
  if (enforce_la_match_only) {
    Serial.printf("[LA_TRIGGER] ignore scenario button key=%u long=%u source=%s while waiting LA match\n",
                  static_cast<unsigned int>(key),
                  long_press ? 1U : 0U,
                  (source_tag != nullptr && source_tag[0] != '\0') ? source_tag : "n/a");
    return false;
  }
  g_scenario.notifyButton(key, long_press, now_ms);
  return true;
}

uint8_t laStablePercent() {
  return LaTriggerService::stablePercent(g_hardware_cfg, g_la_trigger);
}

void startLaTimeoutRecovery(const ScenarioSnapshot& snapshot, uint32_t now_ms) {
  (void)snapshot;
  g_la_trigger.timeout_pending = false;
  g_la_trigger.timeout_deadline_ms = 0U;
  g_la_trigger.dispatched = false;
  g_la_trigger.locked = false;
  g_la_trigger.sample_match = false;
  g_la_dispatch_in_progress = false;

  g_audio.stop();
  g_last_action_step_key[0] = '\0';
  g_win_etape_ui_refresh_pending = false;
  if (g_hardware_started) {
    g_hardware.clearManualLed();
    if (g_hardware_cfg.led_auto_from_scene) {
      g_hardware.setSceneHint("SCENE_U_SON_PROTO");
    }
  }
  LaTriggerService::resetState(&g_la_trigger, false);
  const bool routed = g_scenario.gotoScene("SCENE_U_SON_PROTO", now_ms, "la_timeout_recovery");
  Serial.printf("[LA_TRIGGER] timeout -> goto SCENE_U_SON_PROTO routed=%u\n", routed ? 1U : 0U);
  refreshSceneIfNeeded(true);
}

void updateLaGameplayTrigger(const ScenarioSnapshot& snapshot, const HardwareManager::Snapshot& hw, uint32_t now_ms) {
  const LaTriggerService::UpdateResult update =
      LaTriggerService::update(g_hardware_cfg, &g_la_trigger, snapshot, hw, now_ms);
  if (update.timed_out) {
    Serial.printf(
        "[LA_TRIGGER] timeout after %lu ms (freq=%u cents=%d conf=%u level=%u)\n",
        static_cast<unsigned long>(now_ms - g_la_trigger.gate_entered_ms),
        static_cast<unsigned int>(hw.mic_freq_hz),
        static_cast<int>(hw.mic_pitch_cents),
        static_cast<unsigned int>(hw.mic_pitch_confidence),
        static_cast<unsigned int>(hw.mic_level_percent));
    startLaTimeoutRecovery(snapshot, now_ms);
    return;
  }
  if (!update.lock_ready) {
    return;
  }

  const char* event_name =
      (g_hardware_cfg.mic_la_event_name[0] != '\0') ? g_hardware_cfg.mic_la_event_name : "SERIAL:BTN_NEXT";
  const ScenarioSnapshot before = g_scenario.snapshot();
  g_la_dispatch_in_progress = true;
  const bool dispatched = dispatchScenarioEventByName(event_name, now_ms);
  g_la_dispatch_in_progress = false;
  const ScenarioSnapshot after = g_scenario.snapshot();
  const bool changed = std::strcmp(stepIdFromSnapshot(before), stepIdFromSnapshot(after)) != 0;
  Serial.printf(
      "[LA_TRIGGER] dispatched=%u changed=%u event=%s step=%s freq=%u cents=%d conf=%u level=%u stable_ms=%lu gate=%u\n",
      dispatched ? 1U : 0U,
      changed ? 1U : 0U,
      event_name,
      stepIdFromSnapshot(after),
      static_cast<unsigned int>(hw.mic_freq_hz),
      static_cast<int>(hw.mic_pitch_cents),
      static_cast<unsigned int>(hw.mic_pitch_confidence),
      static_cast<unsigned int>(hw.mic_level_percent),
      static_cast<unsigned long>(g_la_trigger.stable_ms),
      update.gate_active ? 1U : 0U);
  if (dispatched) {
    g_la_trigger.dispatched = true;
    g_la_trigger.last_trigger_ms = now_ms;
  }
}

bool startsWithIgnoreCase(const char* text, const char* prefix) {
  if (text == nullptr || prefix == nullptr) {
    return false;
  }
  size_t index = 0U;
  while (prefix[index] != '\0') {
    if (text[index] == '\0') {
      return false;
    }
    const char lhs = static_cast<char>(std::tolower(static_cast<unsigned char>(text[index])));
    const char rhs = static_cast<char>(std::tolower(static_cast<unsigned char>(prefix[index])));
    if (lhs != rhs) {
      return false;
    }
    ++index;
  }
  return true;
}

const char* eventTypeName(StoryEventType type) {
  switch (type) {
    case StoryEventType::kUnlock:
      return "unlock";
    case StoryEventType::kAudioDone:
      return "audio_done";
    case StoryEventType::kTimer:
      return "timer";
    case StoryEventType::kSerial:
      return "serial";
    case StoryEventType::kButton:
      return "button";
    case StoryEventType::kEspNow:
      return "espnow";
    case StoryEventType::kAction:
      return "action";
    default:
      return "none";
  }
}

bool parseEventType(const char* text, StoryEventType* out_type) {
  if (text == nullptr || out_type == nullptr) {
    return false;
  }
  char normalized[20] = {0};
  std::strncpy(normalized, text, sizeof(normalized) - 1U);
  toLowerAsciiInPlace(normalized);
  if (std::strcmp(normalized, "unlock") == 0) {
    *out_type = StoryEventType::kUnlock;
    return true;
  }
  if (std::strcmp(normalized, "audio_done") == 0) {
    *out_type = StoryEventType::kAudioDone;
    return true;
  }
  if (std::strcmp(normalized, "timer") == 0) {
    *out_type = StoryEventType::kTimer;
    return true;
  }
  if (std::strcmp(normalized, "serial") == 0) {
    *out_type = StoryEventType::kSerial;
    return true;
  }
  if (std::strcmp(normalized, "button") == 0 || std::strcmp(normalized, "btn") == 0) {
    *out_type = StoryEventType::kButton;
    return true;
  }
  if (std::strcmp(normalized, "espnow") == 0 || std::strcmp(normalized, "esp_now") == 0) {
    *out_type = StoryEventType::kEspNow;
    return true;
  }
  if (std::strcmp(normalized, "action") == 0) {
    *out_type = StoryEventType::kAction;
    return true;
  }
  return false;
}

const char* defaultEventNameForType(StoryEventType type) {
  switch (type) {
    case StoryEventType::kUnlock:
      return "UNLOCK";
    case StoryEventType::kAudioDone:
      return "AUDIO_DONE";
    case StoryEventType::kTimer:
      return "ETAPE2_DUE";
    case StoryEventType::kSerial:
      return "BTN_NEXT";
    case StoryEventType::kButton:
      return "ANY";
    case StoryEventType::kEspNow:
      return "EVENT";
    case StoryEventType::kAction:
      return "ACTION_FORCE_ETAPE2";
    default:
      return "";
  }
}

bool buildEventTokenFromTypeName(StoryEventType type,
                                 const char* event_name,
                                 char* out_event,
                                 size_t out_capacity) {
  if (out_event == nullptr || out_capacity == 0U) {
    return false;
  }
  const char* resolved_name = (event_name != nullptr && event_name[0] != '\0') ? event_name : defaultEventNameForType(type);
  char normalized_name[64] = {0};
  copyText(normalized_name, sizeof(normalized_name), resolved_name);
  trimAsciiInPlace(normalized_name);
  toUpperAsciiInPlace(normalized_name);

  switch (type) {
    case StoryEventType::kUnlock:
      if (normalized_name[0] == '\0' || std::strcmp(normalized_name, "UNLOCK") == 0) {
        copyText(out_event, out_capacity, "UNLOCK");
      } else {
        snprintf(out_event, out_capacity, "UNLOCK:%s", normalized_name);
      }
      return true;
    case StoryEventType::kAudioDone:
      copyText(out_event, out_capacity, "AUDIO_DONE");
      return true;
    case StoryEventType::kTimer:
      snprintf(out_event, out_capacity, "TIMER:%s", normalized_name[0] != '\0' ? normalized_name : "ETAPE2_DUE");
      return true;
    case StoryEventType::kSerial:
      snprintf(out_event, out_capacity, "SERIAL:%s", normalized_name[0] != '\0' ? normalized_name : "BTN_NEXT");
      return true;
    case StoryEventType::kButton:
      snprintf(out_event, out_capacity, "BUTTON:%s", normalized_name[0] != '\0' ? normalized_name : "ANY");
      return true;
    case StoryEventType::kEspNow:
      snprintf(out_event, out_capacity, "ESPNOW:%s", normalized_name[0] != '\0' ? normalized_name : "EVENT");
      return true;
    case StoryEventType::kAction:
      snprintf(out_event,
               out_capacity,
               "ACTION:%s",
               normalized_name[0] != '\0' ? normalized_name : "ACTION_FORCE_ETAPE2");
      return true;
    default:
      return false;
  }
}

bool normalizeEventTokenFromText(const char* raw_text, char* out_event, size_t out_capacity) {
  if (raw_text == nullptr || out_event == nullptr || out_capacity == 0U) {
    return false;
  }

  char event[kSerialLineCapacity] = {0};
  copyText(event, sizeof(event), raw_text);
  trimAsciiInPlace(event);
  if (event[0] == '\0') {
    return false;
  }

  if (startsWithIgnoreCase(event, "SC_EVENT_RAW ")) {
    char* payload = event + 13;
    trimAsciiInPlace(payload);
    if (payload[0] == '\0') {
      return false;
    }
    copyText(out_event, out_capacity, payload);
    return true;
  }

  if (startsWithIgnoreCase(event, "SC_EVENT ")) {
    char* args = event + 9;
    trimAsciiInPlace(args);
    if (args[0] == '\0') {
      return false;
    }
    char* type_text = args;
    char* name_text = nullptr;
    for (size_t index = 0U; args[index] != '\0'; ++index) {
      if (args[index] == ' ') {
        args[index] = '\0';
        name_text = &args[index + 1U];
        break;
      }
    }
    if (name_text != nullptr) {
      trimAsciiInPlace(name_text);
      if (name_text[0] == '\0') {
        name_text = nullptr;
      }
    }
    StoryEventType parsed_type = StoryEventType::kNone;
    if (!parseEventType(type_text, &parsed_type)) {
      return false;
    }
    return buildEventTokenFromTypeName(parsed_type, name_text, out_event, out_capacity);
  }

  if (startsWithIgnoreCase(event, "SERIAL ")) {
    char* name = event + 7;
    trimAsciiInPlace(name);
    toUpperAsciiInPlace(name);
    snprintf(out_event, out_capacity, "SERIAL:%s", name[0] != '\0' ? name : "BTN_NEXT");
    return true;
  }
  if (startsWithIgnoreCase(event, "BUTTON ")) {
    char* name = event + 7;
    trimAsciiInPlace(name);
    toUpperAsciiInPlace(name);
    snprintf(out_event, out_capacity, "BUTTON:%s", name[0] != '\0' ? name : "ANY");
    return true;
  }
  if (startsWithIgnoreCase(event, "ESPNOW ")) {
    char* name = event + 7;
    trimAsciiInPlace(name);
    toUpperAsciiInPlace(name);
    snprintf(out_event, out_capacity, "ESPNOW:%s", name[0] != '\0' ? name : "EVENT");
    return true;
  }
  if (startsWithIgnoreCase(event, "TIMER ")) {
    char* name = event + 6;
    trimAsciiInPlace(name);
    toUpperAsciiInPlace(name);
    snprintf(out_event, out_capacity, "TIMER:%s", name[0] != '\0' ? name : "ETAPE2_DUE");
    return true;
  }
  if (startsWithIgnoreCase(event, "ACTION ")) {
    char* name = event + 7;
    trimAsciiInPlace(name);
    toUpperAsciiInPlace(name);
    snprintf(out_event, out_capacity, "ACTION:%s", name[0] != '\0' ? name : "ACTION_FORCE_ETAPE2");
    return true;
  }

  toUpperAsciiInPlace(event);
  copyText(out_event, out_capacity, event);
  return true;
}

bool extractEventTokenFromJsonObject(JsonVariantConst root, char* out_event, size_t out_capacity) {
  if (!root.is<JsonObjectConst>()) {
    return false;
  }

  const char* root_type = root["event_type"] | root["type"] | "";
  const char* root_name = root["event_name"] | root["name"] | "";
  if (root_type[0] != '\0') {
    StoryEventType type = StoryEventType::kNone;
    if (parseEventType(root_type, &type) && buildEventTokenFromTypeName(type, root_name, out_event, out_capacity)) {
      return true;
    }
  }

  if (root["event"].is<const char*>()) {
    const char* text = root["event"].as<const char*>();
    if (text != nullptr && text[0] != '\0' && normalizeEventTokenFromText(text, out_event, out_capacity)) {
      return true;
    }
  }

  if (root["event"].is<JsonObjectConst>()) {
    JsonVariantConst event_obj = root["event"];
    const char* event_type = event_obj["event_type"] | event_obj["type"] | "";
    const char* event_name = event_obj["event_name"] | event_obj["name"] | "";
    StoryEventType type = StoryEventType::kNone;
    if (event_type[0] != '\0' && parseEventType(event_type, &type) &&
        buildEventTokenFromTypeName(type, event_name, out_event, out_capacity)) {
      return true;
    }
    if (event_obj["cmd"].is<const char*>()) {
      const char* cmd = event_obj["cmd"].as<const char*>();
      if (cmd != nullptr && cmd[0] != '\0' && normalizeEventTokenFromText(cmd, out_event, out_capacity)) {
        return true;
      }
    }
    if (event_obj["raw"].is<const char*>()) {
      const char* raw = event_obj["raw"].as<const char*>();
      if (raw != nullptr && raw[0] != '\0' && normalizeEventTokenFromText(raw, out_event, out_capacity)) {
        return true;
      }
    }
  }

  if (root["cmd"].is<const char*>()) {
    const char* cmd = root["cmd"].as<const char*>();
    if (cmd != nullptr && cmd[0] != '\0' && normalizeEventTokenFromText(cmd, out_event, out_capacity)) {
      return true;
    }
  }
  if (root["raw"].is<const char*>()) {
    const char* raw = root["raw"].as<const char*>();
    if (raw != nullptr && raw[0] != '\0' && normalizeEventTokenFromText(raw, out_event, out_capacity)) {
      return true;
    }
  }

  if (root["payload"].is<const char*>()) {
    const char* payload = root["payload"].as<const char*>();
    if (payload != nullptr && payload[0] != '\0' && normalizeEventTokenFromText(payload, out_event, out_capacity)) {
      return true;
    }
  }
  if (root["payload"].is<JsonObjectConst>()) {
    if (extractEventTokenFromJsonObject(root["payload"], out_event, out_capacity)) {
      return true;
    }
  }
  return false;
}

bool normalizeEspNowPayloadToScenarioEvent(const char* payload_text, char* out_event, size_t out_capacity) {
  if (payload_text == nullptr || out_event == nullptr || out_capacity == 0U) {
    return false;
  }

  char normalized[kSerialLineCapacity] = {0};
  copyText(normalized, sizeof(normalized), payload_text);
  trimAsciiInPlace(normalized);
  if (normalized[0] == '\0') {
    return false;
  }

  if (normalized[0] == '{') {
    StaticJsonDocument<512> document;
    const DeserializationError error = deserializeJson(document, normalized);
    if (!error) {
      if (extractEventTokenFromJsonObject(document.as<JsonVariantConst>(), out_event, out_capacity)) {
        return true;
      }
      return false;
    }
  }

  char normalized_event[kSerialLineCapacity] = {0};
  if (!normalizeEventTokenFromText(normalized, normalized_event, sizeof(normalized_event))) {
    return false;
  }
  if (std::strchr(normalized_event, ':') == nullptr &&
      std::strcmp(normalized_event, "UNLOCK") != 0 &&
      std::strcmp(normalized_event, "AUDIO_DONE") != 0) {
    std::snprintf(out_event, out_capacity, "ESPNOW:%s", normalized_event);
    return true;
  }
  copyText(out_event, out_capacity, normalized_event);
  return true;
}

bool dispatchScenarioEventByType(StoryEventType type, const char* event_name, uint32_t now_ms);
bool dispatchScenarioEventByName(const char* event_name, uint32_t now_ms);
void handleSerialCommand(const char* command_line, uint32_t now_ms);
void handleSerialCommandImpl(const char* command_line, uint32_t now_ms);
void runtimeTickBridge(uint32_t now_ms, RuntimeServices* services);
void serialDispatchBridge(const char* command_line, uint32_t now_ms, RuntimeServices* services);
void refreshSceneIfNeeded(bool force_render);
void refreshSceneIfNeededImpl(bool force_render);
void startPendingAudioIfAny();
#if defined(USE_AUDIO) && (USE_AUDIO != 0)
bool isAmpSceneId(const char* scene_id);
bool ensureAmpInitialized();
size_t scanAmpPlaylistWithFallback();
void setAmpSceneActive(bool active);
void syncAmpSceneState(const ScenarioSnapshot& snapshot);
void printAmpStatus();
#endif
bool isCameraSceneId(const char* scene_id);
bool ensureCameraUiInitialized();
void setCameraSceneActive(bool active);
void syncCameraSceneState(const ScenarioSnapshot& snapshot);
bool dispatchCameraSceneButton(uint8_t key, bool long_press);
void printCameraRecorderStatus();
void executeStoryActionsForStep(const ScenarioSnapshot& snapshot, uint32_t now_ms);
bool executeStoryAction(const char* action_id, const ScenarioSnapshot& snapshot, uint32_t now_ms);
void webFillWifiStatus(JsonObject out, const NetworkManager::Snapshot& net);
void webFillEspNowStatus(JsonObject out, const NetworkManager::Snapshot& net);
void webFillHardwareStatus(JsonObject out);
void webFillCameraStatus(JsonObject out);
void webFillMediaStatus(JsonObject out, uint32_t now_ms);
void webFillAppsStatus(JsonObject out);
void webSendHardwareStatus();
void webSendCameraStatus();
void webSendMediaFiles();
void webSendMediaRecordStatus();
void webSendAppsList();
void webSendAppsStatus();
void webSendAppsContentHealth();
void webSendSharePeers();
void webSendShareFiles();
void webSendAuthStatus();
void webSendProvisionStatus();
bool webReconnectLocalWifi();
bool refreshStoryFromSd();
bool dispatchControlAction(const String& action_raw, uint32_t now_ms, String* out_error = nullptr);
bool dispatchControlActionImpl(const String& action_raw, uint32_t now_ms, String* out_error = nullptr);
void setupWebUiImpl();
void clearRuntimeStaCredentials();
void applyRuntimeStaCredentials(const char* ssid, const char* password);
void updateAuthPolicy();
bool ensureWebToken(bool rotate_token, bool print_token, bool* out_generated = nullptr);
void loadBootProvisioningState();
bool provisionWifiCredentials(const char* ssid,
                              const char* password,
                              bool persist,
                              bool* out_connect_started = nullptr,
                              bool* out_persisted = nullptr,
                              bool* out_token_generated = nullptr);
bool forgetWifiCredentials();
bool webAuthorizeApiRequest(const char* path);
void printHardwareStatus();
void printMicTunerStatus();
void printHardwareStatusJson();
void printUiSceneStatus();
void printCameraStatus();
void printMediaStatus();
void maybeEmitHardwareEvents(uint32_t now_ms);
void maybeLogHardwareTelemetry(uint32_t now_ms);
void maybeStreamMicTunerStatus(uint32_t now_ms);

struct EspNowCommandResult {
  bool handled = false;
  bool ok = false;
  String code;
  String error;
  String data_json;
};

void appendCompactRuntimeStatus(JsonObject out) {
  const NetworkManager::Snapshot net = g_network.snapshot();
  const ScenarioSnapshot scenario = g_scenario.snapshot();
  const HardwareManager::Snapshot& hardware = g_hardware.snapshotRef();
  const CameraManager::Snapshot camera = g_camera.snapshot();
  const MediaManager::Snapshot media = g_media.snapshot();
  out["state"] = net.state;
  out["mode"] = net.mode;
  out["ip"] = net.ip;
  out["sta_connected"] = net.sta_connected;
  out["espnow_enabled"] = net.espnow_enabled;
  out["scenario"] = scenarioIdFromSnapshot(scenario);
  out["step"] = stepIdFromSnapshot(scenario);
  out["screen"] = (scenario.screen_scene_id != nullptr) ? scenario.screen_scene_id : "";
  out["audio_pack"] = (scenario.audio_pack_id != nullptr) ? scenario.audio_pack_id : "";
  out["audio_playing"] = g_audio.isPlaying();
  JsonObject espnow = out["espnow"].to<JsonObject>();
  espnow["device_name"] = g_espnow_device_name;
  espnow["send_mode"] = "broadcast";
  espnow["discovery"] = true;
  espnow["discovery_runtime"] = g_espnow_discovery_runtime_enabled;
  espnow["discovery_interval_ms"] = kEspNowDiscoveryIntervalMs;
  out["hw_ready"] = hardware.ready;
  out["cam_enabled"] = camera.enabled;
  out["cam_scene_active"] = g_camera_scene_active;
  out["cam_recorder"] = camera.recorder_session_active;
  out["media_recording"] = media.recording;
}

bool executeEspNowCommandPayload(const char* payload_text, uint32_t now_ms, EspNowCommandResult* out_result) {
  if (payload_text == nullptr || out_result == nullptr) {
    return false;
  }
  out_result->handled = false;
  out_result->ok = false;
  out_result->code.remove(0);
  out_result->error.remove(0);
  out_result->data_json.remove(0);

  String command;
  String trailing_arg;
  StaticJsonDocument<768> payload_document;
  JsonVariantConst args = JsonVariantConst();

  if (payload_text[0] == '{') {
    const DeserializationError error = deserializeJson(payload_document, payload_text);
    if (!error) {
      JsonVariantConst root = payload_document.as<JsonVariantConst>();
      const char* cmd = root["cmd"] | root["command"] | root["action"] | "";
      if ((cmd == nullptr || cmd[0] == '\0') && root["payload"].is<JsonObjectConst>()) {
        JsonVariantConst nested = root["payload"];
        cmd = nested["cmd"] | nested["command"] | nested["action"] | "";
        if (!nested["args"].isNull()) {
          args = nested["args"];
        }
      }
      if (cmd != nullptr && cmd[0] != '\0') {
        command = cmd;
        if (args.isNull()) {
          if (!root["args"].isNull()) {
            args = root["args"];
          } else if (!root["payload"].isNull()) {
            args = root["payload"];
          }
        }
        if (args.is<const char*>()) {
          trailing_arg = args.as<const char*>();
          args = JsonVariantConst();
        }
      }
    }
  }

  if (command.isEmpty()) {
    command = payload_text;
    command.trim();
    const int sep = command.indexOf(' ');
    if (sep > 0) {
      trailing_arg = command.substring(static_cast<unsigned int>(sep + 1));
      command = command.substring(0, static_cast<unsigned int>(sep));
    }
  }

  command.trim();
  command.toUpperCase();
  trailing_arg.trim();
  if (command.isEmpty()) {
    return false;
  }

  out_result->handled = true;
  out_result->code = command;

  if (command == "STATUS") {
    StaticJsonDocument<512> response;
    appendCompactRuntimeStatus(response.to<JsonObject>());
    serializeJson(response, out_result->data_json);
    out_result->ok = true;
    return true;
  }
  if (command == "UI_SCENE_STATUS") {
    const UiSceneStatusSnapshot ui = g_ui.sceneStatusSnapshot();
    String scene_id = ui.scene_id;
    String step_id = ui.step_id;
    String scene_upper = scene_id;
    String step_upper = step_id;
    scene_upper.toUpperCase();
    step_upper.toUpperCase();

    const char* validation_state = "none";
    if (step_upper.indexOf("RTC_ESP_ETAPE") >= 0 ||
        step_upper.indexOf("WAITING") >= 0 ||
        step_upper.indexOf("PENDING") >= 0 ||
        scene_upper == "SCENE_WIN_ETAPE" ||
        scene_upper == "SCENE_WIN_ETAPE2") {
      validation_state = "waiting";
    } else if (step_upper.indexOf("WARNING") >= 0 ||
               step_upper.indexOf("REFUS") >= 0 ||
               step_upper.indexOf("BROKEN") >= 0 ||
               scene_upper == "SCENE_WARNING" ||
               scene_upper == "SCENE_BROKEN") {
      validation_state = "refused";
    } else if (step_upper.indexOf("WIN") >= 0 ||
               step_upper.indexOf("FINAL") >= 0 ||
               step_upper.indexOf("REWARD") >= 0 ||
               scene_upper == "SCENE_WIN_ETAPE1" ||
               scene_upper == "SCENE_WIN" ||
               scene_upper == "SCENE_REWARD") {
      validation_state = "granted";
    }

    StaticJsonDocument<320> response;
    response["valid"] = ui.valid;
    response["scenario_id"] = ui.scenario_id;
    response["scene_id"] = ui.scene_id;
    response["step_id"] = ui.step_id;
    response["validation_state"] = validation_state;
    serializeJson(response, out_result->data_json);
    out_result->ok = true;
    return true;
  }
  if (command == "WIFI_STATUS") {
    StaticJsonDocument<384> response;
    webFillWifiStatus(response.to<JsonObject>(), g_network.snapshot());
    serializeJson(response, out_result->data_json);
    out_result->ok = true;
    return true;
  }
  if (command == "ESPNOW_STATUS") {
    StaticJsonDocument<512> response;
    webFillEspNowStatus(response.to<JsonObject>(), g_network.snapshot());
    serializeJson(response, out_result->data_json);
    out_result->ok = true;
    return true;
  }
  if (command == "UNLOCK") {
    out_result->ok = dispatchScenarioEventByName("UNLOCK", now_ms);
    return true;
  }
  if (command == "NEXT") {
    bool ok = dispatchScenarioEventByName("SERIAL:BTN_NEXT", now_ms);
    if (!ok) {
      ok = notifyScenarioButtonGuarded(5U, false, now_ms, "espnow_command");
    }
    out_result->ok = ok;
    if (!out_result->ok) {
      out_result->error = "scene_not_found";
    }
    return true;
  }
  if (command == "WIFI_DISCONNECT") {
    g_network.disconnectSta();
    out_result->ok = true;
    return true;
  }
  if (command == "WIFI_RECONNECT") {
    if (g_network_cfg.local_ssid[0] == '\0') {
      out_result->ok = false;
      out_result->error = "no_credentials";
      return true;
    }
    out_result->ok = webReconnectLocalWifi();
    if (!out_result->ok) {
      out_result->error = "wifi_reconnect_failed";
    }
    return true;
  }
  if (command == "ESPNOW_ON") {
    out_result->ok = g_network.enableEspNow();
    if (!out_result->ok) {
      out_result->error = "espnow_enable_failed";
    }
    return true;
  }
  if (command == "ESPNOW_OFF") {
    g_network.disableEspNow();
    out_result->ok = true;
    return true;
  }
  if (command == "ESPNOW_DISCOVERY") {
    const uint8_t discovered = runEspNowDiscoverySweep();
    out_result->ok = discovered > 0U;
    if (!out_result->ok) {
      out_result->error = "peer_not_found";
      return true;
    }
    StaticJsonDocument<384> response;
    response["target"] = "broadcast";
    response["mode"] = "broadcast+discovery";
    response["device_name"] = g_espnow_device_name;
    response["peer_count"] = discovered;
    JsonArray peers = response["peers"].to<JsonArray>();
    for (uint8_t index = 0U; index < discovered; ++index) {
      char peer[18] = {0};
      if (!g_network.espNowPeerAt(index, peer, sizeof(peer))) {
        continue;
      }
      peers.add(peer);
    }
    serializeJson(response, out_result->data_json);
    return true;
  }
  if (command == "ESPNOW_DISCOVERY_RUNTIME") {
    if (!trailing_arg.isEmpty()) {
      bool enabled = false;
      if (!parseBoolToken(trailing_arg.c_str(), &enabled)) {
        out_result->ok = false;
        out_result->error = "invalid_discovery_runtime";
        return true;
      }
      g_espnow_discovery_runtime_enabled = enabled;
      g_next_espnow_discovery_ms = millis() + 50U;
    }
    StaticJsonDocument<160> response;
    response["enabled"] = g_espnow_discovery_runtime_enabled;
    response["interval_ms"] = kEspNowDiscoveryIntervalMs;
    response["device_name"] = g_espnow_device_name;
    serializeJson(response, out_result->data_json);
    out_result->ok = true;
    return true;
  }
  if (command == "ESPNOW_DEVICE_NAME_GET") {
    StaticJsonDocument<128> response;
    response["device_name"] = g_espnow_device_name;
    serializeJson(response, out_result->data_json);
    out_result->ok = true;
    return true;
  }
  if (command == "ESPNOW_DEVICE_NAME_SET") {
    String next_name = trailing_arg;
    next_name.trim();
    if (next_name.isEmpty() && !args.isNull()) {
      if (args.is<const char*>()) {
        next_name = args.as<const char*>();
      } else if (args.is<JsonObjectConst>()) {
        JsonObjectConst name_args = args.as<JsonObjectConst>();
        const char* requested = name_args["device_name"] | name_args["name"] | name_args["value"] | "";
        if (requested != nullptr && requested[0] != '\0') {
          next_name = requested;
        }
      }
    }
    next_name.trim();
    if (next_name.isEmpty()) {
      out_result->ok = false;
      out_result->error = "missing_device_name";
      return true;
    }
    out_result->ok = saveEspNowDeviceNameToNvs(next_name.c_str());
    if (!out_result->ok) {
      out_result->error = "device_name_store_failed";
      return true;
    }
    StaticJsonDocument<128> response;
    response["device_name"] = g_espnow_device_name;
    serializeJson(response, out_result->data_json);
    return true;
  }
  if (command == "STORY_REFRESH_SD") {
    out_result->ok = refreshStoryFromSd();
    if (!out_result->ok) {
      out_result->error = "story_refresh_sd_failed";
    }
    return true;
  }
  if (command == "SC_EVENT") {
    bool dispatched = false;
    if (!args.isNull() && args.is<JsonObjectConst>()) {
      JsonVariantConst args_obj = args;
      const char* type_text = args_obj["event_type"] | args_obj["type"] | "";
      const char* name_text = args_obj["event_name"] | args_obj["name"] | "";
      StoryEventType event_type = StoryEventType::kNone;
      if (type_text[0] != '\0' && parseEventType(type_text, &event_type)) {
        dispatched = dispatchScenarioEventByType(event_type, name_text, now_ms);
      } else {
        char event_token[kSerialLineCapacity] = {0};
        if (extractEventTokenFromJsonObject(args_obj, event_token, sizeof(event_token))) {
          dispatched = dispatchScenarioEventByName(event_token, now_ms);
        }
      }
    }
    if (!dispatched && !trailing_arg.isEmpty()) {
      char event_token[kSerialLineCapacity] = {0};
      if (normalizeEventTokenFromText(trailing_arg.c_str(), event_token, sizeof(event_token))) {
        dispatched = dispatchScenarioEventByName(event_token, now_ms);
      }
    }
    out_result->ok = dispatched;
    if (!dispatched) {
      out_result->error = "invalid_sc_event";
    }
    return true;
  }
  if (command == "RING") {
    out_result->ok = dispatchScenarioEventByName("RING", now_ms);
    if (!out_result->ok) {
      out_result->error = "invalid_ring_event";
    }
    return true;
  }
  if (command == "SCENE") {
    String scene_id = trailing_arg;
    scene_id.trim();
    if (scene_id.isEmpty() && !args.isNull()) {
      if (args.is<const char*>()) {
        scene_id = args.as<const char*>();
      } else if (args.is<JsonObjectConst>()) {
        JsonObjectConst scene_args = args.as<JsonObjectConst>();
        const char* id = scene_args["id"] | scene_args["scenario"] | scene_args["scenario_id"] | scene_args["scene_id"] | "";
        if (id != nullptr && id[0] != '\0') {
          scene_id = id;
        } else if (scene_args["name"].is<const char*>()) {
          scene_id = scene_args["name"].as<const char*>();
        }
      } else if (args.is<int>()) {
        scene_id = String(args.as<int>());
      } else if (args.is<unsigned int>()) {
        scene_id = String(args.as<unsigned int>());
      } else if (args.is<long>()) {
        scene_id = String(args.as<long>());
      } else if (args.is<unsigned long>()) {
        scene_id = String(args.as<unsigned long>());
      }
    }

    scene_id.trim();
    if (scene_id.isEmpty()) {
      out_result->ok = false;
      out_result->error = "missing_scene_id";
      return true;
    }
    scene_id.toUpperCase();

    String load_source;
    String load_path;
    out_result->ok = loadScenarioByIdPreferStoryFile(scene_id.c_str(), &load_source, &load_path);
    if (!out_result->ok) {
      out_result->error = "scene_not_found";
      return true;
    }
    if (!load_path.isEmpty()) {
      Serial.printf("[SCENARIO] SCENE source=%s path=%s\n", load_source.c_str(), load_path.c_str());
    } else {
      Serial.printf("[SCENARIO] SCENE source=%s id=%s\n", load_source.c_str(), scene_id.c_str());
    }
    g_last_action_step_key[0] = '\0';
    refreshSceneIfNeeded(true);
    startPendingAudioIfAny();
    return true;
  }

  String control_action = command;
  if (!trailing_arg.isEmpty()) {
    control_action += " ";
    control_action += trailing_arg;
  }
  String control_error;
  const bool control_ok = dispatchControlAction(control_action, now_ms, &control_error);
  if (control_ok || control_error != "unsupported_action") {
    out_result->ok = control_ok;
    if (!control_ok) {
      out_result->error = control_error;
    }
    return true;
  }

  out_result->handled = false;
  out_result->error = "unsupported_command";
  return false;
}

void sendEspNowAck(const char* peer,
                   const char* msg_id,
                   uint32_t seq,
                   const EspNowCommandResult& result,
                   bool ack_requested) {
  if (!ack_requested || peer == nullptr || peer[0] == '\0') {
    return;
  }

  StaticJsonDocument<768> response;
  char fallback_msg_id[32] = {0};
  if (msg_id == nullptr || msg_id[0] == '\0') {
    snprintf(fallback_msg_id, sizeof(fallback_msg_id), "ack-%08lX", static_cast<unsigned long>(millis()));
    msg_id = fallback_msg_id;
  }
  response["msg_id"] = msg_id;
  response["seq"] = seq;
  response["type"] = "ack";
  response["ack"] = true;
  JsonObject payload = response["payload"].to<JsonObject>();
  payload["ok"] = result.ok;
  payload["code"] = result.code;
  payload["error"] = result.error;
  if (!result.data_json.isEmpty()) {
    StaticJsonDocument<512> data_doc;
    if (!deserializeJson(data_doc, result.data_json)) {
      payload["data"] = data_doc.as<JsonVariantConst>();
    } else {
      payload["data_raw"] = result.data_json;
    }
  }

  String frame;
  serializeJson(response, frame);
  if (!g_network.sendEspNowTarget(peer, frame.c_str())) {
    Serial.printf("[NET] ESPNOW ACK send failed peer=%s msg_id=%s code=%s\n",
                  peer,
                  msg_id,
                  result.code.c_str());
  }
}

void printScenarioList() {
  const char* default_id = storyScenarioV2IdAt(0U);
  Serial.printf("SC_LIST count=%u default=%s\n",
                storyScenarioV2Count(),
                (default_id != nullptr) ? default_id : "n/a");
  for (uint8_t index = 0U; index < storyScenarioV2Count(); ++index) {
    const char* scenario_id = storyScenarioV2IdAt(index);
    if (scenario_id == nullptr) {
      continue;
    }
    Serial.printf("SC_LIST_ITEM idx=%u id=%s\n", index, scenario_id);
  }
}

bool splitSsidPass(const char* argument, String* out_ssid, String* out_pass) {
  if (argument == nullptr || out_ssid == nullptr || out_pass == nullptr) {
    return false;
  }
  String raw = argument;
  raw.trim();
  if (raw.isEmpty()) {
    return false;
  }
  const int sep = raw.lastIndexOf(' ');
  if (sep < 0) {
    *out_ssid = raw;
    out_pass->remove(0);
    return true;
  }
  *out_ssid = raw.substring(0, static_cast<unsigned int>(sep));
  *out_pass = raw.substring(static_cast<unsigned int>(sep + 1));
  out_ssid->trim();
  out_pass->trim();
  return !out_ssid->isEmpty();
}

bool parseBoundedLongToken(const char* token, long min_value, long max_value, long* out_value) {
  if (token == nullptr || out_value == nullptr) {
    return false;
  }
  errno = 0;
  char* end = nullptr;
  const long parsed = std::strtol(token, &end, 10);
  if (errno == ERANGE || end == token || (end != nullptr && *end != '\0') || parsed < min_value || parsed > max_value) {
    return false;
  }
  *out_value = parsed;
  return true;
}

bool parseHwLedSetArgs(const char* args,
                       uint8_t* out_r,
                       uint8_t* out_g,
                       uint8_t* out_b,
                       uint8_t* out_brightness,
                       bool* out_pulse) {
  if (args == nullptr || out_r == nullptr || out_g == nullptr || out_b == nullptr || out_brightness == nullptr ||
      out_pulse == nullptr) {
    return false;
  }

  char buffer[kSerialLineCapacity] = {0};
  copyText(buffer, sizeof(buffer), args);
  trimAsciiInPlace(buffer);
  if (buffer[0] == '\0') {
    return false;
  }

  char* tokens[5] = {nullptr, nullptr, nullptr, nullptr, nullptr};
  size_t token_count = 0U;
  char* cursor = buffer;
  while (*cursor != '\0' && token_count < 5U) {
    while (*cursor == ' ') {
      ++cursor;
    }
    if (*cursor == '\0') {
      break;
    }
    tokens[token_count++] = cursor;
    while (*cursor != '\0' && *cursor != ' ') {
      ++cursor;
    }
    if (*cursor == '\0') {
      break;
    }
    *cursor++ = '\0';
  }
  if (token_count < 3U) {
    return false;
  }

  long parsed_values[5] = {0L, 0L, 0L, static_cast<long>(FREENOVE_WS2812_BRIGHTNESS), 1L};
  for (size_t index = 0U; index < token_count; ++index) {
    const long max_value = (index == 4U) ? 1L : 255L;
    if (!parseBoundedLongToken(tokens[index], 0L, max_value, &parsed_values[index])) {
      return false;
    }
  }
  *out_r = static_cast<uint8_t>(parsed_values[0]);
  *out_g = static_cast<uint8_t>(parsed_values[1]);
  *out_b = static_cast<uint8_t>(parsed_values[2]);
  *out_brightness = static_cast<uint8_t>(parsed_values[3]);
  *out_pulse = (parsed_values[4] != 0L);
  return true;
}

bool parseEspNowSendPayload(const char* argument, String& payload, bool* used_target) {
  if (argument == nullptr) {
    return false;
  }

  String args = argument;
  args.trim();
  if (args.isEmpty()) {
    return false;
  }
  if (used_target != nullptr) {
    *used_target = false;
  }

  const int separator = args.indexOf(' ');
  if (separator < 0) {
    payload = args;
    return true;
  }

  String maybe_target = args.substring(0U, static_cast<unsigned int>(separator));
  maybe_target.trim();
  String parsed_payload = args.substring(static_cast<unsigned int>(separator + 1U));
  parsed_payload.trim();
  if (parsed_payload.isEmpty()) {
    return false;
  }

  bool looks_like_target = false;
  String target_lower = maybe_target;
  target_lower.toLowerCase();
  if (target_lower == kEspNowBroadcastTarget) {
    looks_like_target = true;
  } else {
    uint8_t parsed_mac[6] = {0};
    if (g_network.parseMac(maybe_target.c_str(), parsed_mac)) {
      looks_like_target = true;
    }
  }

  if (!looks_like_target) {
    payload = args;
    return true;
  }

  if (used_target != nullptr) {
    *used_target = true;
  }
  payload = parsed_payload;
  return true;
}

void printNetworkStatus() {
  const NetworkManager::Snapshot net = g_network.snapshot();
  Serial.printf("NET_STATUS state=%s mode=%s sta=%u connecting=%u ap=%u fallback_ap=%u espnow=%u espnow_name=%s "
                "espnow_mode=broadcast+discovery discovery_runtime=%u discovery_interval_ms=%lu ip=%s sta_ssid=%s "
                "ap_ssid=%s ap_clients=%u local_target=%s local_match=%u local_retry_paused=%u rssi=%ld ch=%ld peers=%u rx=%lu "
                "tx_ok=%lu tx_fail=%lu drop=%lu last_msg=%s seq=%lu type=%s ack=%u\n",
                net.state,
                net.mode,
                net.sta_connected ? 1U : 0U,
                net.sta_connecting ? 1U : 0U,
                net.ap_enabled ? 1U : 0U,
                net.fallback_ap_active ? 1U : 0U,
                net.espnow_enabled ? 1U : 0U,
                g_espnow_device_name,
                g_espnow_discovery_runtime_enabled ? 1U : 0U,
                static_cast<unsigned long>(kEspNowDiscoveryIntervalMs),
                net.ip,
                net.sta_ssid[0] != '\0' ? net.sta_ssid : "n/a",
                net.ap_ssid[0] != '\0' ? net.ap_ssid : "n/a",
                static_cast<unsigned int>(net.ap_clients),
                net.local_target[0] != '\0' ? net.local_target : "n/a",
                net.local_match ? 1U : 0U,
                net.local_retry_paused ? 1U : 0U,
                static_cast<long>(net.rssi),
                static_cast<long>(net.channel),
                net.espnow_peer_count,
                static_cast<unsigned long>(net.espnow_rx_packets),
                static_cast<unsigned long>(net.espnow_tx_ok),
                static_cast<unsigned long>(net.espnow_tx_fail),
                static_cast<unsigned long>(net.espnow_drop_packets),
                net.last_msg_id[0] != '\0' ? net.last_msg_id : "n/a",
                static_cast<unsigned long>(net.espnow_last_seq),
                net.last_type[0] != '\0' ? net.last_type : "n/a",
                net.espnow_last_ack ? 1U : 0U);
  for (uint8_t index = 0U; index < g_network.espNowPeerCount(); ++index) {
    char peer[18] = {0};
    if (!g_network.espNowPeerAt(index, peer, sizeof(peer))) {
      continue;
    }
    Serial.printf("NET_PEER idx=%u mac=%s\n", index, peer);
  }
  if (net.last_payload[0] != '\0') {
    Serial.printf("NET_LAST peer=%s payload=%s\n",
                  net.last_peer[0] != '\0' ? net.last_peer : "n/a",
                  net.last_payload);
  }
}

void printEspNowStatusJson() {
  const NetworkManager::Snapshot net = g_network.snapshot();
  StaticJsonDocument<768> document;
  document["ready"] = net.espnow_enabled;
  document["peer_count"] = net.espnow_peer_count;
  document["tx_ok"] = net.espnow_tx_ok;
  document["tx_fail"] = net.espnow_tx_fail;
  document["rx_count"] = net.espnow_rx_packets;
  document["last_rx_mac"] = net.last_rx_peer;
  document["last_msg_id"] = net.last_msg_id;
  document["last_seq"] = net.espnow_last_seq;
  document["last_type"] = net.last_type;
  document["last_ack"] = net.espnow_last_ack;
  document["last_payload"] = net.last_payload;
  document["device_name"] = g_espnow_device_name;
  document["send_mode"] = "broadcast";
  document["discovery"] = true;
  document["discovery_runtime"] = g_espnow_discovery_runtime_enabled;
  document["discovery_interval_ms"] = kEspNowDiscoveryIntervalMs;
  document["channel"] = net.channel;
  JsonArray peers = document["peers"].to<JsonArray>();
  for (uint8_t index = 0U; index < g_network.espNowPeerCount(); ++index) {
    char peer[18] = {0};
    if (!g_network.espNowPeerAt(index, peer, sizeof(peer))) {
      continue;
    }
    peers.add(peer);
  }
  serializeJson(document, Serial);
  Serial.println();
}

bool sendRingCommandToRtc() {
  StaticJsonDocument<96> payload;
  payload["cmd"] = "RING";
  String payload_text;
  serializeJson(payload, payload_text);
  const bool ok = g_network.sendEspNowTarget(kEspNowBroadcastTarget, payload_text.c_str());
  Serial.printf("[MAIN] RING send to rtc ok=%u payload=%s\n", ok ? 1U : 0U, payload_text.c_str());
  return ok;
}

#if defined(USE_AUDIO) && (USE_AUDIO != 0)
bool isAmpSceneId(const char* scene_id) {
  if (scene_id == nullptr || scene_id[0] == '\0') {
    return false;
  }
  return std::strcmp(scene_id, "SCENE_MP3_PLAYER") == 0 ||
         std::strcmp(scene_id, "SCENE_AUDIO_PLAYER") == 0 ||
         std::strcmp(scene_id, "SCENE_MP3") == 0;
}

bool beginAmpAtBase(const char* base_dir) {
  if (base_dir == nullptr || base_dir[0] == '\0') {
    return false;
  }
  ui::audio::AmigaAudioPlayer::UiConfig ui_cfg;
  ui_cfg.base_dir = base_dir;
  ui_cfg.start_visible = false;
  ui_cfg.auto_scan = false;
  ui_cfg.dim_background = true;
  ui_cfg.capture_keys_when_visible = true;
  const bool ok = g_amp_player.begin(ui_cfg, ui::audio::AudioPlayerService::Config{});
  if (ok) {
    copyText(g_amp_base_dir, sizeof(g_amp_base_dir), base_dir);
    g_amp_ready = true;
    Serial.printf("[AMP] ready base_dir=%s\n", g_amp_base_dir);
  }
  return ok;
}

bool ensureAmpInitialized() {
  if (g_amp_ready) {
    return true;
  }
  // Keep AMP backend lazy to avoid dual Audio/I2S contention with story audio.
  if (!g_amp_scene_active) {
    return false;
  }
  const char* const candidates[] = {kAmpMusicPathPrimary, kAmpMusicPathFallback1, kAmpMusicPathFallback2};
  for (const char* candidate : candidates) {
    if (beginAmpAtBase(candidate)) {
      return true;
    }
  }
  return false;
}

size_t scanAmpPlaylistWithFallback() {
  if (!ensureAmpInitialized()) {
    return 0U;
  }
  size_t count = g_amp_player.service().scanPlaylist();
  if (count > 0U) {
    return count;
  }

  const char* const candidates[] = {kAmpMusicPathPrimary, kAmpMusicPathFallback1, kAmpMusicPathFallback2};
  for (const char* candidate : candidates) {
    if (std::strcmp(candidate, g_amp_base_dir) == 0) {
      continue;
    }
    g_amp_player.end();
    g_amp_ready = false;
    if (!beginAmpAtBase(candidate)) {
      continue;
    }
    count = g_amp_player.service().scanPlaylist();
    if (count > 0U) {
      return count;
    }
  }
  return count;
}

void syncAmpSceneState(const ScenarioSnapshot& snapshot) {
  const bool want_amp_scene = isAmpSceneId(snapshot.screen_scene_id);
  setAmpSceneActive(want_amp_scene);
}

void setAmpSceneActive(bool active) {
  if (active == g_amp_scene_active) {
    return;
  }
  g_amp_scene_active = active;
  if (g_amp_scene_active) {
    g_audio.stop();
    if (ensureAmpInitialized()) {
      (void)scanAmpPlaylistWithFallback();
      g_amp_player.show();
    }
    Serial.println("[AMP] scene owner=amp");
    return;
  }
  if (g_amp_ready) {
    g_amp_player.service().stop();
    g_amp_player.hide();
  }
  Serial.println("[AMP] scene owner=story_audio");
}

void printAmpStatus() {
  if (!ensureAmpInitialized()) {
    Serial.println("AMP_STATUS ready=0");
    return;
  }
  const ui::audio::AudioPlayerService::Stats stats = g_amp_player.service().stats();
  const size_t count = g_amp_player.service().trackCount();
  const size_t index = g_amp_player.service().currentIndex();
  const char* path = g_amp_player.service().currentPath();
  Serial.printf("AMP_STATUS ready=1 visible=%u scene=%u base=%s tracks=%u idx=%u path=%s state=%u pos=%lu dur=%lu vu=%u\n",
                g_amp_player.visible() ? 1U : 0U,
                g_amp_scene_active ? 1U : 0U,
                g_amp_base_dir,
                static_cast<unsigned int>(count),
                static_cast<unsigned int>(index),
                (path != nullptr && path[0] != '\0') ? path : "n/a",
                static_cast<unsigned int>(stats.state),
                static_cast<unsigned long>(stats.position_s),
                static_cast<unsigned long>(stats.duration_s),
                static_cast<unsigned int>(stats.vu));
}
#endif

bool isCameraSceneId(const char* scene_id) {
  if (scene_id == nullptr || scene_id[0] == '\0') {
    return false;
  }
  return std::strcmp(scene_id, kCameraSceneId) == 0;
}

bool ensureCameraUiInitialized() {
  if (g_camera_scene_ready) {
    return true;
  }
  ui::camera::Win311CameraUI::UiConfig ui_cfg;
  ui_cfg.start_visible = false;
  ui_cfg.base_dir = "/picture";
  ui_cfg.camera = &g_camera;
  ui_cfg.capture_keys_when_visible = true;
  ui::camera::CameraCaptureService::Config service_cfg;
  service_cfg.camera = &g_camera;
  service_cfg.base_dir = "/picture";
  g_camera_scene_ready = g_camera_player.begin(ui_cfg, service_cfg);
  if (!g_camera_scene_ready) {
    Serial.println("[CAM_UI] init failed");
    return false;
  }
  g_camera_player.hide();
  Serial.println("[CAM_UI] ready");
  return true;
}

void syncCameraSceneState(const ScenarioSnapshot& snapshot) {
  const bool want_camera_scene = isCameraSceneId(snapshot.screen_scene_id);
  setCameraSceneActive(want_camera_scene);
}

void setCameraSceneActive(bool active) {
  if (active == g_camera_scene_active) {
    return;
  }
  g_camera_scene_active = active;
  if (g_camera_scene_active) {
    if (!ensureCameraUiInitialized()) {
      g_camera_scene_active = false;
      return;
    }
    if (!g_camera.startRecorderSession()) {
      Serial.println("[CAM_UI] recorder session start failed");
      g_camera_player.show();
      return;
    }
    g_camera_player.show();
    Serial.println("[CAM_UI] scene owner=recorder");
    return;
  }
  if (g_camera_scene_ready) {
    g_camera_player.hide();
    g_camera_player.service().discard_frozen();
  }
  g_camera.stopRecorderSession();
  Serial.println("[CAM_UI] scene owner=legacy");
}

bool dispatchCameraSceneButton(uint8_t key, bool long_press) {
  if (!g_camera_scene_active || !g_camera_scene_ready) {
    return false;
  }
  ui::camera::Win311CameraUI::InputAction action = ui::camera::Win311CameraUI::InputAction::kSnapToggle;
  switch (key) {
    case 1U:
      action = ui::camera::Win311CameraUI::InputAction::kSnapToggle;
      break;
    case 2U:
      action = ui::camera::Win311CameraUI::InputAction::kSave;
      break;
    case 3U:
      action = long_press ? ui::camera::Win311CameraUI::InputAction::kGalleryNext
                          : ui::camera::Win311CameraUI::InputAction::kGalleryToggle;
      break;
    case 4U:
      action = ui::camera::Win311CameraUI::InputAction::kDeleteSelected;
      break;
    case 5U:
      action = ui::camera::Win311CameraUI::InputAction::kClose;
      break;
    default:
      return false;
  }
  return g_camera_player.handleInputAction(action);
}

void printCameraRecorderStatus() {
  const CameraManager::Snapshot camera = g_camera.snapshot();
  Serial.printf(
      "CAM_REC_STATUS scene=%u ui_ready=%u visible=%u session=%u frozen=%u preview=%ux%u selected=%s last=%s err=%s\n",
      g_camera_scene_active ? 1U : 0U,
      g_camera_scene_ready ? 1U : 0U,
      (g_camera_scene_ready && g_camera_player.visible()) ? 1U : 0U,
      camera.recorder_session_active ? 1U : 0U,
      camera.recorder_frozen ? 1U : 0U,
      static_cast<unsigned int>(camera.recorder_preview_width),
      static_cast<unsigned int>(camera.recorder_preview_height),
      camera.recorder_selected_file[0] != '\0' ? camera.recorder_selected_file : "n/a",
      camera.last_file[0] != '\0' ? camera.last_file : "n/a",
      camera.last_error[0] != '\0' ? camera.last_error : "none");
}

void onAudioFinished(const char* track, void* ctx) {
  (void)ctx;
  Serial.printf("[MAIN] audio done: %s\n", track != nullptr ? track : "unknown");
#if defined(USE_AUDIO) && (USE_AUDIO != 0)
  if (g_amp_scene_active) {
    return;
  }
#endif
  const ScenarioSnapshot snapshot = g_scenario.snapshot();
  const char* current_scene = (snapshot.screen_scene_id != nullptr) ? snapshot.screen_scene_id : "";
  const bool credits_scene = (std::strcmp(current_scene, "SCENE_CREDITS") == 0 ||
                              std::strcmp(current_scene, "SCENE_CREDIT") == 0);
  if (credits_scene && track != nullptr && track[0] != '\0') {
    const char* next_credits_scene =
        (std::strcmp(current_scene, "SCENE_CREDIT") == 0) ? "SCENE_CREDITS" : "SCENE_CREDIT";
    const bool routed = g_scenario.gotoScene(next_credits_scene, millis(), "credits_scene_loop");
    const char* loop_track = track;
    bool restart_ok = false;
    if (isCreditsWinTrackPath(track)) {
      restart_ok = g_audio.play(track);
    } else {
      restart_ok = playCreditsWinTrack(&loop_track);
    }
    if (restart_ok) {
      Serial.printf("[MAIN] audio loop scene=%s next=%s routed=%u track=%s\n",
                    current_scene,
                    next_credits_scene,
                    routed ? 1U : 0U,
                    loop_track);
      return;
    }
    Serial.printf("[MAIN] audio loop restart failed scene=%s next=%s routed=%u track=%s\n",
                  current_scene,
                  next_credits_scene,
                  routed ? 1U : 0U,
                  loop_track);
    if (routed) {
      return;
    }
  }
  const char* loop_scene_tag = nullptr;
  if (current_scene[0] != '\0') {
    if (std::strcmp(current_scene, "SCENE_WIN_ETAPE1") == 0) {
      loop_scene_tag = "SCENE_WIN_ETAPE1";
    } else if (std::strcmp(current_scene, "SCENE_WIN") == 0 ||
               std::strcmp(current_scene, "SCENE_WIN_ETAPE2") == 0) {
      loop_scene_tag = current_scene;
    }
  }
  if (loop_scene_tag != nullptr && track != nullptr && track[0] != '\0') {
    if (g_audio.play(track)) {
      Serial.printf("[MAIN] audio loop scene=%s track=%s\n", loop_scene_tag, track);
      return;
    }
    Serial.printf("[MAIN] audio loop restart failed scene=%s track=%s\n", loop_scene_tag, track);
  }
  if (snapshot.step != nullptr &&
      snapshot.step->id != nullptr &&
      std::strcmp(snapshot.step->id, kStepWinEtape) == 0 &&
      snapshot.audio_pack_id != nullptr &&
      std::strcmp(snapshot.audio_pack_id, kPackWin) == 0 &&
      !g_has_ring_sent_for_win_etape) {
    g_has_ring_sent_for_win_etape = sendRingCommandToRtc();
  }
  if (snapshot.step != nullptr && snapshot.step->id != nullptr && std::strcmp(snapshot.step->id, kStepWinEtape) == 0 &&
      snapshot.audio_pack_id != nullptr && std::strcmp(snapshot.audio_pack_id, kPackWin) == 0) {
    g_win_etape_ui_refresh_pending = true;
  }
  g_scenario.notifyAudioDone(millis());
}

void printButtonRead() {
  Serial.printf("BTN mv=%d key=%u\n", g_buttons.lastAnalogMilliVolts(), g_buttons.currentKey());
}

void printRuntimeStatus() {
  const ScenarioSnapshot snapshot = g_scenario.snapshot();
  const NetworkManager::Snapshot net = g_network.snapshot();
  const HardwareManager::Snapshot& hw = g_hardware.snapshotRef();
  const CameraManager::Snapshot camera = g_camera.snapshot();
  const MediaManager::Snapshot media = g_media.snapshot();
  const runtime::resource::ResourceCoordinatorSnapshot resource = g_resource_coordinator.snapshot();
  const char* scenario_id = scenarioIdFromSnapshot(snapshot);
  const char* step_id = stepIdFromSnapshot(snapshot);
  const char* screen_id = (snapshot.screen_scene_id != nullptr) ? snapshot.screen_scene_id : "n/a";
  const char* audio_pack = (snapshot.audio_pack_id != nullptr) ? snapshot.audio_pack_id : "n/a";
  Serial.printf("STATUS scenario=%s step=%s screen=%s pack=%s audio=%u track=%s codec=%s bitrate=%u profile=%u:%s fx=%u:%s vol=%u "
                "net=%s/%s sta=%u connecting=%u ap=%u espnow=%u peers=%u ip=%s key=%u mv=%d "
                "hw=%u mic=%u battery=%u cam=%u media_play=%u rec=%u res=%s pressure=%u mic_should=%u cam_allow=%u story_debug_bypass=%u render_lock=%s\n",
                scenario_id,
                step_id,
                screen_id,
                audio_pack,
                g_audio.isPlaying() ? 1 : 0,
                g_audio.currentTrack(),
                g_audio.activeCodec(),
                g_audio.activeBitrateKbps(),
                g_audio.outputProfile(),
                g_audio.outputProfileLabel(g_audio.outputProfile()),
                g_audio.fxProfile(),
                g_audio.fxProfileLabel(g_audio.fxProfile()),
                g_audio.volume(),
                net.state,
                net.mode,
                net.sta_connected ? 1U : 0U,
                net.sta_connecting ? 1U : 0U,
                net.ap_enabled ? 1U : 0U,
                net.espnow_enabled ? 1U : 0U,
                net.espnow_peer_count,
                net.ip,
                g_buttons.currentKey(),
                g_buttons.lastAnalogMilliVolts(),
                hw.ready ? 1U : 0U,
                hw.mic_level_percent,
                hw.battery_percent,
                camera.enabled ? 1U : 0U,
                media.playing ? 1U : 0U,
                media.recording ? 1U : 0U,
                g_resource_coordinator.profileName(),
                resource.graphics_pressure ? 1U : 0U,
                resource.mic_should_run ? 1U : 0U,
                resource.allow_camera_ops ? 1U : 0U,
                g_scenario.debugTransitionBypassEnabled() ? 1U : 0U,
                "lgfx_runtime_only");
}

void printHardwareStatus() {
  const HardwareManager::Snapshot& hw = g_hardware.snapshotRef();
  Serial.printf(
      "HW_STATUS ready=%u ws2812=%u mic=%u battery=%u auto=%u manual=%u bl=%u bl_mode=%s led_mode=%s one_led=%u led=%u,%u,%u br=%u "
      "mic_pct=%u mic_peak=%u mic_noise=%u mic_gain=%u mic_freq=%u mic_cents=%d mic_conf=%u "
      "la_gate=%u la_match=%u la_lock=%u la_pending=%u la_stable_ms=%lu la_timeout_ms=%lu "
      "battery_pct=%u battery_mv=%u charging=%u scene=%s\n",
      hw.ready ? 1U : 0U,
      hw.ws2812_ready ? 1U : 0U,
      hw.mic_ready ? 1U : 0U,
      hw.battery_ready ? 1U : 0U,
      g_hardware_cfg.led_auto_from_scene ? 1U : 0U,
      hw.led_manual ? 1U : 0U,
      static_cast<unsigned int>(g_lcd_backlight_level),
      g_scene_backlight_mode,
      hw.led_mode,
      hw.led_one_at_a_time ? 1U : 0U,
      hw.led_r,
      hw.led_g,
      hw.led_b,
      hw.led_brightness,
      hw.mic_level_percent,
      hw.mic_peak,
      static_cast<unsigned int>(hw.mic_noise_floor),
      static_cast<unsigned int>(hw.mic_gain_percent),
      hw.mic_freq_hz,
      static_cast<int>(hw.mic_pitch_cents),
      hw.mic_pitch_confidence,
      g_la_trigger.gate_active ? 1U : 0U,
      g_la_trigger.sample_match ? 1U : 0U,
      g_la_trigger.locked ? 1U : 0U,
      g_la_trigger.timeout_pending ? 1U : 0U,
      static_cast<unsigned long>(g_la_trigger.stable_ms),
      static_cast<unsigned long>(g_hardware_cfg.mic_la_timeout_ms),
      hw.battery_percent,
      hw.battery_cell_mv,
      hw.charging ? 1U : 0U,
      hw.scene_id);
}

void printMicTunerStatus() {
  const HardwareManager::Snapshot& hw = g_hardware.snapshotRef();
  Serial.printf(
      "MIC_TUNER_STATUS freq=%u cents=%d conf=%u level=%u peak=%u noise=%u gain=%u scene=%s stream=%u period_ms=%u "
      "la_gate=%u la_match=%u la_lock=%u la_pending=%u la_stable_ms=%lu la_pct=%u\n",
                static_cast<unsigned int>(hw.mic_freq_hz),
                static_cast<int>(hw.mic_pitch_cents),
                static_cast<unsigned int>(hw.mic_pitch_confidence),
                static_cast<unsigned int>(hw.mic_level_percent),
                static_cast<unsigned int>(hw.mic_peak),
                static_cast<unsigned int>(hw.mic_noise_floor),
                static_cast<unsigned int>(hw.mic_gain_percent),
                hw.scene_id,
                g_mic_tuner_stream_enabled ? 1U : 0U,
                static_cast<unsigned int>(g_mic_tuner_stream_period_ms),
                g_la_trigger.gate_active ? 1U : 0U,
                g_la_trigger.sample_match ? 1U : 0U,
                g_la_trigger.locked ? 1U : 0U,
                g_la_trigger.timeout_pending ? 1U : 0U,
                static_cast<unsigned long>(g_la_trigger.stable_ms),
                static_cast<unsigned int>(laStablePercent()));
}

void printHardwareStatusJson() {
  StaticJsonDocument<1024> document;
  webFillHardwareStatus(document.to<JsonObject>());
  serializeJson(document, Serial);
  Serial.println();
}

void printCameraStatus() {
  const CameraManager::Snapshot camera = g_camera.snapshot();
  Serial.printf(
      "CAM_STATUS supported=%u enabled=%u init=%u frame=%s quality=%u fb=%u xclk=%lu captures=%lu fails=%lu "
      "rec_scene=%u rec_session=%u rec_frozen=%u preview=%ux%u last=%s err=%s\n",
                camera.supported ? 1U : 0U,
                camera.enabled ? 1U : 0U,
                camera.initialized ? 1U : 0U,
                camera.frame_size,
                static_cast<unsigned int>(camera.jpeg_quality),
                static_cast<unsigned int>(camera.fb_count),
                static_cast<unsigned long>(camera.xclk_hz),
                static_cast<unsigned long>(camera.capture_count),
                static_cast<unsigned long>(camera.fail_count),
                g_camera_scene_active ? 1U : 0U,
                camera.recorder_session_active ? 1U : 0U,
                camera.recorder_frozen ? 1U : 0U,
                static_cast<unsigned int>(camera.recorder_preview_width),
                static_cast<unsigned int>(camera.recorder_preview_height),
                camera.last_file[0] != '\0' ? camera.last_file : "n/a",
                camera.last_error[0] != '\0' ? camera.last_error : "none");
}

bool approveCameraOperation(const char* operation, String* out_error) {
  if (g_resource_coordinator.approveCameraOperation()) {
    return true;
  }
  if (out_error != nullptr) {
    *out_error = "camera_blocked_by_resource_profile";
  }
  Serial.printf("[RESOURCE] camera op blocked profile=%s op=%s\n",
                g_resource_coordinator.profileName(),
                (operation != nullptr) ? operation : "unknown");
  return false;
}

void printResourceStatus() {
  const runtime::resource::ResourceCoordinatorSnapshot snapshot = g_resource_coordinator.snapshot();
  const UiMemorySnapshot ui_mem = g_ui.memorySnapshot();
  Serial.printf(
      "RESOURCE_STATUS profile=%s profile_auto=%u pressure=%u mic_should_run=%u mic_force=%u cam_allow=%u pressure_until=%lu mic_hold_until=%lu cam_cooldown_until=%lu cam_allowed=%lu cam_blocked=%lu "
      "delta_ovf=%lu delta_block=%lu draw_avg=%lu draw_max=%lu flush_avg=%lu flush_max=%lu fx_fps=%u ui_block=%lu ui_ovf=%lu ui_stall=%lu ui_recover=%lu\n",
      g_resource_coordinator.profileName(),
      g_resource_profile_auto ? 1U : 0U,
      snapshot.graphics_pressure ? 1U : 0U,
      snapshot.mic_should_run ? 1U : 0U,
      snapshot.mic_force_on ? 1U : 0U,
      snapshot.allow_camera_ops ? 1U : 0U,
      static_cast<unsigned long>(snapshot.pressure_until_ms),
      static_cast<unsigned long>(snapshot.mic_hold_until_ms),
      static_cast<unsigned long>(snapshot.camera_cooldown_until_ms),
      static_cast<unsigned long>(snapshot.camera_allowed_ops),
      static_cast<unsigned long>(snapshot.camera_blocked_ops),
      static_cast<unsigned long>(snapshot.flush_overflow_delta),
      static_cast<unsigned long>(snapshot.flush_blocked_delta),
      static_cast<unsigned long>(snapshot.last_draw_avg_us),
      static_cast<unsigned long>(snapshot.last_draw_max_us),
      static_cast<unsigned long>(snapshot.last_flush_avg_us),
      static_cast<unsigned long>(snapshot.last_flush_max_us),
      static_cast<unsigned int>(ui_mem.fx_fps),
      static_cast<unsigned long>(ui_mem.flush_blocked),
      static_cast<unsigned long>(ui_mem.flush_overflow),
      static_cast<unsigned long>(ui_mem.flush_stall),
      static_cast<unsigned long>(ui_mem.flush_recover));
}

void printSimdStatus() {
  const runtime::simd::SimdAccelStatus& status = runtime::simd::status();
  Serial.printf("SIMD_STATUS enabled=%u esp_dsp=%u selftest_runs=%lu selftest_fail=%lu bench_runs=%lu loops=%lu pixels=%lu l8_us=%lu idx_us=%lu rgb888_us=%lu gain_us=%lu\n",
                status.simd_path_enabled ? 1U : 0U,
                status.esp_dsp_enabled ? 1U : 0U,
                static_cast<unsigned long>(status.selftest_runs),
                static_cast<unsigned long>(status.selftest_failures),
                static_cast<unsigned long>(status.bench_runs),
                static_cast<unsigned long>(status.bench_loops),
                static_cast<unsigned long>(status.bench_pixels),
                static_cast<unsigned long>(status.bench_l8_to_rgb565_us),
                static_cast<unsigned long>(status.bench_idx8_to_rgb565_us),
                static_cast<unsigned long>(status.bench_rgb888_to_rgb565_us),
                static_cast<unsigned long>(status.bench_s16_gain_q15_us));
}

void printMediaStatus() {
  const MediaManager::Snapshot media = g_media.snapshot();
  Serial.printf("REC_STATUS playing=%u recording=%u elapsed=%u/%u file=%s music_dir=%s picture_dir=%s record_dir=%s last_ok=%u err=%s\n",
                media.playing ? 1U : 0U,
                media.recording ? 1U : 0U,
                static_cast<unsigned int>(media.record_elapsed_seconds),
                static_cast<unsigned int>(media.record_limit_seconds),
                media.record_file[0] != '\0' ? media.record_file : "n/a",
                media.music_dir,
                media.picture_dir,
                media.record_dir,
                media.last_ok ? 1U : 0U,
                media.last_error[0] != '\0' ? media.last_error : "none");
}

void printUiSceneStatus() {
  const UiSceneStatusSnapshot ui = g_ui.sceneStatusSnapshot();
  const StorageManager::ScenePayloadMeta payload_meta = g_storage.lastScenePayloadMeta();

  StaticJsonDocument<1536> document;
  document["render_backend_lock"] = "lgfx_runtime_only";
  document["valid"] = ui.valid;
  document["scenario_id"] = ui.scenario_id;
  document["step_id"] = ui.step_id;
  document["scene_id"] = ui.scene_id;
  document["audio_pack_id"] = ui.audio_pack_id;
  document["audio_playing"] = ui.audio_playing;
  document["show_title"] = ui.show_title;
  document["show_subtitle"] = ui.show_subtitle;
  document["show_symbol"] = ui.show_symbol;
  document["text_backend"] = ui.text_backend;
  document["lvgl_text_disabled"] = ui.lvgl_text_disabled;
  document["title"] = ui.title;
  document["subtitle"] = ui.subtitle;
  document["symbol"] = ui.symbol;
  document["effect"] = ui.effect;
  document["effect_speed_ms"] = ui.effect_speed_ms;
  document["text_glitch_pct"] = ui.text_glitch_pct;
  document["text_size_pct"] = ui.text_size_pct;
  document["transition"] = ui.transition;
  document["transition_ms"] = ui.transition_ms;
  document["overlay_draw_ok"] = ui.overlay_draw_ok;
  document["overlay_draw_fail"] = ui.overlay_draw_fail;
  document["overlay_startwrite_fail"] = ui.overlay_startwrite_fail;
  document["overlay_skip_busy"] = ui.overlay_skip_busy;
  document["bg_rgb"] = ui.bg_rgb;
  document["accent_rgb"] = ui.accent_rgb;
  document["text_rgb"] = ui.text_rgb;
  document["payload_crc"] = ui.payload_crc;
  document["payload_origin"] = payload_meta.origin;
  document["payload_source_kind"] = payload_meta.source_kind;
  document["story_debug_bypass"] = g_scenario.debugTransitionBypassEnabled();

  serializeJson(document, Serial);
  Serial.println();
}

constexpr const char* kWebUiIndex = R"HTML(
<!doctype html>
<html>
<head>
  <meta charset="utf-8" />
  <meta name="viewport" content="width=device-width,initial-scale=1" />
  <title>Zacus Freenove</title>
  <link rel="icon" type="image/png" href="/webui/assets/favicon.png" />
  <link rel="preload" href="/webui/assets/fonts/PressStart2P-Regular.ttf" as="font" type="font/ttf" crossorigin />
  <link rel="preload" href="/webui/assets/fonts/ComicNeue-Regular.ttf" as="font" type="font/ttf" crossorigin />
  <link rel="preload" href="/webui/assets/fonts/ComicNeue-Bold.ttf" as="font" type="font/ttf" crossorigin />
  <style>
    @font-face { font-family: 'ZacusPixel'; src: url('/webui/assets/fonts/PressStart2P-Regular.ttf') format('truetype'); font-display: swap; }
    @font-face { font-family: 'ZacusKids'; src: url('/webui/assets/fonts/ComicNeue-Regular.ttf') format('truetype'); font-display: swap; }
    @font-face { font-family: 'ZacusKids'; src: url('/webui/assets/fonts/ComicNeue-Bold.ttf') format('truetype'); font-display: swap; font-weight: 700; }
    body { font-family: 'ZacusKids', sans-serif; margin: 1rem; background: radial-gradient(circle at 15% 20%, #173a7a 0%, #0a1734 45%, #060d1f 100%); color: #eef4ff; }
    h2 { font-family: 'ZacusPixel', monospace; font-size: 1.08rem; letter-spacing: 0.04em; text-align: center; }
    .card { border: 1px solid #355192; border-radius: 12px; padding: 1rem; margin-bottom: 1rem; background: rgba(10,26,61,0.72); box-shadow: 0 10px 24px rgba(0,0,0,0.28); }
    .wbtn { margin: 0.25rem; padding: 0.56rem 0.86rem; border-radius: 10px; border: 1px solid #6fe7ff; background: #17356f; color: #f2f8ff; cursor: pointer; font-family: 'ZacusPixel', monospace; font-size: 0.62rem; letter-spacing: 0.03em; }
    .wbtn:hover { filter: brightness(1.15); }
    input { margin: 0.25rem; padding: 0.46rem; border-radius: 8px; border: 1px solid #4e6fb4; background: #0f2149; color: #f4f9ff; }
    pre { white-space: pre-wrap; word-break: break-word; background: #112549; padding: 0.8rem; border-radius: 8px; border: 1px solid #355192; }
    .hero { width: 100%; max-width: 640px; border-radius: 14px; border: 1px solid #4a6ebe; display: block; margin: 0.5rem auto 1rem auto; }
  </style>
</head>
<body>
  <h2>Zacus Freenove WebUI</h2>
  <img class="hero" src="/webui/assets/header.png" alt="Zacus Kids WebUI" />
  <div class="card">
    <button class="wbtn" onclick="unlock()">UNLOCK</button>
    <button class="wbtn" onclick="nextStep()">NEXT</button>
    <button class="wbtn" onclick="storyRefreshSd()">STORY_REFRESH_SD</button>
    <button class="wbtn" onclick="wifiDisc()">WIFI_DISCONNECT</button>
    <button class="wbtn" onclick="wifiReconn()">WIFI_RECONNECT</button>
    <button class="wbtn" onclick="refreshStatus()">Refresh</button>
  </div>
  <div class="card">
    <input id="ssid" placeholder="SSID" />
    <input id="pass" placeholder="Password" />
    <button class="wbtn" onclick="wifiConn()">WIFI_CONNECT</button>
  </div>
  <div class="card">
    <input id="token" placeholder="Bearer token" />
    <button class="wbtn" onclick="saveToken()">SET TOKEN</button>
  </div>
  <div class="card">
    <input id="payload" placeholder="Payload" />
    <button class="wbtn" onclick="espnowSend()">ESPNOW_SEND</button>
    <button class="wbtn" onclick="espnowOn()">ESPNOW_ON</button>
    <button class="wbtn" onclick="espnowOff()">ESPNOW_OFF</button>
  </div>
  <div class="card">
    <pre id="status">loading...</pre>
  </div>
  <script>
    let stream;
    let reconnectTimer;
    function showStatus(json) {
      document.getElementById("status").textContent = JSON.stringify(json, null, 2);
    }
    const tokenStorageKey = "zacus_web_token";
    let apiToken = localStorage.getItem(tokenStorageKey) || "";
    const sfx = {
      click: new Audio("/webui/assets/sfx_click.wav"),
      ok: new Audio("/webui/assets/sfx_ok.wav"),
      error: new Audio("/webui/assets/sfx_error.wav")
    };
    function playSfx(name) {
      const channel = sfx[name];
      if (!channel) return;
      try {
        channel.currentTime = 0;
        channel.play();
      } catch (err) {}
    }
    function authHeaders() {
      if (!apiToken) {
        return {};
      }
      return { "Authorization": "Bearer " + apiToken };
    }
    function saveToken() {
      apiToken = (document.getElementById("token").value || "").trim();
      localStorage.setItem(tokenStorageKey, apiToken);
      refreshStatus();
      connectStream();
    }
    async function post(path, params) {
      playSfx("click");
      const body = new URLSearchParams(params || {});
      const res = await fetch(path, { method: "POST", body, headers: authHeaders() });
      if (res.ok) {
        playSfx("ok");
      } else {
        playSfx("error");
      }
      await refreshStatus();
    }
    async function refreshStatus() {
      const res = await fetch("/api/status", { headers: authHeaders() });
      if (!res.ok) {
        document.getElementById("status").textContent = "HTTP " + res.status;
        return;
      }
      const json = await res.json();
      showStatus(json);
    }
    function connectStream() {
      if (stream) {
        stream.close();
        stream = null;
      }
      if (apiToken || typeof EventSource === "undefined") {
        return;
      }
      stream = new EventSource("/api/stream");
      stream.addEventListener("status", (evt) => {
        try {
          showStatus(JSON.parse(evt.data));
        } catch (err) {
          console.warn("status parse failed", err);
        }
      });
      stream.addEventListener("done", () => {
        stream.close();
        clearTimeout(reconnectTimer);
        reconnectTimer = setTimeout(connectStream, 400);
      });
      stream.onerror = () => {
        if (stream) {
          stream.close();
        }
        clearTimeout(reconnectTimer);
        reconnectTimer = setTimeout(connectStream, 1000);
      };
    }
    function unlock() { return post("/api/scenario/unlock"); }
    function nextStep() { return post("/api/scenario/next"); }
    function storyRefreshSd() { return post("/api/story/refresh-sd"); }
    function wifiDisc() { return post("/api/wifi/disconnect"); }
    function wifiReconn() { return post("/api/network/wifi/reconnect"); }
    function wifiConn() {
      return post("/api/wifi/connect", {
        ssid: document.getElementById("ssid").value,
        password: document.getElementById("pass").value,
        persist: 1
      });
    }
    function espnowOn() { return post("/api/network/espnow/on"); }
    function espnowOff() { return post("/api/network/espnow/off"); }
    function espnowSend() {
      return post("/api/espnow/send", {
        payload: document.getElementById("payload").value
      });
    }
    document.getElementById("token").value = apiToken;
    refreshStatus();
    setInterval(refreshStatus, 3000);
    connectStream();
  </script>
</body>
</html>
)HTML";

template <typename TDocument>
void webSendJsonDocument(const TDocument& document, int status_code = 200) {
  String payload;
  serializeJson(document, payload);
  g_web_server.send(status_code, "application/json", payload);
}

void webSendResult(const char* action, bool ok) {
  StaticJsonDocument<192> document;
  document["action"] = action;
  document["ok"] = ok;
  webSendJsonDocument(document, ok ? 200 : 400);
}

bool webSendLittleFsAsset(const char* path, const char* content_type) {
  if (path == nullptr || path[0] == '\0') {
    return false;
  }
  File file = LittleFS.open(path, "r");
  if (!file || file.isDirectory()) {
    return false;
  }
  g_web_server.streamFile(file, content_type != nullptr ? content_type : "application/octet-stream");
  file.close();
  return true;
}

template <size_t N>
bool webParseJsonBody(StaticJsonDocument<N>* out_document) {
  if (out_document == nullptr || !g_web_server.hasArg("plain")) {
    return false;
  }
  const String body = g_web_server.arg("plain");
  if (body.isEmpty()) {
    return false;
  }
  const DeserializationError error = deserializeJson(*out_document, body);
  return !error;
}

const char* appRuntimeStateLabel(AppRuntimeState state) {
  switch (state) {
    case AppRuntimeState::kStarting:
      return "starting";
    case AppRuntimeState::kRunning:
      return "running";
    case AppRuntimeState::kStopping:
      return "stopping";
    case AppRuntimeState::kFailed:
      return "failed";
    case AppRuntimeState::kIdle:
    default:
      return "idle";
  }
}

bool runtimeStartAppWithLog(const AppStartRequest& request, uint32_t now_ms, const char* channel) {
  const bool ok = g_app_runtime_manager.startApp(request, now_ms);
  const AppRuntimeStatus status = g_app_runtime_manager.current();
  Serial.printf("%s id=%s mode=%s source=%s state=%s err=%s channel=%s\n",
                ok ? "APP_OPEN_OK" : "APP_OPEN_FAIL",
                request.id,
                request.mode,
                request.source,
                appRuntimeStateLabel(status.state),
                status.last_error,
                (channel != nullptr) ? channel : "n/a");
  return ok;
}

bool runtimeStopAppWithLog(const AppStopRequest& request, uint32_t now_ms, const char* channel) {
  const bool ok = g_app_runtime_manager.stopApp(request, now_ms);
  const AppRuntimeStatus status = g_app_runtime_manager.current();
  Serial.printf("%s id=%s reason=%s state=%s err=%s channel=%s\n",
                ok ? "APP_CLOSE_OK" : "APP_CLOSE_FAIL",
                request.id,
                request.reason,
                appRuntimeStateLabel(status.state),
                status.last_error,
                (channel != nullptr) ? channel : "n/a");
  return ok;
}

bool runtimeHandleActionWithLog(const AppAction& action, uint32_t now_ms, const char* channel) {
  const bool ok = g_app_runtime_manager.handleAction(action, now_ms);
  const AppRuntimeStatus status = g_app_runtime_manager.current();
  Serial.printf("%s id=%s action=%s state=%s err=%s channel=%s\n",
                ok ? "APP_ACTION_OK" : "APP_ACTION_FAIL",
                action.id,
                action.name,
                appRuntimeStateLabel(status.state),
                status.last_error,
                (channel != nullptr) ? channel : "n/a");
  return ok;
}

bool amigaShellOpenAppBridge(const char* app_id, const char* mode, const char* source) {
  if (app_id == nullptr || app_id[0] == '\0') {
    Serial.println("APP_OPEN_FAIL id=<empty> reason=missing_app_id channel=amiga_shell_bridge");
    return false;
  }
  AppStartRequest request = {};
  copyText(request.id, sizeof(request.id), app_id);
  copyText(request.mode, sizeof(request.mode), (mode != nullptr && mode[0] != '\0') ? mode : "default");
  copyText(request.source, sizeof(request.source), (source != nullptr && source[0] != '\0') ? source : "amiga_shell");
  return runtimeStartAppWithLog(request, millis(), "amiga_shell_bridge");
}

bool amigaShellCloseAppBridge(const char* reason) {
  AppStopRequest request = {};
  copyText(request.reason, sizeof(request.reason), (reason != nullptr && reason[0] != '\0') ? reason : "amiga_shell");
  return runtimeStopAppWithLog(request, millis(), "amiga_shell_bridge");
}

AppRuntimeStatus amigaShellCurrentStatusBridge() {
  return g_app_runtime_manager.current();
}

const AmigaAppRuntimeBridge kAmigaAppRuntimeBridge = {
    amigaShellOpenAppBridge,
    amigaShellCloseAppBridge,
    amigaShellCurrentStatusBridge,
};

void clearRuntimeStaCredentials() {
  g_network_cfg.local_ssid[0] = '\0';
  g_network_cfg.local_password[0] = '\0';
  g_network_cfg.wifi_test_ssid[0] = '\0';
  g_network_cfg.wifi_test_password[0] = '\0';
}

void applyRuntimeStaCredentials(const char* ssid, const char* password) {
  copyText(g_network_cfg.local_ssid, sizeof(g_network_cfg.local_ssid), (ssid != nullptr) ? ssid : "");
  copyText(g_network_cfg.local_password, sizeof(g_network_cfg.local_password), (password != nullptr) ? password : "");
  copyText(g_network_cfg.wifi_test_ssid, sizeof(g_network_cfg.wifi_test_ssid), g_network_cfg.local_ssid);
  copyText(g_network_cfg.wifi_test_password, sizeof(g_network_cfg.wifi_test_password), g_network_cfg.local_password);
}

void updateAuthPolicy() {
  g_web_auth_required = !g_setup_mode;
}

bool ensureWebToken(bool rotate_token, bool print_token, bool* out_generated) {
  if (out_generated != nullptr) {
    *out_generated = false;
  }
  if (!rotate_token && g_web_auth_token[0] != '\0') {
    return true;
  }
  if (!rotate_token && g_credential_store.loadWebToken(g_web_auth_token, sizeof(g_web_auth_token))) {
    return true;
  }
  if (!g_credential_store.generateWebToken(g_web_auth_token, sizeof(g_web_auth_token))) {
    g_web_auth_token[0] = '\0';
    return false;
  }
  if (!g_credential_store.saveWebToken(g_web_auth_token)) {
    g_web_auth_token[0] = '\0';
    return false;
  }
  if (out_generated != nullptr) {
    *out_generated = true;
  }
  if (print_token) {
    Serial.printf("[AUTH] web token=%s\n", g_web_auth_token);
  }
  return true;
}

void loadBootProvisioningState() {
  clearRuntimeStaCredentials();
  char stored_ssid[sizeof(g_network_cfg.local_ssid)] = {0};
  char stored_password[sizeof(g_network_cfg.local_password)] = {0};
  const bool has_credentials =
      g_credential_store.loadStaCredentials(stored_ssid, sizeof(stored_ssid), stored_password, sizeof(stored_password));
  if (has_credentials) {
    applyRuntimeStaCredentials(stored_ssid, stored_password);
  }
  g_setup_mode = !has_credentials;
  updateAuthPolicy();
  if (!g_setup_mode) {
    bool token_generated = false;
    if (!ensureWebToken(false, true, &token_generated)) {
      Serial.println("[AUTH] web token load/generation failed");
    }
  }
}

bool provisionWifiCredentials(const char* ssid,
                              const char* password,
                              bool persist,
                              bool* out_connect_started,
                              bool* out_persisted,
                              bool* out_token_generated) {
  if (out_connect_started != nullptr) {
    *out_connect_started = false;
  }
  if (out_persisted != nullptr) {
    *out_persisted = false;
  }
  if (out_token_generated != nullptr) {
    *out_token_generated = false;
  }
  if (ssid == nullptr || ssid[0] == '\0') {
    return false;
  }

  bool persisted = true;
  if (persist) {
    persisted = g_credential_store.saveStaCredentials(ssid, (password != nullptr) ? password : "");
    if (persisted) {
      applyRuntimeStaCredentials(ssid, (password != nullptr) ? password : "");
      g_network.configureLocalPolicy(g_network_cfg.local_ssid,
                                     g_network_cfg.local_password,
                                     g_network_cfg.force_ap_if_not_local,
                                     g_network_cfg.local_retry_ms,
                                     g_network_cfg.pause_local_retry_when_ap_client);
      g_setup_mode = false;
      updateAuthPolicy();
      bool token_generated = false;
      if (!ensureWebToken(false, true, &token_generated)) {
        persisted = false;
      } else if (out_token_generated != nullptr) {
        *out_token_generated = token_generated;
      }
      g_network.stopAp();
    }
  }

  const bool connect_started = g_network.connectSta(ssid, (password != nullptr) ? password : "");
  if (out_connect_started != nullptr) {
    *out_connect_started = connect_started;
  }
  if (out_persisted != nullptr) {
    *out_persisted = persisted;
  }
  return connect_started && persisted;
}

bool forgetWifiCredentials() {
  const bool cleared = g_credential_store.clearStaCredentials();
  clearRuntimeStaCredentials();
  g_network.configureLocalPolicy(g_network_cfg.local_ssid,
                                 g_network_cfg.local_password,
                                 g_network_cfg.force_ap_if_not_local,
                                 g_network_cfg.local_retry_ms,
                                 g_network_cfg.pause_local_retry_when_ap_client);
  g_network.disconnectSta();
  g_setup_mode = true;
  updateAuthPolicy();
  if (g_network_cfg.ap_default_ssid[0] != '\0') {
    g_network.startAp(g_network_cfg.ap_default_ssid, g_network_cfg.ap_default_password);
  }
  return cleared;
}

bool isSetupWhitelistApiPath(const char* path) {
  if (path == nullptr) {
    return false;
  }
  return std::strcmp(path, kProvisionStatusPath) == 0 || std::strcmp(path, kSetupWifiConnectPath) == 0 ||
         std::strcmp(path, kSetupNetworkWifiConnectPath) == 0;
}

bool hasValidBearerToken() {
  const String header = g_web_server.header(kWebAuthHeaderName);
  if (!header.startsWith(kWebAuthBearerPrefix)) {
    return false;
  }
  String token = header.substring(static_cast<unsigned int>(std::strlen(kWebAuthBearerPrefix)));
  token.trim();
  return g_web_auth_token[0] != '\0' && token == g_web_auth_token;
}

bool webAuthorizeApiRequest(const char* path) {
  if (path == nullptr || std::strncmp(path, "/api/", 5U) != 0) {
    return true;
  }
  if (g_setup_mode) {
    if (isSetupWhitelistApiPath(path)) {
      return true;
    }
    g_web_server.send(403, "application/json", "{\"ok\":false,\"error\":\"setup_mode_restricted\"}");
    return false;
  }
  if (!g_web_auth_required) {
    return true;
  }
  if (g_web_auth_token[0] == '\0') {
    g_web_server.send(503, "application/json", "{\"ok\":false,\"error\":\"auth_token_missing\"}");
    return false;
  }
  if (hasValidBearerToken()) {
    return true;
  }
  g_web_server.send(401, "application/json", "{\"ok\":false,\"error\":\"unauthorized\"}");
  return false;
}

template <typename Handler>
void webOnApi(const char* path, HTTPMethod method, Handler&& handler) {
  g_web_server.on(path, method, [path, handler]() {
    if (!webAuthorizeApiRequest(path)) {
      return;
    }
    handler();
  });
}

void webFillEspNowStatus(JsonObject out, const NetworkManager::Snapshot& net) {
  out["ready"] = net.espnow_enabled;
  out["peer_count"] = net.espnow_peer_count;
  out["tx_ok"] = net.espnow_tx_ok;
  out["tx_fail"] = net.espnow_tx_fail;
  out["rx_count"] = net.espnow_rx_packets;
  out["last_rx_mac"] = String(net.last_rx_peer);
  out["last_msg_id"] = String(net.last_msg_id);
  out["last_seq"] = net.espnow_last_seq;
  out["last_type"] = String(net.last_type);
  out["last_ack"] = net.espnow_last_ack;
  out["last_payload"] = String(net.last_payload);
  out["device_name"] = g_espnow_device_name;
  out["send_mode"] = "broadcast";
  out["discovery"] = true;
  out["discovery_runtime"] = g_espnow_discovery_runtime_enabled;
  out["discovery_interval_ms"] = kEspNowDiscoveryIntervalMs;
  JsonArray peers = out["peers"].to<JsonArray>();
  for (uint8_t index = 0U; index < g_network.espNowPeerCount(); ++index) {
    char peer[18] = {0};
    if (!g_network.espNowPeerAt(index, peer, sizeof(peer))) {
      continue;
    }
    peers.add(peer);
  }
}

void webFillWifiStatus(JsonObject out, const NetworkManager::Snapshot& net) {
  out["connected"] = net.sta_connected;
  out["has_credentials"] = (g_network_cfg.local_ssid[0] != '\0');
  out["setup_mode"] = g_setup_mode;
  out["auth_required"] = g_web_auth_required;
  out["token_set"] = (g_web_auth_token[0] != '\0');
  out["ssid"] = String(net.sta_ssid);
  out["ip"] = net.sta_connected ? String(net.ip) : String("");
  out["rssi"] = net.rssi;
  out["state"] = String(net.state);
  out["ap_active"] = net.ap_enabled;
  out["ap_ssid"] = String(net.ap_ssid);
  out["ap_ip"] = (!net.sta_connected && net.ap_enabled) ? String(net.ip) : String("");
  out["ap_clients"] = net.ap_clients;
  out["local_retry_paused"] = net.local_retry_paused;
  out["mode"] = String(net.mode);
}

void webFillHardwareStatus(JsonObject out) {
  const HardwareManager::Snapshot& hw = g_hardware.snapshotRef();
  out["ready"] = hw.ready;
  out["enabled_on_boot"] = g_hardware_cfg.enabled_on_boot;
  out["led_auto_from_scene"] = g_hardware_cfg.led_auto_from_scene;
  out["telemetry_period_ms"] = g_hardware_cfg.telemetry_period_ms;
  out["ws2812_ready"] = hw.ws2812_ready;
  out["mic_ready"] = hw.mic_ready;
  out["battery_ready"] = hw.battery_ready;
  out["led_manual"] = hw.led_manual;
  out["backlight_level"] = g_lcd_backlight_level;
  out["backlight_mode"] = g_scene_backlight_mode;
  JsonObject led = out["led"].to<JsonObject>();
  led["r"] = hw.led_r;
  led["g"] = hw.led_g;
  led["b"] = hw.led_b;
  led["mode"] = hw.led_mode;
  led["one_led_at_a_time"] = hw.led_one_at_a_time;
  out["led_brightness"] = hw.led_brightness;
  out["mic_enabled"] = g_hardware_cfg.mic_enabled;
  out["mic_threshold_pct"] = g_hardware_cfg.mic_event_threshold_pct;
  out["mic_level_pct"] = hw.mic_level_percent;
  out["mic_peak"] = hw.mic_peak;
  out["mic_noise_floor"] = hw.mic_noise_floor;
  out["mic_gain_pct"] = hw.mic_gain_percent;
  out["mic_freq_hz"] = hw.mic_freq_hz;
  out["mic_pitch_cents"] = hw.mic_pitch_cents;
  out["mic_pitch_confidence"] = hw.mic_pitch_confidence;
  JsonObject la_trigger = out["la_trigger"].to<JsonObject>();
  la_trigger["enabled"] = g_hardware_cfg.mic_la_trigger_enabled;
  la_trigger["target_hz"] = g_hardware_cfg.mic_la_target_hz;
  la_trigger["tolerance_hz"] = g_hardware_cfg.mic_la_tolerance_hz;
  la_trigger["max_abs_cents"] = g_hardware_cfg.mic_la_max_abs_cents;
  la_trigger["min_confidence"] = g_hardware_cfg.mic_la_min_confidence;
  la_trigger["min_level_pct"] = g_hardware_cfg.mic_la_min_level_pct;
  la_trigger["stable_ms"] = g_hardware_cfg.mic_la_stable_ms;
  la_trigger["release_ms"] = g_hardware_cfg.mic_la_release_ms;
  la_trigger["cooldown_ms"] = g_hardware_cfg.mic_la_cooldown_ms;
  la_trigger["timeout_ms"] = g_hardware_cfg.mic_la_timeout_ms;
  la_trigger["sequence_enabled"] = g_hardware_cfg.mic_la_sequence_enabled;
  la_trigger["sequence_note_hold_ms"] = g_hardware_cfg.mic_la_sequence_note_hold_ms;
  la_trigger["sequence_count"] = g_hardware_cfg.mic_la_sequence_count;
  JsonArray sequence = la_trigger["sequence_hz"].to<JsonArray>();
  for (uint8_t idx = 0U; idx < g_hardware_cfg.mic_la_sequence_count &&
                         idx < RuntimeHardwareConfig::kLaSequenceMaxNotes;
       ++idx) {
    sequence.add(g_hardware_cfg.mic_la_sequence_hz[idx]);
  }
  la_trigger["event_name"] = g_hardware_cfg.mic_la_event_name;
  la_trigger["gate_active"] = g_la_trigger.gate_active;
  la_trigger["sample_match"] = g_la_trigger.sample_match;
  la_trigger["locked"] = g_la_trigger.locked;
  la_trigger["timeout_pending"] = g_la_trigger.timeout_pending;
  la_trigger["stable_now_ms"] = g_la_trigger.stable_ms;
  la_trigger["stable_pct"] = laStablePercent();
  la_trigger["sequence_index"] = g_la_trigger.sequence_index;
  la_trigger["sequence_target_hz"] = g_la_trigger.sequence_target_hz;
  out["battery_enabled"] = g_hardware_cfg.battery_enabled;
  out["battery_low_pct"] = g_hardware_cfg.battery_low_pct;
  out["battery_pct"] = hw.battery_percent;
  out["battery_mv"] = hw.battery_cell_mv;
  out["charging"] = hw.charging;
  out["last_button"] = hw.last_button;
  out["scene_id"] = hw.scene_id;
}

void webFillCameraStatus(JsonObject out) {
  const CameraManager::Snapshot camera = g_camera.snapshot();
  out["supported"] = camera.supported;
  out["enabled"] = camera.enabled;
  out["initialized"] = camera.initialized;
  out["enabled_on_boot"] = g_camera_cfg.enabled_on_boot;
  out["frame_size"] = camera.frame_size;
  out["jpeg_quality"] = camera.jpeg_quality;
  out["fb_count"] = camera.fb_count;
  out["xclk_hz"] = camera.xclk_hz;
  out["snapshot_dir"] = camera.snapshot_dir;
  out["capture_count"] = camera.capture_count;
  out["fail_count"] = camera.fail_count;
  out["last_capture_ms"] = camera.last_capture_ms;
  out["last_file"] = camera.last_file;
  out["last_error"] = camera.last_error;
  out["scene_active"] = g_camera_scene_active;
  out["ui_ready"] = g_camera_scene_ready;
  out["ui_visible"] = g_camera_scene_ready ? g_camera_player.visible() : false;
  out["recorder_session_active"] = camera.recorder_session_active;
  out["recorder_frozen"] = camera.recorder_frozen;
  out["recorder_preview_width"] = camera.recorder_preview_width;
  out["recorder_preview_height"] = camera.recorder_preview_height;
  out["recorder_selected_file"] = camera.recorder_selected_file;
}

void webFillMediaStatus(JsonObject out, uint32_t now_ms) {
  const MediaManager::Snapshot media = g_media.snapshot();
  out["ready"] = media.ready;
  out["playing"] = media.playing;
  out["playing_path"] = media.playing_path;
  out["recording"] = media.recording;
  out["record_limit_seconds"] = media.record_limit_seconds;
  uint16_t elapsed = media.record_elapsed_seconds;
  if (media.recording && media.record_started_ms > 0U) {
    elapsed = static_cast<uint16_t>((now_ms - media.record_started_ms) / 1000U);
  }
  out["record_elapsed_seconds"] = elapsed;
  out["record_file"] = media.record_file;
  out["record_simulated"] = media.record_simulated;
  out["music_dir"] = media.music_dir;
  out["picture_dir"] = media.picture_dir;
  out["record_dir"] = media.record_dir;
  out["last_ok"] = media.last_ok;
  out["last_error"] = media.last_error;
}

void webFillAppsStatus(JsonObject out) {
  const AppRuntimeStatus app_status = g_app_runtime_manager.current();
  out["running_id"] = app_status.id;
  out["state"] = appRuntimeStateLabel(app_status.state);
  out["mode"] = app_status.mode;
  out["source"] = app_status.source;
  out["last_error"] = app_status.last_error;
  out["last_event"] = app_status.last_event;
  out["started_at_ms"] = app_status.started_at_ms;
  out["last_tick_ms"] = app_status.last_tick_ms;
  out["tick_count"] = app_status.tick_count;
  out["required_cap_mask"] = app_status.required_cap_mask;
  out["missing_cap_mask"] = app_status.missing_cap_mask;
  out["avg_tick_us"] = app_status.avg_tick_us;
  out["max_tick_us"] = app_status.max_tick_us;
  out["heap_free"] = app_status.heap_free;
  out["psram_free"] = app_status.psram_free;
  JsonArray catalog = out["catalog"].to<JsonArray>();
  const std::vector<AppDescriptor>& descriptors = g_app_registry.descriptors();
  for (const AppDescriptor& descriptor : descriptors) {
    JsonObject item = catalog.createNestedObject();
    item["id"] = descriptor.id;
    item["title"] = descriptor.title;
    item["category"] = descriptor.category;
    item["entry_screen"] = descriptor.entry_screen;
    item["enabled"] = descriptor.enabled;
    item["version"] = descriptor.version;
    item["icon_path"] = descriptor.icon_path;
    item["supports_offline"] = descriptor.supports_offline;
    item["supports_streaming"] = descriptor.supports_streaming;
    item["required_capabilities"] = descriptor.required_capabilities;
    item["optional_capabilities"] = descriptor.optional_capabilities;
    item["asset_manifest"] = descriptor.asset_manifest;
  }
}

void webSendWifiStatus() {
  const NetworkManager::Snapshot net = g_network.snapshot();
  StaticJsonDocument<384> document;
  webFillWifiStatus(document.to<JsonObject>(), net);
  webSendJsonDocument(document);
}

void webSendEspNowStatus() {
  const NetworkManager::Snapshot net = g_network.snapshot();
  StaticJsonDocument<512> document;
  webFillEspNowStatus(document.to<JsonObject>(), net);
  webSendJsonDocument(document);
}

void webSendHardwareStatus() {
  StaticJsonDocument<1280> document;
  webFillHardwareStatus(document.to<JsonObject>());
  webSendJsonDocument(document);
}

void webSendCameraStatus() {
  StaticJsonDocument<768> document;
  webFillCameraStatus(document.to<JsonObject>());
  webSendJsonDocument(document);
}

void webSendMediaFiles() {
  String kind = g_web_server.arg("kind");
  if (kind.isEmpty()) {
    kind = "music";
  }
  String files_json;
  const bool ok = g_media.listFiles(kind.c_str(), &files_json);
  DynamicJsonDocument response(3072);
  response["ok"] = ok;
  response["kind"] = kind;
  if (ok) {
    DynamicJsonDocument files_doc(2048);
    if (!deserializeJson(files_doc, files_json)) {
      response["files"] = files_doc.as<JsonArrayConst>();
    } else {
      response["files_raw"] = files_json;
    }
  } else {
    response["error"] = "invalid_kind";
  }
  webSendJsonDocument(response, ok ? 200 : 400);
}

void webSendMediaRecordStatus() {
  StaticJsonDocument<768> document;
  webFillMediaStatus(document.to<JsonObject>(), millis());
  webSendJsonDocument(document);
}

void webSendAppsList() {
  DynamicJsonDocument document(12288);
  JsonObject root = document.to<JsonObject>();
  root["ok"] = true;
  JsonArray apps = root["apps"].to<JsonArray>();
  const std::vector<AppDescriptor>& descriptors = g_app_registry.descriptors();
  for (const AppDescriptor& descriptor : descriptors) {
    JsonObject app = apps.createNestedObject();
    app["id"] = descriptor.id;
    app["title"] = descriptor.title;
    app["category"] = descriptor.category;
    app["entry_screen"] = descriptor.entry_screen;
    app["enabled"] = descriptor.enabled;
    app["version"] = descriptor.version;
    app["icon_path"] = descriptor.icon_path;
    app["required_capabilities"] = descriptor.required_capabilities;
    app["optional_capabilities"] = descriptor.optional_capabilities;
    app["supports_offline"] = descriptor.supports_offline;
    app["supports_streaming"] = descriptor.supports_streaming;
    app["asset_manifest"] = descriptor.asset_manifest;
  }
  webSendJsonDocument(document);
}

void webSendAppsStatus() {
  DynamicJsonDocument document(12288);
  JsonObject root = document.to<JsonObject>();
  root["ok"] = true;
  webFillAppsStatus(root["runtime"].to<JsonObject>());
  webSendJsonDocument(document);
}

void webSendAppsContentHealth() {
  DynamicJsonDocument document(12288);
  JsonObject root = document.to<JsonObject>();
  root["ok"] = true;
  const NetworkManager::Snapshot net = g_network.snapshot();
  JsonArray apps = root["apps"].to<JsonArray>();
  const std::vector<AppDescriptor>& descriptors = g_app_registry.descriptors();
  for (const AppDescriptor& descriptor : descriptors) {
    JsonObject app = apps.createNestedObject();
    app["id"] = descriptor.id;
    app["enabled"] = descriptor.enabled;
    app["supports_offline"] = descriptor.supports_offline;
    app["supports_streaming"] = descriptor.supports_streaming;
    app["manifest"] = descriptor.asset_manifest;
    const bool manifest_ok = (descriptor.asset_manifest[0] != '\0') &&
                             g_storage.fileExists(descriptor.asset_manifest);
    app["manifest_ok"] = manifest_ok;
    char streams_path[120] = {0};
    std::snprintf(streams_path, sizeof(streams_path), "/apps/%s/streams.json", descriptor.id);
    app["streams"] = streams_path;
    app["streams_ok"] = g_storage.fileExists(streams_path);
    char progress_path[120] = {0};
    std::snprintf(progress_path, sizeof(progress_path), "/apps/%s/progress.json", descriptor.id);
    app["progress"] = progress_path;
    app["progress_ok"] = g_storage.fileExists(progress_path);
    app["network_ready"] = net.sta_connected;
    if (!descriptor.enabled) {
      app["status"] = "disabled";
      continue;
    }
    if (!manifest_ok) {
      app["status"] = "degraded";
      app["error"] = "missing_asset";
      continue;
    }
    if (descriptor.supports_streaming && !descriptor.supports_offline && !net.sta_connected) {
      app["status"] = "degraded";
      app["error"] = "network_unavailable";
      continue;
    }
    app["status"] = "ok";
  }
  webSendJsonDocument(document);
}

void webSendSharePeers() {
  DynamicJsonDocument document(2048);
  JsonObject root = document.to<JsonObject>();
  root["ok"] = true;
  JsonArray peers = root["peers"].to<JsonArray>();
  FileShareService::PeerInfo discovered[FileShareService::kMaxPeers] = {};
  const uint8_t count = g_file_share_service.discoverPeers(discovered, FileShareService::kMaxPeers);
  for (uint8_t idx = 0U; idx < count; ++idx) {
    JsonObject peer = peers.createNestedObject();
    peer["instance"] = discovered[idx].instance;
    peer["host"] = discovered[idx].host;
    peer["ip"] = discovered[idx].ip;
    peer["port"] = discovered[idx].port;
  }
  root["count"] = count;
  webSendJsonDocument(document);
}

void webSendShareFiles() {
  String files_json;
  const bool ok = g_file_share_service.listSharedFiles(&files_json);
  DynamicJsonDocument response(4096);
  response["ok"] = ok;
  if (ok) {
    DynamicJsonDocument files_doc(3072);
    if (!deserializeJson(files_doc, files_json)) {
      response["files"] = files_doc.as<JsonArrayConst>();
    } else {
      response["files_raw"] = files_json;
    }
  } else {
    response["error"] = "share_list_failed";
  }
  webSendJsonDocument(response, ok ? 200 : 500);
}

void webSendAuthStatus() {
  StaticJsonDocument<256> document;
  document["setup_mode"] = g_setup_mode;
  document["auth_required"] = g_web_auth_required;
  document["token_set"] = (g_web_auth_token[0] != '\0');
  document["provisioned"] = g_credential_store.isProvisioned();
  document["has_credentials"] = (g_network_cfg.local_ssid[0] != '\0');
  webSendJsonDocument(document);
}

void webSendProvisionStatus() {
  StaticJsonDocument<384> document;
  const NetworkManager::Snapshot net = g_network.snapshot();
  document["setup_mode"] = g_setup_mode;
  document["auth_required"] = g_web_auth_required;
  document["token_set"] = (g_web_auth_token[0] != '\0');
  document["provisioned"] = g_credential_store.isProvisioned();
  document["has_credentials"] = (g_network_cfg.local_ssid[0] != '\0');
  document["sta_connected"] = net.sta_connected;
  document["sta_connecting"] = net.sta_connecting;
  document["ap_enabled"] = net.ap_enabled;
  document["sta_ssid"] = net.sta_ssid;
  document["ap_ssid"] = net.ap_ssid;
  document["ip"] = net.ip;
  webSendJsonDocument(document);
}

void webSendEspNowPeerList() {
  StaticJsonDocument<384> document;
  JsonArray peers = document.to<JsonArray>();
  for (uint8_t index = 0U; index < g_network.espNowPeerCount(); ++index) {
    char peer[18] = {0};
    if (!g_network.espNowPeerAt(index, peer, sizeof(peer))) {
      continue;
    }
    peers.add(peer);
  }
  webSendJsonDocument(document);
}

bool webReconnectLocalWifi() {
  if (g_network_cfg.local_ssid[0] == '\0') {
    return false;
  }
  return g_network.connectSta(g_network_cfg.local_ssid, g_network_cfg.local_password);
}

void webScheduleStaDisconnect() {
  g_web_disconnect_sta_pending = true;
  g_web_disconnect_sta_at_ms = millis() + 250U;
}

bool refreshStoryFromSd() {
  const bool synced_tree = g_storage.syncStoryTreeFromSd();
  const bool synced_default = g_storage.syncStoryFileFromSd(kDefaultScenarioFile);
  const bool synced = synced_tree || synced_default;
  if (!synced) {
    return false;
  }
  const bool reloaded = g_scenario.begin(kDefaultScenarioFile);
  if (reloaded) {
    g_last_action_step_key[0] = '\0';
    resetLaTriggerState(false);
    refreshSceneIfNeeded(true);
    startPendingAudioIfAny();
  }
  Serial.printf("[SCENARIO] refresh from sd synced=%u reload=%u\n", synced ? 1U : 0U, reloaded ? 1U : 0U);
  return reloaded;
}

void maybeEmitHardwareEvents(uint32_t now_ms) {
  if (!g_hardware_started) {
    return;
  }
  const HardwareManager::Snapshot& hw = g_hardware.snapshotRef();
  const ScenarioSnapshot scenario = g_scenario.snapshot();

  if (g_hardware_cfg.mic_enabled && hw.mic_ready) {
    if (hw.mic_level_percent >= g_hardware_cfg.mic_event_threshold_pct) {
      if (g_mic_event_armed && g_hardware_cfg.mic_event_name[0] != '\0') {
        dispatchScenarioEventByName(g_hardware_cfg.mic_event_name, now_ms);
        g_mic_event_armed = false;
      }
    } else if (hw.mic_level_percent + 6U < g_hardware_cfg.mic_event_threshold_pct) {
      g_mic_event_armed = true;
    }
  }

  if (g_hardware_cfg.battery_enabled && hw.battery_ready) {
    if (!g_battery_low_latched && hw.battery_percent <= g_hardware_cfg.battery_low_pct &&
        g_hardware_cfg.battery_low_event_name[0] != '\0') {
      dispatchScenarioEventByName(g_hardware_cfg.battery_low_event_name, now_ms);
      g_battery_low_latched = true;
    } else if (g_battery_low_latched && hw.battery_percent > (g_hardware_cfg.battery_low_pct + 4U)) {
      g_battery_low_latched = false;
    }
  }

  updateLaGameplayTrigger(scenario, hw, now_ms);
}

void maybeLogHardwareTelemetry(uint32_t now_ms) {
  if (!g_hardware_started || g_hardware_cfg.telemetry_period_ms < 250U) {
    return;
  }
  if (now_ms < g_next_hw_telemetry_ms) {
    return;
  }
  g_next_hw_telemetry_ms = now_ms + g_hardware_cfg.telemetry_period_ms;
  const HardwareManager::Snapshot& hw = g_hardware.snapshotRef();
  Serial.printf("[HW] telemetry mic=%u%% peak=%u battery=%u%% (%umV) led=%u,%u,%u auto=%u\n",
                hw.mic_level_percent,
                hw.mic_peak,
                hw.battery_percent,
                hw.battery_cell_mv,
                hw.led_r,
                hw.led_g,
                hw.led_b,
                g_hardware_cfg.led_auto_from_scene ? 1U : 0U);
}

void maybeStreamMicTunerStatus(uint32_t now_ms) {
  if (!g_hardware_started || !g_mic_tuner_stream_enabled) {
    return;
  }
  if (now_ms < g_next_mic_tuner_stream_ms) {
    return;
  }
  g_next_mic_tuner_stream_ms = now_ms + g_mic_tuner_stream_period_ms;
  printMicTunerStatus();
}

bool executeStoryAction(const char* action_id, const ScenarioSnapshot& snapshot, uint32_t now_ms) {
  if (action_id == nullptr || action_id[0] == '\0') {
    return false;
  }

  if (std::strcmp(action_id, "ACTION_TRACE_STEP") == 0) {
    Serial.printf("[ACTION] TRACE scenario=%s step=%s screen=%s audio=%s\n",
                  scenarioIdFromSnapshot(snapshot),
                  stepIdFromSnapshot(snapshot),
                  snapshot.screen_scene_id != nullptr ? snapshot.screen_scene_id : "n/a",
                  snapshot.audio_pack_id != nullptr ? snapshot.audio_pack_id : "n/a");
    return true;
  }

  if (std::strcmp(action_id, "ACTION_QUEUE_SONAR") == 0) {
    const char* candidates[4] = {"/music/sonar_hint.mp3", nullptr, nullptr, nullptr};
    size_t candidate_count = 1U;
    if (snapshot.screen_scene_id != nullptr && isCreditsSceneId(snapshot.screen_scene_id)) {
      const char* selected_path = "/music/SCENE_WIN.mp3";
      const bool ok = playCreditsWinTrack(&selected_path);
      Serial.printf("[ACTION] QUEUE_AUDIO_PACK pack=PACK_SONAR_HINT scene=%s path=%s ok=%u source=credits_win\n",
                    snapshot.screen_scene_id,
                    selected_path,
                    ok ? 1U : 0U);
      return ok;
    }
    if (snapshot.screen_scene_id != nullptr &&
        std::strcmp(snapshot.screen_scene_id, "SCENE_LA_DETECTOR") == 0) {
      candidates[0] = "/music/scene_la_detector_tts.mp3";
      candidates[1] = "/music/sonar_hint.mp3";
      candidate_count = 2U;
    } else if (snapshot.screen_scene_id != nullptr &&
               std::strcmp(snapshot.screen_scene_id, "SCENE_WIN_ETAPE") == 0) {
      candidates[0] = "/music/scene_win.mp3";
      candidates[1] = "/music/SCENE_WIN.mp3";
      candidates[2] = "/music/SCENE_WIN_ETAPE.mp3";
      candidates[3] = "/music/sonar_hint.mp3";
      candidate_count = 4U;
    }

    bool ok = false;
    const char* selected_path = candidates[0];
    for (size_t index = 0; index < candidate_count; ++index) {
      const char* path = candidates[index];
      if (path == nullptr || path[0] == '\0') {
        continue;
      }
      if (g_audio.play(path)) {
        selected_path = path;
        ok = true;
        break;
      }
    }

    Serial.printf("[ACTION] QUEUE_AUDIO_PACK pack=PACK_SONAR_HINT scene=%s path=%s ok=%u source=builtin\n",
                  snapshot.screen_scene_id != nullptr ? snapshot.screen_scene_id : "n/a",
                  selected_path,
                  ok ? 1U : 0U);
    return ok;
  }

  const String action_path = String("/story/actions/") + action_id + ".json";
  String payload = g_storage.loadTextFile(action_path.c_str());
  if (payload.isEmpty()) {
    const char* alias_id = nullptr;
    if (std::strcmp(action_id, "ACTION_QR_CODE_SCANNER_START") == 0) {
      alias_id = "ACTION_QR_SCAN_START";
    } else if (std::strcmp(action_id, "ACTION_SET_BOOT_MEDIA_MANAGER") == 0) {
      alias_id = "ACTION_BOOT_MEDIA_MGR";
    }
    if (alias_id != nullptr) {
      const String alias_path = String("/story/actions/") + alias_id + ".json";
      payload = g_storage.loadTextFile(alias_path.c_str());
      if (!payload.isEmpty()) {
        Serial.printf("[ACTION] payload alias id=%s file=%s\n", action_id, alias_id);
      }
    }
  }
  g_story_action_doc.clear();
  if (!payload.isEmpty()) {
    deserializeJson(g_story_action_doc, payload);
  }
  const char* action_type = g_story_action_doc["type"] | "";

  if (std::strcmp(action_type, "trace_step") == 0) {
    Serial.printf("[ACTION] TRACE scenario=%s step=%s screen=%s audio=%s\n",
                  scenarioIdFromSnapshot(snapshot),
                  stepIdFromSnapshot(snapshot),
                  snapshot.screen_scene_id != nullptr ? snapshot.screen_scene_id : "n/a",
                  snapshot.audio_pack_id != nullptr ? snapshot.audio_pack_id : "n/a");
    return true;
  }

  if (std::strcmp(action_type, "open_app") == 0) {
    const char* app_id = g_story_action_doc["config"]["id"] | g_story_action_doc["config"]["app_id"] | "";
    const char* mode = g_story_action_doc["config"]["mode"] | "story";
    if (app_id[0] == '\0') {
      return false;
    }
    AppStartRequest request = {};
    copyText(request.id, sizeof(request.id), app_id);
    copyText(request.mode, sizeof(request.mode), mode);
    copyText(request.source, sizeof(request.source), "story_action");
    const bool ok = runtimeStartAppWithLog(request, now_ms, "story_action");
    return ok;
  }

  if (std::strcmp(action_type, "close_app") == 0) {
    const char* app_id = g_story_action_doc["config"]["id"] | g_story_action_doc["config"]["app_id"] | "";
    const char* reason = g_story_action_doc["config"]["reason"] | "story_action";
    AppStopRequest request = {};
    copyText(request.id, sizeof(request.id), app_id);
    copyText(request.reason, sizeof(request.reason), reason);
    const bool ok = runtimeStopAppWithLog(request, now_ms, "story_action");
    return ok;
  }

  if (std::strcmp(action_type, "app_action") == 0) {
    AppAction app_action = {};
    const char* app_id = g_story_action_doc["config"]["id"] | g_story_action_doc["config"]["app_id"] | "";
    const char* action_name = g_story_action_doc["config"]["action"] | g_story_action_doc["config"]["name"] | "";
    const char* content_type = g_story_action_doc["config"]["content_type"] | "application/json";
    copyText(app_action.id, sizeof(app_action.id), app_id);
    copyText(app_action.name, sizeof(app_action.name), action_name);
    String payload;
    if (g_story_action_doc["config"]["payload"].is<JsonVariantConst>()) {
      serializeJson(g_story_action_doc["config"]["payload"], payload);
    } else {
      payload = g_story_action_doc["config"]["payload"] | "";
    }
    copyText(app_action.payload, sizeof(app_action.payload), payload.c_str());
    copyText(app_action.content_type, sizeof(app_action.content_type), content_type);
    const bool ok = runtimeHandleActionWithLog(app_action, now_ms, "story_action");
    return ok;
  }

  if (std::strcmp(action_id, "ACTION_REFRESH_SD") == 0 || std::strcmp(action_type, "refresh_storage") == 0) {
    const bool ok = g_storage.syncStoryTreeFromSd() || g_storage.syncStoryFileFromSd(kDefaultScenarioFile);
    Serial.printf("[ACTION] REFRESH_SD ok=%u\n", ok ? 1U : 0U);
    return ok;
  }

  if (std::strcmp(action_id, "ACTION_HW_LED_ALERT") == 0) {
    const uint8_t red = static_cast<uint8_t>(g_story_action_doc["config"]["r"] | 255U);
    const uint8_t green = static_cast<uint8_t>(g_story_action_doc["config"]["g"] | 60U);
    const uint8_t blue = static_cast<uint8_t>(g_story_action_doc["config"]["b"] | 32U);
    const uint8_t brightness = static_cast<uint8_t>(g_story_action_doc["config"]["brightness"] | 92U);
    const bool pulse = g_story_action_doc["config"]["pulse"] | true;
    return g_hardware.setManualLed(red, green, blue, brightness, pulse);
  }

  if (std::strcmp(action_id, "ACTION_HW_LED_READY") == 0) {
    const bool auto_scene = g_story_action_doc["config"]["auto_from_scene"] | true;
    g_hardware.clearManualLed();
    if (auto_scene && snapshot.screen_scene_id != nullptr && g_hardware_cfg.led_auto_from_scene) {
      g_hardware.setSceneHint(snapshot.screen_scene_id);
    }
    return true;
  }

  if (std::strcmp(action_id, "ACTION_CAMERA_SNAPSHOT") == 0) {
    const char* filename = g_story_action_doc["config"]["filename"] | "";
    const char* event_name = g_story_action_doc["config"]["event_on_success"] | "SERIAL:CAMERA_CAPTURED";
    if (!approveCameraOperation("action_camera_snapshot", nullptr)) {
      return false;
    }
    String out_path;
    const bool ok = g_camera.snapshotToFile(filename[0] != '\0' ? filename : nullptr, &out_path);
    Serial.printf("[ACTION] CAMERA_SNAPSHOT ok=%u path=%s\n", ok ? 1U : 0U, ok ? out_path.c_str() : "n/a");
    if (ok) {
      dispatchScenarioEventByName(event_name, now_ms);
    }
    return ok;
  }

  if (std::strcmp(action_type, "queue_audio_pack") == 0) {
    const char* pack_id = g_story_action_doc["config"]["pack_id"] | g_story_action_doc["config"]["pack"] | "";
    String audio_path = g_storage.resolveAudioPathByPackId(pack_id);
    if (audio_path.isEmpty()) {
      const char* fallback_file = g_story_action_doc["config"]["file"] | g_story_action_doc["config"]["path"] | "";
      if (fallback_file[0] != '\0') {
        audio_path = fallback_file;
      }
    }
    if (audio_path.isEmpty()) {
      Serial.printf("[ACTION] QUEUE_AUDIO_PACK missing path pack=%s\n", pack_id);
      return false;
    }
    const bool ok = g_audio.play(audio_path.c_str());
    Serial.printf("[ACTION] QUEUE_AUDIO_PACK pack=%s path=%s ok=%u\n",
                  pack_id[0] != '\0' ? pack_id : "n/a",
                  audio_path.c_str(),
                  ok ? 1U : 0U);
    return ok;
  }

  if (std::strcmp(action_id, "ACTION_MEDIA_PLAY_FILE") == 0) {
    const char* media_file = g_story_action_doc["config"]["file"] | g_story_action_doc["config"]["path"] | "/music/boot_radio.mp3";
    return g_media.play(media_file, &g_audio);
  }

  if (std::strcmp(action_id, "ACTION_REC_START") == 0) {
    const uint16_t seconds = static_cast<uint16_t>(g_story_action_doc["config"]["seconds"] | g_story_action_doc["config"]["duration_sec"] |
                                                   g_media_cfg.record_max_seconds);
    const char* filename = g_story_action_doc["config"]["filename"] | "";
    return g_media.startRecording(seconds, filename);
  }

  if (std::strcmp(action_id, "ACTION_REC_STOP") == 0) {
    return g_media.stopRecording();
  }

  if (std::strcmp(action_id, "ACTION_ESP_NOW_SEND_ETAPE1") == 0 ||
      std::strcmp(action_id, "ACTION_ESP_NOW_SEND_ETAPE2") == 0 ||
      std::strcmp(action_id, "ACTION_ESP_NOW_WAITING") == 0 ||
      std::strcmp(action_id, "ACTION_ESP_NOW_WAITING_VALIDATION") == 0 ||
      std::strcmp(action_type, "espnow_send") == 0) {
    const char* target = g_story_action_doc["config"]["target"] | g_story_action_doc["config"]["peer"] | "broadcast";
    const char* payload = g_story_action_doc["config"]["payload"] | "";
    String payload_text;
    if (payload[0] == '\0') {
      const char* inferred = "WAITING_VALIDATION";
      if (std::strstr(action_id, "ETAPE2") != nullptr) {
        inferred = "ACK_WIN2";
      } else if (std::strstr(action_id, "ETAPE1") != nullptr) {
        inferred = "ACK_WIN1";
      }
      payload_text = inferred;
      payload = payload_text.c_str();
    } else {
      payload_text = payload;
    }

    const bool is_waiting_action = (std::strcmp(action_id, "ACTION_ESP_NOW_WAITING") == 0 ||
                                    std::strcmp(action_id, "ACTION_ESP_NOW_WAITING_VALIDATION") == 0 ||
                                    payload_text == "WAITING_VALIDATION");
    if (is_waiting_action && !payload_text.startsWith("WAITING_VALIDATION ")) {
      const char* scene_for_waiting = (snapshot.screen_scene_id != nullptr && snapshot.screen_scene_id[0] != '\0')
                                          ? snapshot.screen_scene_id
                                          : "SCENE_WIN_ETAPE";
      // Keep command compact to avoid ESPNOW TX pressure while preserving scene context.
      payload_text = String("WAITING_VALIDATION ") + scene_for_waiting;
      payload = payload_text.c_str();
    }
    uint8_t attempts = 2U;
    uint16_t retry_delay_ms = 40U;
    if (payload_text.startsWith("WAITING_VALIDATION")) {
      // Validation launch is critical: ensure RTC hotline receives it and starts ringing.
      attempts = 4U;
      retry_delay_ms = 80U;
    }
    const bool ok = sendEspNowWithRetry(target, payload, attempts, retry_delay_ms, "ACTION_ESPNOW_SEND");
    Serial.printf("[ACTION] ESPNOW_SEND id=%s target=%s payload=%s ok=%u\n",
                  action_id,
                  target,
                  payload,
                  ok ? 1U : 0U);
    if (is_waiting_action) {
      const bool ring_ok = sendEspNowWithRetry(target, "RING", 2U, 40U, "ACTION_ESPNOW_RING");
      Serial.printf("[ACTION] ESPNOW_RING target=%s ok=%u\n", target, ring_ok ? 1U : 0U);
      return ok || ring_ok;
    }
    return ok;
  }

  if (std::strcmp(action_id, "ACTION_QR_CODE_SCANNER_START") == 0 ||
      std::strcmp(action_type, "qr_scanner_start") == 0) {
    Serial.println("[ACTION] QR scanner gate active");
    return true;
  }

  if (std::strcmp(action_id, "ACTION_WINNER") == 0 ||
      std::strcmp(action_type, "winner_fx") == 0) {
    Serial.println("[ACTION] WINNER effect armed");
    return true;
  }

  if (std::strcmp(action_id, "ACTION_SET_BOOT_MEDIA") == 0 ||
      std::strcmp(action_id, "ACTION_SET_BOOT_MEDIA_MANAGER") == 0) {
    if (kLockNvsMediaManagerMode) {
      applyStartupMode(BootModeStore::StartupMode::kStory);
      Serial.println("[ACTION] SET_BOOT_MEDIA_MANAGER blocked nvs_lock=1");
      return true;
    }
    const bool mode_ok = g_boot_mode_store.saveMode(BootModeStore::StartupMode::kMediaManager);
    const bool flag_ok = g_boot_mode_store.setMediaValidated(true);
    applyStartupMode(BootModeStore::StartupMode::kMediaManager);
    Serial.printf("[ACTION] SET_BOOT_MEDIA_MANAGER mode_ok=%u validated_ok=%u\n",
                  mode_ok ? 1U : 0U,
                  flag_ok ? 1U : 0U);
    return mode_ok && flag_ok;
  }

  return false;
}

void executeStoryActionsForStep(const ScenarioSnapshot& snapshot, uint32_t now_ms) {
  if (snapshot.step == nullptr) {
    g_has_ring_sent_for_win_etape = false;
    g_win_etape_ui_refresh_pending = false;
    return;
  }

  char step_key[sizeof(g_last_action_step_key)] = {0};
  snprintf(step_key,
           sizeof(step_key),
           "%s:%s",
           scenarioIdFromSnapshot(snapshot),
           stepIdFromSnapshot(snapshot));
  if (std::strcmp(step_key, g_last_action_step_key) == 0) {
    if (snapshot.action_ids == nullptr || snapshot.action_count == 0U) {
      return;
    }
  } else {
    copyText(g_last_action_step_key, sizeof(g_last_action_step_key), step_key);
    g_has_ring_sent_for_win_etape = std::strcmp(stepIdFromSnapshot(snapshot), kStepWinEtape) != 0;
    g_win_etape_ui_refresh_pending = false;
    if (snapshot.action_ids == nullptr || snapshot.action_count == 0U) {
      return;
    }
  }

  g_media.noteStepChange();
  for (uint8_t index = 0U; index < snapshot.action_count; ++index) {
    const char* action_id = snapshot.action_ids[index];
    if (action_id == nullptr || action_id[0] == '\0') {
      continue;
    }
    const bool ok = executeStoryAction(action_id, snapshot, now_ms);
    Serial.printf("[ACTION] id=%s ok=%u\n", action_id, ok ? 1U : 0U);
  }
}

bool dispatchControlActionImpl(const String& action_raw, uint32_t now_ms, String* out_error) {
  if (out_error != nullptr) {
    out_error->remove(0);
  }
  String action = action_raw;
  action.trim();
  if (action.isEmpty()) {
    if (out_error != nullptr) {
      *out_error = "empty_action";
    }
    return false;
  }

  auto parseRecorderFormat = [](const String& value,
                                ui::camera::CameraCaptureService::CaptureFormat* out_format) -> bool {
    if (out_format == nullptr) {
      return false;
    }
    String normalized = value;
    normalized.trim();
    normalized.toLowerCase();
    if (normalized.isEmpty() || normalized == "auto") {
      *out_format = ui::camera::CameraCaptureService::CaptureFormat::Auto;
      return true;
    }
    if (normalized == "bmp") {
      *out_format = ui::camera::CameraCaptureService::CaptureFormat::Bmp24;
      return true;
    }
    if (normalized == "jpg" || normalized == "jpeg") {
      *out_format = ui::camera::CameraCaptureService::CaptureFormat::Jpeg;
      return true;
    }
    if (normalized == "raw" || normalized == "rgb565") {
      *out_format = ui::camera::CameraCaptureService::CaptureFormat::RawRGB565;
      return true;
    }
    return false;
  };

  if (action.equalsIgnoreCase("UNLOCK")) {
    return dispatchScenarioEventByName("UNLOCK", now_ms);
  }
  if (action.equalsIgnoreCase("NEXT")) {
    return notifyScenarioButtonGuarded(5U, false, now_ms, "api_control");
  }
  if (action.equalsIgnoreCase("STORY_REFRESH_SD")) {
    return refreshStoryFromSd();
  }
  if (action.equalsIgnoreCase("WIFI_DISCONNECT")) {
    webScheduleStaDisconnect();
    return true;
  }
  if (action.equalsIgnoreCase("WIFI_FORGET")) {
    const bool ok = forgetWifiCredentials();
    if (!ok && out_error != nullptr) {
      *out_error = "wifi_forget_failed";
    }
    return ok;
  }
  if (action.equalsIgnoreCase("WIFI_RECONNECT")) {
    return webReconnectLocalWifi();
  }
  if (action.equalsIgnoreCase("ESPNOW_ON")) {
    return g_network.enableEspNow();
  }
  if (action.equalsIgnoreCase("ESPNOW_OFF")) {
    g_network.disableEspNow();
    return true;
  }
  if (action.equalsIgnoreCase("HW_STATUS")) {
    printHardwareStatus();
    return true;
  }
  if (action.equalsIgnoreCase("HW_STATUS_JSON")) {
    printHardwareStatusJson();
    return true;
  }
  if (action.equalsIgnoreCase("HW_MIC_STATUS")) {
    printHardwareStatus();
    return true;
  }
  if (action.equalsIgnoreCase("HW_BAT_STATUS")) {
    printHardwareStatus();
    return true;
  }
  if (action.equalsIgnoreCase("CAM_STATUS")) {
    printCameraStatus();
    return true;
  }
  if (action.equalsIgnoreCase("CAM_REC_STATUS")) {
    printCameraRecorderStatus();
    return true;
  }
  if (action.equalsIgnoreCase("CAM_UI_SHOW")) {
    if (!g_camera_scene_active) {
      if (out_error != nullptr) {
        *out_error = "camera_scene_inactive";
      }
      return false;
    }
    if (!ensureCameraUiInitialized()) {
      if (out_error != nullptr) {
        *out_error = "camera_ui_not_ready";
      }
      return false;
    }
    g_camera_player.show();
    return true;
  }
  if (action.equalsIgnoreCase("CAM_UI_HIDE")) {
    if (!g_camera_scene_active || !g_camera_scene_ready) {
      if (out_error != nullptr) {
        *out_error = "camera_scene_inactive";
      }
      return false;
    }
    g_camera_player.hide();
    return true;
  }
  if (action.equalsIgnoreCase("CAM_UI_TOGGLE")) {
    if (!g_camera_scene_active || !g_camera_scene_ready) {
      if (out_error != nullptr) {
        *out_error = "camera_scene_inactive";
      }
      return false;
    }
    g_camera_player.toggle();
    return true;
  }
  if (action.equalsIgnoreCase("CAM_REC_SNAP")) {
    if (!g_camera_scene_active || !g_camera_scene_ready) {
      if (out_error != nullptr) {
        *out_error = "camera_scene_inactive";
      }
      return false;
    }
    const bool was_frozen = g_camera_player.service().has_frozen();
    if (!g_camera_player.handleInputAction(ui::camera::Win311CameraUI::InputAction::kSnapToggle)) {
      if (out_error != nullptr) {
        *out_error = "camera_snap_failed";
      }
      return false;
    }
    const bool now_frozen = g_camera_player.service().has_frozen();
    if (!was_frozen && !now_frozen) {
      if (out_error != nullptr) {
        *out_error = "camera_snap_failed";
      }
      return false;
    }
    return true;
  }
  if (startsWithIgnoreCase(action.c_str(), "CAM_REC_SAVE")) {
    if (!g_camera_scene_active || !g_camera_scene_ready) {
      if (out_error != nullptr) {
        *out_error = "camera_scene_inactive";
      }
      return false;
    }
    const size_t prefix_len = std::strlen("CAM_REC_SAVE");
    String format_arg = action.substring(static_cast<unsigned int>(prefix_len));
    ui::camera::CameraCaptureService::CaptureFormat format = ui::camera::CameraCaptureService::CaptureFormat::Auto;
    if (!parseRecorderFormat(format_arg, &format)) {
      if (out_error != nullptr) {
        *out_error = "cam_rec_save_arg";
      }
      return false;
    }
    if (!g_camera_player.service().has_frozen()) {
      if (out_error != nullptr) {
        *out_error = "camera_not_frozen";
      }
      return false;
    }
    String out_path;
    const bool ok = g_camera_player.service().save_frozen(out_path, format);
    if (!ok && out_error != nullptr) {
      *out_error = "camera_save_failed";
    }
    return ok;
  }
  if (action.equalsIgnoreCase("CAM_REC_GALLERY")) {
    if (!g_camera_scene_active || !g_camera_scene_ready) {
      if (out_error != nullptr) {
        *out_error = "camera_scene_inactive";
      }
      return false;
    }
    return g_camera_player.handleInputAction(ui::camera::Win311CameraUI::InputAction::kGalleryToggle);
  }
  if (action.equalsIgnoreCase("CAM_REC_NEXT")) {
    if (!g_camera_scene_active || !g_camera_scene_ready) {
      if (out_error != nullptr) {
        *out_error = "camera_scene_inactive";
      }
      return false;
    }
    return g_camera_player.handleInputAction(ui::camera::Win311CameraUI::InputAction::kGalleryNext);
  }
  if (action.equalsIgnoreCase("CAM_REC_DELETE")) {
    if (!g_camera_scene_active || !g_camera_scene_ready) {
      if (out_error != nullptr) {
        *out_error = "camera_scene_inactive";
      }
      return false;
    }
    return g_camera_player.handleInputAction(ui::camera::Win311CameraUI::InputAction::kDeleteSelected);
  }
  if (action.equalsIgnoreCase("RESOURCE_STATUS")) {
    printResourceStatus();
    return true;
  }
  if (action.equalsIgnoreCase("SIMD_STATUS")) {
    printSimdStatus();
    return true;
  }
  if (action.equalsIgnoreCase("SIMD_SELFTEST")) {
    return runtime::simd::runSelfTestCommand();
  }
  if (startsWithIgnoreCase(action.c_str(), "SIMD_BENCH")) {
    uint32_t loops = 200U;
    uint32_t pixels = 7680U;
    const size_t prefix_len = std::strlen("SIMD_BENCH");
    String args = action.substring(static_cast<unsigned int>(prefix_len));
    args.trim();
    if (!args.isEmpty()) {
      const int sep = args.indexOf(' ');
      String loops_text = (sep < 0) ? args : args.substring(0, static_cast<unsigned int>(sep));
      String pixels_text = (sep < 0) ? String("") : args.substring(static_cast<unsigned int>(sep + 1));
      loops_text.trim();
      pixels_text.trim();
      if (!loops_text.isEmpty()) {
        loops = static_cast<uint32_t>(std::strtoul(loops_text.c_str(), nullptr, 10));
      }
      if (!pixels_text.isEmpty()) {
        pixels = static_cast<uint32_t>(std::strtoul(pixels_text.c_str(), nullptr, 10));
      }
    }
    const runtime::simd::SimdBenchResult result = runtime::simd::runBenchCommand(loops, pixels);
    Serial.printf("SIMD_BENCH loops=%lu pixels=%lu l8_us=%lu idx_us=%lu rgb888_us=%lu gain_us=%lu\n",
                  static_cast<unsigned long>(result.loops),
                  static_cast<unsigned long>(result.pixels),
                  static_cast<unsigned long>(result.l8_to_rgb565_us),
                  static_cast<unsigned long>(result.idx8_to_rgb565_us),
                  static_cast<unsigned long>(result.rgb888_to_rgb565_us),
                  static_cast<unsigned long>(result.s16_gain_q15_us));
    return true;
  }
  if (startsWithIgnoreCase(action.c_str(), "RESOURCE_PROFILE_AUTO")) {
    const size_t prefix_len = std::strlen("RESOURCE_PROFILE_AUTO");
    String profile_auto = action.substring(static_cast<unsigned int>(prefix_len));
    profile_auto.trim();
    bool parse_ok = false;
    applyResourceProfileAutoCommand(profile_auto.c_str(), &parse_ok);
    if (!parse_ok) {
      if (out_error != nullptr) {
        *out_error = "resource_profile_auto_arg";
      }
      return false;
    }
    return true;
  }
  if (startsWithIgnoreCase(action.c_str(), "RESOURCE_PROFILE")) {
    const size_t prefix_len = std::strlen("RESOURCE_PROFILE");
    String profile = action.substring(static_cast<unsigned int>(prefix_len));
    profile.trim();
    if (profile.isEmpty()) {
      printResourceStatus();
      return true;
    }
    if (!g_resource_coordinator.parseAndSetProfile(profile.c_str())) {
      if (out_error != nullptr) {
        *out_error = "resource_profile_arg";
      }
      return false;
    }
    g_resource_profile_auto = false;
    return true;
  }
  if (action.equalsIgnoreCase("CAM_ON")) {
    if (g_camera_scene_active) {
      if (out_error != nullptr) {
        *out_error = "camera_busy_recorder_owner";
      }
      return false;
    }
    if (!approveCameraOperation("cam_on", out_error)) {
      return false;
    }
    return g_camera.start();
  }
  if (action.equalsIgnoreCase("CAM_OFF")) {
    if (g_camera_scene_active) {
      if (out_error != nullptr) {
        *out_error = "camera_busy_recorder_owner";
      }
      return false;
    }
    g_camera.stop();
    return true;
  }
  if (action.equalsIgnoreCase("MEDIA_STOP")) {
    return g_media.stop(&g_audio);
  }
  if (action.equalsIgnoreCase("REC_STOP")) {
    return g_media.stopRecording();
  }
  if (action.equalsIgnoreCase("REC_STATUS")) {
    printMediaStatus();
    return true;
  }
  if (action.equalsIgnoreCase("BOOT_MODE_STATUS")) {
    printBootModeStatus();
    return true;
  }
  if (action.equalsIgnoreCase("BOOT_MODE_CLEAR")) {
    const bool ok = g_boot_mode_store.clearMode();
    applyStartupMode(BootModeStore::StartupMode::kStory);
    if (!ok && out_error != nullptr) {
      *out_error = "boot_mode_clear_failed";
    }
    return ok;
  }
  if (startsWithIgnoreCase(action.c_str(), "BOOT_MODE_SET ")) {
    String mode_text = action.substring(static_cast<unsigned int>(std::strlen("BOOT_MODE_SET ")));
    mode_text.trim();
    mode_text.toUpperCase();
    BootModeStore::StartupMode mode = BootModeStore::StartupMode::kStory;
    if (!parseBootModeToken(mode_text.c_str(), &mode)) {
      if (out_error != nullptr) {
        *out_error = "boot_mode_set_arg";
      }
      return false;
    }
    const bool ok = g_boot_mode_store.saveMode(mode);
    if (!ok) {
      if (out_error != nullptr) {
        *out_error = "boot_mode_set_failed";
      }
      return false;
    }
    applyStartupMode(mode);
    (void)g_boot_mode_store.setMediaValidated(mode == BootModeStore::StartupMode::kMediaManager);
    return true;
  }
  if (startsWithIgnoreCase(action.c_str(), "QR_SIM ")) {
    String payload = action.substring(static_cast<unsigned int>(std::strlen("QR_SIM ")));
    payload.trim();
    const bool ok = !payload.isEmpty() && g_ui.simulateQrPayload(payload.c_str());
    if (!ok && out_error != nullptr) {
      *out_error = "qr_sim_arg";
    }
    return ok;
  }

  if (startsWithIgnoreCase(action.c_str(), "WIFI_CONNECT ")) {
    String ssid;
    String password;
    if (!splitSsidPass(action.c_str() + std::strlen("WIFI_CONNECT "), &ssid, &password)) {
      return false;
    }
    return g_network.connectSta(ssid.c_str(), password.c_str());
  }

  if (startsWithIgnoreCase(action.c_str(), "WIFI_PROVISION ")) {
    String ssid;
    String password;
    if (!splitSsidPass(action.c_str() + std::strlen("WIFI_PROVISION "), &ssid, &password)) {
      if (out_error != nullptr) {
        *out_error = "wifi_provision_args";
      }
      return false;
    }
    bool connect_started = false;
    bool persisted = false;
    const bool ok = provisionWifiCredentials(ssid.c_str(), password.c_str(), true, &connect_started, &persisted, nullptr);
    if (!ok && out_error != nullptr) {
      *out_error = persisted ? "wifi_connect_failed" : "wifi_persist_failed";
    }
    return ok;
  }

  if (startsWithIgnoreCase(action.c_str(), "ESPNOW_SEND ")) {
    String args = action.substring(static_cast<unsigned int>(std::strlen("ESPNOW_SEND ")));
    String payload;
    if (!parseEspNowSendPayload(args.c_str(), payload, nullptr)) {
      return false;
    }
    return g_network.sendEspNowTarget(kEspNowBroadcastTarget, payload.c_str());
  }

  if (startsWithIgnoreCase(action.c_str(), "SC_EVENT_RAW ")) {
    char event_name[kSerialLineCapacity] = {0};
    copyText(event_name, sizeof(event_name), action.c_str() + std::strlen("SC_EVENT_RAW "));
    trimAsciiInPlace(event_name);
    if (event_name[0] == '\0') {
      return false;
    }
    return dispatchScenarioEventByName(event_name, now_ms);
  }

  if (startsWithIgnoreCase(action.c_str(), "SC_EVENT ")) {
    char args[kSerialLineCapacity] = {0};
    copyText(args, sizeof(args), action.c_str() + std::strlen("SC_EVENT "));
    trimAsciiInPlace(args);
    if (args[0] == '\0') {
      return false;
    }
    char* type_text = args;
    char* event_name = nullptr;
    for (size_t index = 0U; args[index] != '\0'; ++index) {
      if (args[index] != ' ') {
        continue;
      }
      args[index] = '\0';
      event_name = &args[index + 1U];
      break;
    }
    if (event_name != nullptr) {
      trimAsciiInPlace(event_name);
      if (event_name[0] == '\0') {
        event_name = nullptr;
      }
    }
    StoryEventType event_type = StoryEventType::kNone;
    if (!parseEventType(type_text, &event_type)) {
      return false;
    }
    return dispatchScenarioEventByType(event_type, event_name, now_ms);
  }

  if (startsWithIgnoreCase(action.c_str(), "SCENE_GOTO ")) {
    String scene_id = action.substring(static_cast<unsigned int>(std::strlen("SCENE_GOTO ")));
    scene_id.trim();
    scene_id.toUpperCase();
    if (scene_id.isEmpty()) {
      if (out_error != nullptr) {
        *out_error = "scene_goto_arg";
      }
      return false;
    }
    if (scene_id == "SCENE_LOCK") {
      scene_id = "SCENE_LOCKED";
    } else if (scene_id == "LOCKED" || scene_id == "LOCK") {
      scene_id = "SCENE_LOCKED";
    }
    const bool ok = g_scenario.gotoScene(scene_id.c_str(), now_ms, "scene_goto_control");
    if (!ok) {
      if (out_error != nullptr) {
        *out_error = "scene_not_found";
      }
      return false;
    }
    g_last_action_step_key[0] = '\0';
    refreshSceneIfNeeded(true);
    startPendingAudioIfAny();
    return true;
  }
  if (action.equalsIgnoreCase("SCENE_GOTO")) {
    if (out_error != nullptr) {
      *out_error = "scene_goto_arg";
    }
    return false;
  }

  if (startsWithIgnoreCase(action.c_str(), "HW_LED_SET ")) {
    String args = action.substring(static_cast<unsigned int>(std::strlen("HW_LED_SET ")));
    args.trim();
    uint8_t red = 0U;
    uint8_t green = 0U;
    uint8_t blue = 0U;
    uint8_t brightness = static_cast<uint8_t>(FREENOVE_WS2812_BRIGHTNESS);
    bool pulse = true;
    if (!parseHwLedSetArgs(args.c_str(), &red, &green, &blue, &brightness, &pulse)) {
      if (out_error != nullptr) {
        *out_error = "hw_led_set_args";
      }
      return false;
    }
    return g_hardware.setManualLed(red, green, blue, brightness, pulse);
  }

  if (startsWithIgnoreCase(action.c_str(), "HW_LED_AUTO ")) {
    String value = action.substring(static_cast<unsigned int>(std::strlen("HW_LED_AUTO ")));
    value.trim();
    bool enabled = false;
    if (!parseBoolToken(value.c_str(), &enabled)) {
      if (out_error != nullptr) {
        *out_error = "hw_led_auto_args";
      }
      return false;
    }
    g_hardware_cfg.led_auto_from_scene = enabled;
    if (enabled) {
      g_hardware.clearManualLed();
      const ScenarioSnapshot snapshot = g_scenario.snapshot();
      if (snapshot.screen_scene_id != nullptr) {
        g_hardware.setSceneHint(snapshot.screen_scene_id);
      }
    }
    return true;
  }

  if (startsWithIgnoreCase(action.c_str(), "LCD_BACKLIGHT ")) {
    String value = action.substring(static_cast<unsigned int>(std::strlen("LCD_BACKLIGHT ")));
    value.trim();
    if (value.isEmpty()) {
      if (out_error != nullptr) {
        *out_error = "lcd_backlight_arg";
      }
      return false;
    }
    const int parsed = value.toInt();
    if (parsed < 0 || parsed > 255) {
      if (out_error != nullptr) {
        *out_error = "lcd_backlight_range";
      }
      return false;
    }
    applyLcdBacklight(static_cast<uint8_t>(parsed));
    Serial.printf("LCD_BACKLIGHT level=%u\n", static_cast<unsigned int>(g_lcd_backlight_level));
    return true;
  }

  if (action.equalsIgnoreCase("LCD_BACKLIGHT")) {
    Serial.printf("LCD_BACKLIGHT level=%u\n", static_cast<unsigned int>(g_lcd_backlight_level));
    return true;
  }

  if (startsWithIgnoreCase(action.c_str(), "CAM_SNAPSHOT")) {
    if (g_camera_scene_active) {
      if (out_error != nullptr) {
        *out_error = "camera_busy_recorder_owner";
      }
      return false;
    }
    if (!approveCameraOperation("cam_snapshot", out_error)) {
      return false;
    }
    const size_t prefix_len = std::strlen("CAM_SNAPSHOT");
    String filename = action.substring(static_cast<unsigned int>(prefix_len));
    filename.trim();
    String out_path;
    const bool ok = g_camera.snapshotToFile(filename.isEmpty() ? nullptr : filename.c_str(), &out_path);
    if (ok) {
      dispatchScenarioEventByName("SERIAL:CAMERA_CAPTURED", now_ms);
    } else if (out_error != nullptr) {
      *out_error = "camera_snapshot_failed";
    }
    return ok;
  }

  if (startsWithIgnoreCase(action.c_str(), "MEDIA_PLAY ")) {
    String media_path = action.substring(static_cast<unsigned int>(std::strlen("MEDIA_PLAY ")));
    media_path.trim();
    const bool ok = !media_path.isEmpty() && g_media.play(media_path.c_str(), &g_audio);
    if (!ok && out_error != nullptr) {
      *out_error = "media_play_failed";
    }
    return ok;
  }

  if (startsWithIgnoreCase(action.c_str(), "REC_START")) {
    const size_t prefix_len = std::strlen("REC_START");
    String args = action.substring(static_cast<unsigned int>(prefix_len));
    args.trim();
    uint16_t seconds = g_media_cfg.record_max_seconds;
    String filename;
    if (!args.isEmpty()) {
      const int sep = args.indexOf(' ');
      String seconds_text = (sep < 0) ? args : args.substring(0, static_cast<unsigned int>(sep));
      filename = (sep < 0) ? String("") : args.substring(static_cast<unsigned int>(sep + 1));
      seconds_text.trim();
      filename.trim();
      if (!seconds_text.isEmpty()) {
        char* end = nullptr;
        const unsigned long parsed = std::strtoul(seconds_text.c_str(), &end, 10);
        if (end != seconds_text.c_str() && (end == nullptr || *end == '\0')) {
          seconds = static_cast<uint16_t>(parsed);
        }
      }
    }
    return g_media.startRecording(seconds, filename.isEmpty() ? nullptr : filename.c_str());
  }

  if (startsWithIgnoreCase(action.c_str(), "MEDIA_LIST ")) {
    String kind = action.substring(static_cast<unsigned int>(std::strlen("MEDIA_LIST ")));
    kind.trim();
    if (kind.isEmpty()) {
      kind = "music";
    }
    String files_json;
    const bool ok = g_media.listFiles(kind.c_str(), &files_json);
    if (ok) {
      Serial.printf("MEDIA_LIST kind=%s files=%s\n", kind.c_str(), files_json.c_str());
    }
    return ok;
  }

  if (startsWithIgnoreCase(action.c_str(), "APP_OPEN ")) {
    String args = action.substring(static_cast<unsigned int>(std::strlen("APP_OPEN ")));
    args.trim();
    if (args.isEmpty()) {
      if (out_error != nullptr) {
        *out_error = "app_open_arg";
      }
      return false;
    }
    const int sep = args.indexOf(' ');
    String app_id = (sep < 0) ? args : args.substring(0, static_cast<unsigned int>(sep));
    String mode = (sep < 0) ? String("default") : args.substring(static_cast<unsigned int>(sep + 1));
    app_id.trim();
    mode.trim();
    AppStartRequest request = {};
    copyText(request.id, sizeof(request.id), app_id.c_str());
    copyText(request.mode, sizeof(request.mode), mode.isEmpty() ? "default" : mode.c_str());
    copyText(request.source, sizeof(request.source), "control");
    const bool ok = runtimeStartAppWithLog(request, now_ms, "control_action");
    if (!ok && out_error != nullptr) {
      *out_error = g_app_runtime_manager.current().last_error;
    }
    return ok;
  }

  if (startsWithIgnoreCase(action.c_str(), "APP_CLOSE")) {
    String reason = "control";
    if (action.length() > std::strlen("APP_CLOSE")) {
      reason = action.substring(static_cast<unsigned int>(std::strlen("APP_CLOSE")));
      reason.trim();
      if (reason.isEmpty()) {
        reason = "control";
      }
    }
    AppStopRequest request = {};
    copyText(request.reason, sizeof(request.reason), reason.c_str());
    const bool ok = runtimeStopAppWithLog(request, now_ms, "control_action");
    if (!ok && out_error != nullptr) {
      *out_error = "app_close_failed";
    }
    return ok;
  }

  if (startsWithIgnoreCase(action.c_str(), "APP_ACTION ")) {
    String args = action.substring(static_cast<unsigned int>(std::strlen("APP_ACTION ")));
    args.trim();
    if (args.isEmpty()) {
      if (out_error != nullptr) {
        *out_error = "app_action_arg";
      }
      return false;
    }
    const int first_sep = args.indexOf(' ');
    String action_name = (first_sep < 0) ? args : args.substring(0, static_cast<unsigned int>(first_sep));
    String payload = (first_sep < 0) ? String("") : args.substring(static_cast<unsigned int>(first_sep + 1));
    action_name.trim();
    payload.trim();
    AppAction app_action = {};
    copyText(app_action.name, sizeof(app_action.name), action_name.c_str());
    copyText(app_action.payload, sizeof(app_action.payload), payload.c_str());
    copyText(app_action.content_type,
             sizeof(app_action.content_type),
             (payload.startsWith("{") || payload.startsWith("[")) ? "application/json" : "text/plain");
    const bool ok = runtimeHandleActionWithLog(app_action, now_ms, "control_action");
    if (!ok && out_error != nullptr) {
      *out_error = g_app_runtime_manager.current().last_error;
    }
    return ok;
  }

  if (action.equalsIgnoreCase("APP_STATUS")) {
    const AppRuntimeStatus status = g_app_runtime_manager.current();
    Serial.printf("APP_STATUS id=%s state=%s mode=%s source=%s err=%s event=%s tick=%lu missing=0x%08lX\n",
                  status.id,
                  appRuntimeStateLabel(status.state),
                  status.mode,
                  status.source,
                  status.last_error,
                  status.last_event,
                  static_cast<unsigned long>(status.tick_count),
                  static_cast<unsigned long>(status.missing_cap_mask));
    return true;
  }

  if (out_error != nullptr) {
    *out_error = "unsupported_action";
  }
  return false;
}

bool webDispatchAction(const String& action_raw) {
  return dispatchControlAction(action_raw, millis(), nullptr);
}

void webBuildStatusDocument(StaticJsonDocument<4096>* out_document) {
  if (out_document == nullptr) {
    return;
  }
  const NetworkManager::Snapshot net = g_network.snapshot();
  const ScenarioSnapshot scenario = g_scenario.snapshot();

  out_document->clear();
  JsonObject network = (*out_document)["network"].to<JsonObject>();
  network["state"] = String(net.state);
  network["mode"] = String(net.mode);
  network["sta_connected"] = net.sta_connected;
  network["sta_connecting"] = net.sta_connecting;
  network["fallback_ap"] = net.fallback_ap_active;
  network["sta_ssid"] = String(net.sta_ssid);
  network["ap_ssid"] = String(net.ap_ssid);
  network["local_target"] = String(net.local_target);
  network["local_match"] = net.local_match;
  network["ap_clients"] = net.ap_clients;
  network["local_retry_paused"] = net.local_retry_paused;
  network["ip"] = String(net.ip);
  network["rssi"] = net.rssi;

  JsonObject wifi = (*out_document)["wifi"].to<JsonObject>();
  webFillWifiStatus(wifi, net);

  JsonObject espnow = (*out_document)["espnow"].to<JsonObject>();
  webFillEspNowStatus(espnow, net);

  JsonObject story = (*out_document)["story"].to<JsonObject>();
  story["scenario"] = scenarioIdFromSnapshot(scenario);
  story["step"] = stepIdFromSnapshot(scenario);
  story["screen"] = (scenario.screen_scene_id != nullptr) ? scenario.screen_scene_id : "";
  story["audio_pack"] = (scenario.audio_pack_id != nullptr) ? scenario.audio_pack_id : "";

  JsonObject audio = (*out_document)["audio"].to<JsonObject>();
  audio["playing"] = g_audio.isPlaying();
  audio["track"] = g_audio.currentTrack();
  audio["codec"] = g_audio.activeCodec();
  audio["bitrate_kbps"] = g_audio.activeBitrateKbps();
  audio["fx"] = g_audio.fxProfile();
  audio["fx_label"] = g_audio.fxProfileLabel(g_audio.fxProfile());
  audio["profile"] = g_audio.outputProfile();
  audio["volume"] = g_audio.volume();

  JsonObject hardware = (*out_document)["hardware"].to<JsonObject>();
  webFillHardwareStatus(hardware);

  JsonObject camera = (*out_document)["camera"].to<JsonObject>();
  webFillCameraStatus(camera);

  JsonObject media = (*out_document)["media"].to<JsonObject>();
  webFillMediaStatus(media, millis());

  const AppRuntimeStatus app_status = g_app_runtime_manager.current();
  JsonObject apps = (*out_document)["apps"].to<JsonObject>();
  apps["running_id"] = app_status.id;
  apps["state"] = appRuntimeStateLabel(app_status.state);
  apps["last_error"] = app_status.last_error;
  apps["last_event"] = app_status.last_event;
  apps["missing_cap_mask"] = app_status.missing_cap_mask;

  const runtime::resource::ResourceCoordinatorSnapshot resource_snapshot = g_resource_coordinator.snapshot();
  const UiMemorySnapshot ui_snapshot = g_ui.memorySnapshot();
  JsonObject resource = (*out_document)["resource"].to<JsonObject>();
  resource["profile"] = g_resource_coordinator.profileName();
  resource["profile_auto"] = g_resource_profile_auto;
  resource["graphics_pressure"] = resource_snapshot.graphics_pressure;
  resource["mic_should_run"] = resource_snapshot.mic_should_run;
  resource["mic_force_on"] = resource_snapshot.mic_force_on;
  resource["allow_camera_ops"] = resource_snapshot.allow_camera_ops;
  resource["mic_hold_until_ms"] = resource_snapshot.mic_hold_until_ms;
  resource["camera_allowed_ops"] = resource_snapshot.camera_allowed_ops;
  resource["camera_blocked_ops"] = resource_snapshot.camera_blocked_ops;
  resource["flush_overflow_delta"] = resource_snapshot.flush_overflow_delta;
  resource["flush_blocked_delta"] = resource_snapshot.flush_blocked_delta;
  resource["fx_fps"] = ui_snapshot.fx_fps;
  resource["flush_blocked"] = ui_snapshot.flush_blocked;
  resource["flush_overflow"] = ui_snapshot.flush_overflow;
  resource["flush_stall"] = ui_snapshot.flush_stall;
  resource["flush_recover"] = ui_snapshot.flush_recover;
}

void webSendStatus() {
  StaticJsonDocument<4096> document;
  webBuildStatusDocument(&document);
  webSendJsonDocument(document);
}

void webSendStatusSse() {
  StaticJsonDocument<4096> document;
  webBuildStatusDocument(&document);
  char payload[4608] = {0};
  const size_t measured_size = measureJson(document);
  if (measured_size >= sizeof(payload)) {
    g_web_server.send(500, "application/json", "{\"ok\":false,\"error\":\"status_payload_too_large\"}");
    return;
  }
  const size_t payload_size = serializeJson(document, payload, sizeof(payload));
  if (payload_size == 0U) {
    g_web_server.send(500, "application/json", "{\"ok\":false,\"error\":\"status_serialize_failed\"}");
    return;
  }

  g_web_server.setContentLength(CONTENT_LENGTH_UNKNOWN);
  g_web_server.sendHeader("Cache-Control", "no-cache");
  g_web_server.sendHeader("Connection", "close");
  g_web_server.send(200, "text/event-stream", "");
  g_web_server.sendContent("event: status\n");
  g_web_server.sendContent("data: ");
  g_web_server.sendContent(payload, payload_size);
  g_web_server.sendContent("\n\n");
  g_web_server.sendContent("event: done\ndata: 1\n\n");
}

void setupWebUiImpl() {
  const char* auth_headers[] = {kWebAuthHeaderName};
  g_web_server.collectHeaders(auth_headers, 1);

  g_web_server.on("/", HTTP_GET, []() {
    g_web_server.send(200, "text/html", kWebUiIndex);
  });

  g_web_server.on("/webui/assets/header.png", HTTP_GET, []() {
    if (!webSendLittleFsAsset("/webui/assets/header.png", "image/png")) {
      g_web_server.send(404, "application/json", "{\"ok\":false,\"error\":\"asset_missing\"}");
    }
  });
  g_web_server.on("/webui/assets/favicon.png", HTTP_GET, []() {
    if (!webSendLittleFsAsset("/webui/assets/favicon.png", "image/png")) {
      g_web_server.send(404, "application/json", "{\"ok\":false,\"error\":\"asset_missing\"}");
    }
  });
  g_web_server.on("/webui/assets/fonts/PressStart2P-Regular.ttf", HTTP_GET, []() {
    if (!webSendLittleFsAsset("/webui/assets/fonts/PressStart2P-Regular.ttf", "font/ttf")) {
      g_web_server.send(404, "application/json", "{\"ok\":false,\"error\":\"asset_missing\"}");
    }
  });
  g_web_server.on("/webui/assets/fonts/ComicNeue-Regular.ttf", HTTP_GET, []() {
    if (!webSendLittleFsAsset("/webui/assets/fonts/ComicNeue-Regular.ttf", "font/ttf")) {
      g_web_server.send(404, "application/json", "{\"ok\":false,\"error\":\"asset_missing\"}");
    }
  });
  g_web_server.on("/webui/assets/fonts/ComicNeue-Bold.ttf", HTTP_GET, []() {
    if (!webSendLittleFsAsset("/webui/assets/fonts/ComicNeue-Bold.ttf", "font/ttf")) {
      g_web_server.send(404, "application/json", "{\"ok\":false,\"error\":\"asset_missing\"}");
    }
  });
  g_web_server.on("/webui/assets/sfx_click.wav", HTTP_GET, []() {
    if (!webSendLittleFsAsset("/webui/assets/sfx_click.wav", "audio/wav")) {
      g_web_server.send(404, "application/json", "{\"ok\":false,\"error\":\"asset_missing\"}");
    }
  });
  g_web_server.on("/webui/assets/sfx_ok.wav", HTTP_GET, []() {
    if (!webSendLittleFsAsset("/webui/assets/sfx_ok.wav", "audio/wav")) {
      g_web_server.send(404, "application/json", "{\"ok\":false,\"error\":\"asset_missing\"}");
    }
  });
  g_web_server.on("/webui/assets/sfx_error.wav", HTTP_GET, []() {
    if (!webSendLittleFsAsset("/webui/assets/sfx_error.wav", "audio/wav")) {
      g_web_server.send(404, "application/json", "{\"ok\":false,\"error\":\"asset_missing\"}");
    }
  });

  webOnApi(kProvisionStatusPath, HTTP_GET, []() {
    webSendProvisionStatus();
  });

  webOnApi("/api/auth/status", HTTP_GET, []() {
    webSendAuthStatus();
  });

  webOnApi("/api/status", HTTP_GET, []() {
    webSendStatus();
  });

  webOnApi("/api/stream", HTTP_GET, []() {
    webSendStatusSse();
  });

  webOnApi("/api/hardware", HTTP_GET, []() {
    webSendHardwareStatus();
  });

  webOnApi("/api/hardware/led", HTTP_POST, []() {
    int red = g_web_server.arg("r").toInt();
    int green = g_web_server.arg("g").toInt();
    int blue = g_web_server.arg("b").toInt();
    int brightness = g_web_server.hasArg("brightness") ? g_web_server.arg("brightness").toInt() : FREENOVE_WS2812_BRIGHTNESS;
    bool pulse = true;
    if (g_web_server.hasArg("pulse")) {
      pulse = (g_web_server.arg("pulse").toInt() != 0);
    }
    StaticJsonDocument<256> request_json;
    if (webParseJsonBody(&request_json)) {
      if (request_json["r"].is<int>()) {
        red = request_json["r"].as<int>();
      }
      if (request_json["g"].is<int>()) {
        green = request_json["g"].as<int>();
      }
      if (request_json["b"].is<int>()) {
        blue = request_json["b"].as<int>();
      }
      if (request_json["brightness"].is<int>()) {
        brightness = request_json["brightness"].as<int>();
      }
      if (request_json["pulse"].is<bool>()) {
        pulse = request_json["pulse"].as<bool>();
      }
    }
    if (brightness < 0) {
      brightness = 0;
    } else if (brightness > 255) {
      brightness = 255;
    }
    const bool ok = g_hardware.setManualLed(static_cast<uint8_t>(red),
                                            static_cast<uint8_t>(green),
                                            static_cast<uint8_t>(blue),
                                            static_cast<uint8_t>(brightness),
                                            pulse);
    webSendResult("HW_LED_SET", ok);
  });

  webOnApi("/api/hardware/led/auto", HTTP_POST, []() {
    bool enabled = false;
    bool parsed = false;
    if (g_web_server.hasArg("enabled")) {
      parsed = parseBoolToken(g_web_server.arg("enabled").c_str(), &enabled);
    } else if (g_web_server.hasArg("value")) {
      parsed = parseBoolToken(g_web_server.arg("value").c_str(), &enabled);
    }
    StaticJsonDocument<128> request_json;
    if (!parsed && webParseJsonBody(&request_json)) {
      if (request_json["enabled"].is<bool>()) {
        enabled = request_json["enabled"].as<bool>();
        parsed = true;
      } else if (request_json["value"].is<bool>()) {
        enabled = request_json["value"].as<bool>();
        parsed = true;
      }
    }
    if (!parsed) {
      webSendResult("HW_LED_AUTO", false);
      return;
    }
    g_hardware_cfg.led_auto_from_scene = enabled;
    if (enabled) {
      g_hardware.clearManualLed();
      const ScenarioSnapshot snapshot = g_scenario.snapshot();
      if (snapshot.screen_scene_id != nullptr) {
        g_hardware.setSceneHint(snapshot.screen_scene_id);
      }
    }
    webSendResult("HW_LED_AUTO", true);
  });

  webOnApi("/api/camera/status", HTTP_GET, []() {
    webSendCameraStatus();
  });

  webOnApi("/api/camera/on", HTTP_POST, []() {
    if (g_camera_scene_active) {
      g_web_server.send(409, "application/json", "{\"ok\":false,\"error\":\"camera_busy_recorder_owner\"}");
      return;
    }
    String err;
    if (!approveCameraOperation("web_cam_on", &err)) {
      g_web_server.send(429, "application/json", "{\"ok\":false,\"error\":\"camera_blocked_by_resource_profile\"}");
      return;
    }
    const bool ok = g_camera.start();
    webSendResult("CAM_ON", ok);
  });

  webOnApi("/api/camera/off", HTTP_POST, []() {
    if (g_camera_scene_active) {
      g_web_server.send(409, "application/json", "{\"ok\":false,\"error\":\"camera_busy_recorder_owner\"}");
      return;
    }
    g_camera.stop();
    webSendResult("CAM_OFF", true);
  });

  webOnApi("/api/camera/snapshot.jpg", HTTP_GET, []() {
    if (g_camera_scene_active) {
      g_web_server.send(409, "application/json", "{\"ok\":false,\"error\":\"camera_busy_recorder_owner\"}");
      return;
    }
    String err;
    if (!approveCameraOperation("web_cam_snapshot", &err)) {
      g_web_server.send(429, "application/json", "{\"ok\":false,\"error\":\"camera_blocked_by_resource_profile\"}");
      return;
    }
    String out_path;
    if (!g_camera.snapshotToFile(nullptr, &out_path)) {
      g_web_server.send(500, "application/json", "{\"ok\":false,\"error\":\"camera_snapshot_failed\"}");
      return;
    }
    File image = LittleFS.open(out_path.c_str(), "r");
    if (!image) {
      g_web_server.send(500, "application/json", "{\"ok\":false,\"error\":\"camera_snapshot_missing\"}");
      return;
    }
    g_web_server.streamFile(image, "image/jpeg");
    image.close();
    dispatchScenarioEventByName("SERIAL:CAMERA_CAPTURED", millis());
  });

  webOnApi("/api/media/files", HTTP_GET, []() {
    webSendMediaFiles();
  });

  webOnApi("/api/media/play", HTTP_POST, []() {
    String path = g_web_server.arg("path");
    StaticJsonDocument<256> request_json;
    if (webParseJsonBody(&request_json) && path.isEmpty()) {
      path = request_json["path"] | request_json["file"] | "";
    }
    const bool ok = !path.isEmpty() && g_media.play(path.c_str(), &g_audio);
    webSendResult("MEDIA_PLAY", ok);
  });

  webOnApi("/api/media/stop", HTTP_POST, []() {
    const bool ok = g_media.stop(&g_audio);
    webSendResult("MEDIA_STOP", ok);
  });

  webOnApi("/api/media/record/start", HTTP_POST, []() {
    uint16_t seconds = static_cast<uint16_t>(g_web_server.arg("seconds").toInt());
    String filename = g_web_server.arg("filename");
    StaticJsonDocument<256> request_json;
    if (webParseJsonBody(&request_json)) {
      if (request_json["seconds"].is<unsigned int>()) {
        seconds = static_cast<uint16_t>(request_json["seconds"].as<unsigned int>());
      }
      if (filename.isEmpty()) {
        filename = request_json["filename"] | "";
      }
    }
    const bool ok = g_media.startRecording(seconds, filename.isEmpty() ? nullptr : filename.c_str());
    webSendResult("REC_START", ok);
  });

  webOnApi("/api/media/record/stop", HTTP_POST, []() {
    const bool ok = g_media.stopRecording();
    webSendResult("REC_STOP", ok);
  });

  webOnApi("/api/media/record/status", HTTP_GET, []() {
    webSendMediaRecordStatus();
  });

  webOnApi("/api/apps", HTTP_GET, []() {
    webSendAppsList();
  });

  webOnApi("/api/apps/status", HTTP_GET, []() {
    webSendAppsStatus();
  });

  webOnApi("/api/apps/content/health", HTTP_GET, []() {
    webSendAppsContentHealth();
  });

  webOnApi("/api/share/peers", HTTP_GET, []() {
    webSendSharePeers();
  });

  webOnApi("/api/share/files", HTTP_GET, []() {
    webSendShareFiles();
  });

  webOnApi("/api/share/download", HTTP_GET, []() {
    String path = g_web_server.arg("path");
    if (path.isEmpty()) {
      g_web_server.send(400, "application/json", "{\"ok\":false,\"error\":\"missing_path\"}");
      return;
    }
    File file;
    String full_path;
    if (!g_file_share_service.downloadFile(path.c_str(), &file, &full_path) || !file) {
      g_web_server.send(404, "application/json", "{\"ok\":false,\"error\":\"share_file_missing\"}");
      return;
    }
    const String lower = full_path;
    const char* type = "application/octet-stream";
    if (lower.endsWith(".json")) {
      type = "application/json";
    } else if (lower.endsWith(".txt")) {
      type = "text/plain";
    } else if (lower.endsWith(".jpg") || lower.endsWith(".jpeg")) {
      type = "image/jpeg";
    } else if (lower.endsWith(".png")) {
      type = "image/png";
    } else if (lower.endsWith(".wav")) {
      type = "audio/wav";
    } else if (lower.endsWith(".mp3")) {
      type = "audio/mpeg";
    }
    g_web_server.streamFile(file, type);
    file.close();
  });

  webOnApi("/api/share/upload", HTTP_POST, []() {
    String path = g_web_server.arg("path");
    if (path.isEmpty()) {
      path = g_web_server.arg("filename");
    }
    String payload = g_web_server.arg("plain");
    if (payload.isEmpty()) {
      payload = g_web_server.arg("payload");
    }
    const bool append_mode = g_web_server.arg("append") == "1";
    const uint32_t offset = static_cast<uint32_t>(g_web_server.arg("offset").toInt());
    if (path.isEmpty() || payload.isEmpty()) {
      g_web_server.send(400, "application/json", "{\"ok\":false,\"error\":\"missing_path_or_payload\"}");
      return;
    }
    String saved_path;
    bool ok = false;
    if (append_mode) {
      File file;
      String full_path;
      if (g_file_share_service.downloadFile(path.c_str(), &file, &full_path) && file) {
        file.close();
      }
      saved_path = String("/apps/shared/incoming/") + path;
      if (saved_path.indexOf("..") >= 0) {
        ok = false;
      } else {
        const char* mode = (offset == 0U) ? "w" : "a";
        File out = LittleFS.open(saved_path.c_str(), mode);
        if (out) {
          ok = (out.write(reinterpret_cast<const uint8_t*>(payload.c_str()), payload.length()) == payload.length());
          out.close();
        }
      }
    } else {
      ok = g_file_share_service.saveIncomingFile(path.c_str(),
                                                 reinterpret_cast<const uint8_t*>(payload.c_str()),
                                                 payload.length(),
                                                 &saved_path);
    }
    DynamicJsonDocument response(256);
    response["ok"] = ok;
    response["path"] = path;
    response["saved_path"] = saved_path;
    response["append"] = append_mode;
    response["offset"] = offset;
    if (!ok) {
      response["error"] = "share_upload_failed";
    }
    webSendJsonDocument(response, ok ? 200 : 500);
  });

  webOnApi("/api/share/pull", HTTP_POST, []() {
    String peer_host = g_web_server.arg("peer_host");
    uint16_t peer_port = static_cast<uint16_t>(g_web_server.arg("peer_port").toInt());
    String remote_path = g_web_server.arg("remote_path");
    String local_path = g_web_server.arg("local_path");
    String token = g_web_server.arg("token");
    StaticJsonDocument<512> request_json;
    if (webParseJsonBody(&request_json)) {
      if (peer_host.isEmpty()) {
        peer_host = request_json["peer_host"] | request_json["host"] | "";
      }
      if (peer_port == 0U) {
        peer_port = static_cast<uint16_t>(request_json["peer_port"] | request_json["port"] | 80U);
      }
      if (remote_path.isEmpty()) {
        remote_path = request_json["remote_path"] | request_json["path"] | "";
      }
      if (local_path.isEmpty()) {
        local_path = request_json["local_path"] | "";
      }
      if (token.isEmpty()) {
        token = request_json["token"] | "";
      }
    }
    if (token.isEmpty() && g_web_auth_token[0] != '\0') {
      token = g_web_auth_token;
    }
    String saved_path;
    String pull_error;
    const bool ok = g_file_share_service.pullFromPeer(peer_host.c_str(),
                                                      peer_port,
                                                      remote_path.c_str(),
                                                      local_path.c_str(),
                                                      token.c_str(),
                                                      &saved_path,
                                                      &pull_error);
    DynamicJsonDocument response(384);
    response["ok"] = ok;
    response["peer_host"] = peer_host;
    response["peer_port"] = peer_port;
    response["remote_path"] = remote_path;
    response["saved_path"] = saved_path;
    if (!ok) {
      response["error"] = pull_error;
    }
    webSendJsonDocument(response, ok ? 200 : 500);
  });

  webOnApi("/api/apps/open", HTTP_POST, []() {
    String app_id = g_web_server.arg("id");
    String mode = g_web_server.arg("mode");
    String source = g_web_server.arg("source");
    StaticJsonDocument<512> request_json;
    if (webParseJsonBody(&request_json)) {
      if (app_id.isEmpty()) {
        app_id = request_json["id"] | request_json["app_id"] | "";
      }
      if (mode.isEmpty()) {
        mode = request_json["mode"] | "default";
      }
      if (source.isEmpty()) {
        source = request_json["source"] | "api";
      }
    }
    if (app_id.isEmpty()) {
      g_web_server.send(400, "application/json", "{\"ok\":false,\"error\":\"missing_app_id\"}");
      return;
    }
    AppStartRequest request = {};
    copyText(request.id, sizeof(request.id), app_id.c_str());
    copyText(request.mode, sizeof(request.mode), mode.isEmpty() ? "default" : mode.c_str());
    copyText(request.source, sizeof(request.source), source.isEmpty() ? "api" : source.c_str());
    const bool ok = runtimeStartAppWithLog(request, millis(), "web_api");
    StaticJsonDocument<256> response;
    response["ok"] = ok;
    response["id"] = request.id;
    response["mode"] = request.mode;
    response["source"] = request.source;
    if (!ok) {
      response["error"] = g_app_runtime_manager.current().last_error;
    }
    webSendJsonDocument(response, ok ? 200 : 409);
  });

  webOnApi("/api/apps/close", HTTP_POST, []() {
    String app_id = g_web_server.arg("id");
    String reason = g_web_server.arg("reason");
    StaticJsonDocument<256> request_json;
    if (webParseJsonBody(&request_json)) {
      if (app_id.isEmpty()) {
        app_id = request_json["id"] | request_json["app_id"] | "";
      }
      if (reason.isEmpty()) {
        reason = request_json["reason"] | "api";
      }
    }
    AppStopRequest request = {};
    copyText(request.id, sizeof(request.id), app_id.c_str());
    copyText(request.reason, sizeof(request.reason), reason.isEmpty() ? "api" : reason.c_str());
    const bool ok = runtimeStopAppWithLog(request, millis(), "web_api");
    StaticJsonDocument<192> response;
    response["ok"] = ok;
    response["id"] = request.id;
    response["reason"] = request.reason;
    webSendJsonDocument(response, ok ? 200 : 400);
  });

  webOnApi("/api/apps/action", HTTP_POST, []() {
    String app_id = g_web_server.arg("id");
    String action_name = g_web_server.arg("action");
    String payload = g_web_server.arg("payload");
    String content_type = g_web_server.arg("content_type");
    StaticJsonDocument<768> request_json;
    if (webParseJsonBody(&request_json)) {
      if (app_id.isEmpty()) {
        app_id = request_json["id"] | request_json["app_id"] | "";
      }
      if (action_name.isEmpty()) {
        action_name = request_json["action"] | request_json["name"] | "";
      }
      if (payload.isEmpty()) {
        if (request_json["payload"].is<JsonVariantConst>()) {
          serializeJson(request_json["payload"], payload);
        } else {
          payload = request_json["payload"] | "";
        }
      }
      if (content_type.isEmpty()) {
        content_type = request_json["content_type"] | "";
      }
    }
    if (action_name.isEmpty()) {
      g_web_server.send(400, "application/json", "{\"ok\":false,\"error\":\"missing_action\"}");
      return;
    }
    if (content_type.isEmpty()) {
      content_type = (payload.startsWith("{") || payload.startsWith("[")) ? "application/json" : "text/plain";
    }
    AppAction action = {};
    copyText(action.id, sizeof(action.id), app_id.c_str());
    copyText(action.name, sizeof(action.name), action_name.c_str());
    copyText(action.payload, sizeof(action.payload), payload.c_str());
    copyText(action.content_type, sizeof(action.content_type), content_type.c_str());
    const bool ok = runtimeHandleActionWithLog(action, millis(), "web_api");
    StaticJsonDocument<256> response;
    response["ok"] = ok;
    response["id"] = action.id;
    response["action"] = action.name;
    response["content_type"] = action.content_type;
    if (!ok) {
      response["error"] = g_app_runtime_manager.current().last_error;
    }
    webSendJsonDocument(response, ok ? 200 : 409);
  });

  webOnApi("/api/network/wifi", HTTP_GET, []() {
    webSendWifiStatus();
  });

  webOnApi("/api/network/espnow", HTTP_GET, []() {
    webSendEspNowStatus();
  });

  webOnApi("/api/network/espnow/peer", HTTP_GET, []() {
    webSendEspNowPeerList();
  });

  webOnApi("/api/wifi/disconnect", HTTP_POST, []() {
    webScheduleStaDisconnect();
    webSendResult("WIFI_DISCONNECT", true);
  });

  webOnApi("/api/network/wifi/disconnect", HTTP_POST, []() {
    webScheduleStaDisconnect();
    webSendResult("WIFI_DISCONNECT", true);
  });

  webOnApi("/api/network/wifi/reconnect", HTTP_POST, []() {
    const bool ok = webReconnectLocalWifi();
    webSendResult("WIFI_RECONNECT", ok);
  });

  webOnApi("/api/wifi/connect", HTTP_POST, []() {
    String ssid = g_web_server.arg("ssid");
    String password = g_web_server.arg("password");
    bool persist = false;
    if (password.isEmpty()) {
      password = g_web_server.arg("pass");
    }
    StaticJsonDocument<768> request_json;
    if (g_web_server.hasArg("persist")) {
      bool parsed_persist = false;
      if (parseBoolToken(g_web_server.arg("persist").c_str(), &parsed_persist)) {
        persist = parsed_persist;
      } else {
        long persist_flag = 0L;
        if (parseBoundedLongToken(g_web_server.arg("persist").c_str(), 0L, 1L, &persist_flag)) {
          persist = (persist_flag != 0L);
        }
      }
    }
    if (webParseJsonBody(&request_json)) {
      if (ssid.isEmpty()) {
        ssid = request_json["ssid"] | "";
      }
      if (password.isEmpty()) {
        password = request_json["pass"] | request_json["password"] | "";
      }
      if (request_json["persist"].is<bool>()) {
        persist = request_json["persist"].as<bool>();
      } else if (request_json["persist"].is<int>()) {
        persist = request_json["persist"].as<int>() != 0;
      } else if (request_json["persist"].is<unsigned int>()) {
        persist = request_json["persist"].as<unsigned int>() != 0U;
      }
    }
    if (ssid.isEmpty()) {
      webSendResult("WIFI_CONNECT", false);
      return;
    }
    bool connect_started = false;
    bool persisted = false;
    bool token_generated = false;
    const bool ok = provisionWifiCredentials(ssid.c_str(),
                                             password.c_str(),
                                             persist,
                                             &connect_started,
                                             &persisted,
                                             &token_generated);
    StaticJsonDocument<320> response;
    response["ok"] = ok;
    response["action"] = "WIFI_CONNECT";
    response["persist"] = persist;
    response["connect_started"] = connect_started;
    if (persist) {
      response["persisted"] = persisted;
      if (token_generated && g_web_auth_token[0] != '\0') {
        response["token"] = g_web_auth_token;
      }
    }
    webSendJsonDocument(response, ok ? 200 : 400);
  });

  webOnApi("/api/network/wifi/connect", HTTP_POST, []() {
    String ssid = g_web_server.arg("ssid");
    String password = g_web_server.arg("password");
    bool persist = false;
    if (password.isEmpty()) {
      password = g_web_server.arg("pass");
    }
    StaticJsonDocument<768> request_json;
    if (g_web_server.hasArg("persist")) {
      bool parsed_persist = false;
      if (parseBoolToken(g_web_server.arg("persist").c_str(), &parsed_persist)) {
        persist = parsed_persist;
      } else {
        long persist_flag = 0L;
        if (parseBoundedLongToken(g_web_server.arg("persist").c_str(), 0L, 1L, &persist_flag)) {
          persist = (persist_flag != 0L);
        }
      }
    }
    if (webParseJsonBody(&request_json)) {
      if (ssid.isEmpty()) {
        ssid = request_json["ssid"] | "";
      }
      if (password.isEmpty()) {
        password = request_json["pass"] | request_json["password"] | "";
      }
      if (request_json["persist"].is<bool>()) {
        persist = request_json["persist"].as<bool>();
      } else if (request_json["persist"].is<int>()) {
        persist = request_json["persist"].as<int>() != 0;
      } else if (request_json["persist"].is<unsigned int>()) {
        persist = request_json["persist"].as<unsigned int>() != 0U;
      }
    }
    if (ssid.isEmpty()) {
      webSendResult("WIFI_CONNECT", false);
      return;
    }
    bool connect_started = false;
    bool persisted = false;
    bool token_generated = false;
    const bool ok = provisionWifiCredentials(ssid.c_str(),
                                             password.c_str(),
                                             persist,
                                             &connect_started,
                                             &persisted,
                                             &token_generated);
    StaticJsonDocument<320> response;
    response["ok"] = ok;
    response["action"] = "WIFI_CONNECT";
    response["persist"] = persist;
    response["connect_started"] = connect_started;
    if (persist) {
      response["persisted"] = persisted;
      if (token_generated && g_web_auth_token[0] != '\0') {
        response["token"] = g_web_auth_token;
      }
    }
    webSendJsonDocument(response, ok ? 200 : 400);
  });

  webOnApi("/api/espnow/send", HTTP_POST, []() {
    String payload = g_web_server.arg("payload");
    StaticJsonDocument<768> request_json;
    if (webParseJsonBody(&request_json)) {
      if (payload.isEmpty()) {
        if (request_json["payload"].is<JsonVariantConst>()) {
          serializeJson(request_json["payload"], payload);
        } else {
          payload = request_json["payload"] | "";
        }
      }
    }
    if (payload.isEmpty()) {
      webSendResult("ESPNOW_SEND", false);
      return;
    }
    const bool ok = g_network.sendEspNowTarget(kEspNowBroadcastTarget, payload.c_str());
    webSendResult("ESPNOW_SEND", ok);
  });

  webOnApi("/api/network/espnow/send", HTTP_POST, []() {
    String payload = g_web_server.arg("payload");
    StaticJsonDocument<768> request_json;
    if (webParseJsonBody(&request_json)) {
      if (payload.isEmpty()) {
        if (request_json["payload"].is<JsonVariantConst>()) {
          serializeJson(request_json["payload"], payload);
        } else {
          payload = request_json["payload"] | "";
        }
      }
    }
    if (payload.isEmpty()) {
      webSendResult("ESPNOW_SEND", false);
      return;
    }
    const bool ok = g_network.sendEspNowTarget(kEspNowBroadcastTarget, payload.c_str());
    webSendResult("ESPNOW_SEND", ok);
  });

  webOnApi("/api/network/espnow/on", HTTP_POST, []() {
    const bool ok = g_network.enableEspNow();
    webSendResult("ESPNOW_ON", ok);
  });

  webOnApi("/api/network/espnow/off", HTTP_POST, []() {
    g_network.disableEspNow();
    webSendResult("ESPNOW_OFF", true);
  });

  webOnApi("/api/network/espnow/peer", HTTP_POST, []() {
    String mac = g_web_server.arg("mac");
    StaticJsonDocument<256> request_json;
    if (webParseJsonBody(&request_json) && mac.isEmpty()) {
      mac = request_json["mac"] | "";
    }
    const bool ok = !mac.isEmpty() && g_network.addEspNowPeer(mac.c_str());
    webSendResult("ESPNOW_PEER_ADD", ok);
  });

  webOnApi("/api/network/espnow/peer", HTTP_DELETE, []() {
    String mac = g_web_server.arg("mac");
    StaticJsonDocument<256> request_json;
    if (webParseJsonBody(&request_json) && mac.isEmpty()) {
      mac = request_json["mac"] | "";
    }
    const bool ok = !mac.isEmpty() && g_network.removeEspNowPeer(mac.c_str());
    webSendResult("ESPNOW_PEER_DEL", ok);
  });

  webOnApi("/api/story/refresh-sd", HTTP_POST, []() {
    const bool ok = refreshStoryFromSd();
    webSendResult("STORY_REFRESH_SD", ok);
  });

  webOnApi("/api/scenario/unlock", HTTP_POST, []() {
    const bool ok = dispatchScenarioEventByName("UNLOCK", millis());
    webSendResult("UNLOCK", ok);
  });

  webOnApi("/api/scenario/next", HTTP_POST, []() {
    bool ok = dispatchScenarioEventByName("SERIAL:BTN_NEXT", millis());
    if (!ok) {
      ok = notifyScenarioButtonGuarded(5U, false, millis(), "api_scenario_next");
    }
    webSendResult("NEXT", ok);
  });

  webOnApi("/api/control", HTTP_POST, []() {
    String action = g_web_server.arg("action");
    StaticJsonDocument<768> request_json;
    if (webParseJsonBody(&request_json) && action.isEmpty()) {
      action = request_json["action"] | "";
    }
    String error;
    const bool ok = dispatchControlAction(action, millis(), &error);
    StaticJsonDocument<256> response;
    response["ok"] = ok;
    response["action"] = action;
    if (!ok && !error.isEmpty()) {
      response["error"] = error;
    }
    webSendJsonDocument(response, ok ? 200 : 400);
  });

  g_web_server.onNotFound([]() {
    g_web_server.send(404, "application/json", "{\"ok\":false,\"error\":\"not_found\"}");
  });

  g_web_server.begin();
  g_web_started = true;
  Serial.println("[WEB] started :80");
}

void printScenarioCoverage() {
  const uint32_t mask = g_scenario.transitionEventMask();
  const ScenarioSnapshot snapshot = g_scenario.snapshot();
  const uint8_t unlock = (mask & (1UL << static_cast<uint8_t>(StoryEventType::kUnlock))) ? 1U : 0U;
  const uint8_t audio_done = (mask & (1UL << static_cast<uint8_t>(StoryEventType::kAudioDone))) ? 1U : 0U;
  const uint8_t timer = (mask & (1UL << static_cast<uint8_t>(StoryEventType::kTimer))) ? 1U : 0U;
  const uint8_t serial = (mask & (1UL << static_cast<uint8_t>(StoryEventType::kSerial))) ? 1U : 0U;
  const uint8_t button = (mask & (1UL << static_cast<uint8_t>(StoryEventType::kButton))) ? 1U : 0U;
  const uint8_t espnow = (mask & (1UL << static_cast<uint8_t>(StoryEventType::kEspNow))) ? 1U : 0U;
  const uint8_t action = (mask & (1UL << static_cast<uint8_t>(StoryEventType::kAction))) ? 1U : 0U;
  Serial.printf("SC_COVERAGE scenario=%s unlock=%u audio_done=%u timer=%u serial=%u button=%u espnow=%u action=%u\n",
                scenarioIdFromSnapshot(snapshot),
                unlock,
                audio_done,
                timer,
                serial,
                button,
                espnow,
                action);
}

bool dispatchScenarioEventByType(StoryEventType type, const char* event_name, uint32_t now_ms) {
  switch (type) {
    case StoryEventType::kUnlock: {
      char unlock_name[64] = {0};
      if (event_name != nullptr && event_name[0] != '\0') {
        copyText(unlock_name, sizeof(unlock_name), event_name);
        trimAsciiInPlace(unlock_name);
        toUpperAsciiInPlace(unlock_name);
      }
      const char* selected_name = (unlock_name[0] != '\0') ? unlock_name : "UNLOCK";
      const bool dispatched_named = g_scenario.notifyUnlockEvent(selected_name, now_ms);
      if (std::strcmp(selected_name, "UNLOCK") == 0) {
        // Preserve legacy compatibility: unlock command is considered accepted
        // even when no transition consumes it in the current step.
        return true;
      }
      if (dispatched_named) {
        return true;
      }
      return g_scenario.notifyUnlockEvent("UNLOCK", now_ms);
    }
    case StoryEventType::kAudioDone:
      if (event_name != nullptr && event_name[0] != '\0' && std::strcmp(event_name, "AUDIO_DONE") != 0) {
        return false;
      }
      g_scenario.notifyAudioDone(now_ms);
      return true;
    case StoryEventType::kTimer:
      return g_scenario.notifyTimerEvent(event_name, now_ms);
    case StoryEventType::kSerial:
      return g_scenario.notifySerialEvent(event_name, now_ms);
    case StoryEventType::kButton:
      return g_scenario.notifyButtonEvent(event_name, now_ms);
    case StoryEventType::kEspNow: {
      const bool dispatched_espnow = g_scenario.notifyEspNowEvent(event_name, now_ms);
      const bool dispatched_serial = g_scenario.notifySerialEvent(event_name, now_ms);
      if (event_name != nullptr &&
          (std::strcmp(event_name, "ACK_WIN1") == 0 || std::strcmp(event_name, "ACK_WIN2") == 0 ||
           std::strcmp(event_name, "WAITING_VALIDATION") == 0)) {
        g_win_etape_ui_refresh_pending = true;
      }
      return dispatched_espnow || dispatched_serial;
    }
    case StoryEventType::kAction:
      return g_scenario.notifyActionEvent(event_name, now_ms);
    default:
      return false;
  }
}

bool dispatchScenarioEventByName(const char* event_name, uint32_t now_ms) {
  if (event_name == nullptr || event_name[0] == '\0') {
    return false;
  }

  char normalized[kSerialLineCapacity] = {0};
  std::strncpy(normalized, event_name, sizeof(normalized) - 1U);
  toUpperAsciiInPlace(normalized);

  const ScenarioSnapshot current = g_scenario.snapshot();
  if (!g_la_dispatch_in_progress && shouldEnforceLaMatchOnly(current)) {
    if (std::strcmp(normalized, "UNLOCK") == 0 || std::strcmp(normalized, "BTN_NEXT") == 0 ||
        std::strcmp(normalized, "SERIAL:BTN_NEXT") == 0) {
      Serial.printf("[LA_TRIGGER] blocked manual event=%s while waiting LA match\n", normalized);
      return false;
    }
  }

  if (std::strcmp(normalized, "UNLOCK") == 0) {
    g_scenario.notifyUnlock(now_ms);
    return true;
  }
  if (std::strcmp(normalized, "AUDIO_DONE") == 0) {
    g_scenario.notifyAudioDone(now_ms);
    return true;
  }

  const char* separator = std::strchr(normalized, ':');
  if (separator != nullptr) {
    const size_t head_len = static_cast<size_t>(separator - normalized);
    const char* tail = separator + 1;
    if (tail[0] == '\0') {
      return false;
    }
    if (head_len == 5U && std::strncmp(normalized, "TIMER", 5U) == 0) {
      return g_scenario.notifyTimerEvent(tail, now_ms);
    }
    if (head_len == 6U && std::strncmp(normalized, "UNLOCK", 6U) == 0) {
      const bool dispatched_named = g_scenario.notifyUnlockEvent(tail, now_ms);
      if (dispatched_named) {
        return true;
      }
      return g_scenario.notifyUnlockEvent("UNLOCK", now_ms);
    }
    if (head_len == 6U && std::strncmp(normalized, "ACTION", 6U) == 0) {
      return g_scenario.notifyActionEvent(tail, now_ms);
    }
    if (head_len == 6U && std::strncmp(normalized, "SERIAL", 6U) == 0) {
      return g_scenario.notifySerialEvent(tail, now_ms);
    }
    if (head_len == 6U && std::strncmp(normalized, "BUTTON", 6U) == 0) {
      return g_scenario.notifyButtonEvent(tail, now_ms);
    }
    if (head_len == 6U && std::strncmp(normalized, "ESPNOW", 6U) == 0) {
      const bool dispatched_espnow = g_scenario.notifyEspNowEvent(tail, now_ms);
      const bool dispatched_serial = g_scenario.notifySerialEvent(tail, now_ms);
      return dispatched_espnow || dispatched_serial;
    }
  }

  return g_scenario.notifySerialEvent(normalized, now_ms);
}

void runScenarioRevalidate(uint32_t now_ms) {
  struct EventProbe {
    StoryEventType type;
    const char* event_name;
  };
  struct HardwareProbe {
    uint8_t key;
    bool long_press;
    const char* label;
  };

  const EventProbe event_probes[] = {
      {StoryEventType::kUnlock, "UNLOCK"},
      {StoryEventType::kAudioDone, "AUDIO_DONE"},
      {StoryEventType::kTimer, "ETAPE2_DUE"},
      {StoryEventType::kSerial, "FORCE_DONE"},
      {StoryEventType::kButton, "ANY"},
      {StoryEventType::kEspNow, "ACK_WIN1"},
      {StoryEventType::kAction, "ACTION_FORCE_ETAPE2"},
  };
  const HardwareProbe hardware_probes[] = {
      {1U, false, "BTN1_SHORT"},
      {5U, false, "BTN5_SHORT"},
  };

  g_scenario.reset();
  Serial.println("SC_REVALIDATE_BEGIN");
  printScenarioCoverage();
  for (const EventProbe& probe : event_probes) {
    const ScenarioSnapshot before = g_scenario.snapshot();
    const bool dispatched = dispatchScenarioEventByType(probe.type, probe.event_name, now_ms);
    const ScenarioSnapshot after = g_scenario.snapshot();
    const bool changed = std::strcmp(stepIdFromSnapshot(before), stepIdFromSnapshot(after)) != 0;
    Serial.printf("SC_REVALIDATE event=%s name=%s dispatched=%u changed=%u step_before=%s step_after=%s screen=%s pack=%s\n",
                  eventTypeName(probe.type),
                  probe.event_name,
                  dispatched ? 1U : 0U,
                  changed ? 1U : 0U,
                  stepIdFromSnapshot(before),
                  stepIdFromSnapshot(after),
                  after.screen_scene_id != nullptr ? after.screen_scene_id : "n/a",
                  after.audio_pack_id != nullptr ? after.audio_pack_id : "n/a");
  }
  for (const HardwareProbe& probe : hardware_probes) {
    g_scenario.reset();
    const ScenarioSnapshot before = g_scenario.snapshot();
    g_scenario.notifyButton(probe.key, probe.long_press, now_ms);
    const ScenarioSnapshot after = g_scenario.snapshot();
    const bool changed = std::strcmp(stepIdFromSnapshot(before), stepIdFromSnapshot(after)) != 0;
    Serial.printf("SC_REVALIDATE_HW key=%u long=%u label=%s changed=%u step_before=%s step_after=%s screen=%s pack=%s\n",
                  probe.key,
                  probe.long_press ? 1U : 0U,
                  probe.label,
                  changed ? 1U : 0U,
                  stepIdFromSnapshot(before),
                  stepIdFromSnapshot(after),
                  after.screen_scene_id != nullptr ? after.screen_scene_id : "n/a",
                  after.audio_pack_id != nullptr ? after.audio_pack_id : "n/a");
  }

  auto prepareStepXProbe = [&]() {
    g_scenario.reset();
    g_scenario.notifyUnlock(now_ms);
    g_scenario.notifyAudioDone(now_ms);
    return g_scenario.snapshot();
  };

  {
    const ScenarioSnapshot before = prepareStepXProbe();
    const bool dispatched = g_scenario.notifyTimerEvent("ETAPE2_DUE", now_ms);
    const ScenarioSnapshot after = g_scenario.snapshot();
    const bool changed = std::strcmp(stepIdFromSnapshot(before), stepIdFromSnapshot(after)) != 0;
    Serial.printf("SC_REVALIDATE_STEPX event=timer name=ETAPE2_DUE dispatched=%u changed=%u anchor_step=%s step_after=%s\n",
                  dispatched ? 1U : 0U,
                  changed ? 1U : 0U,
                  stepIdFromSnapshot(before),
                  stepIdFromSnapshot(after));
  }

  {
    const ScenarioSnapshot before = prepareStepXProbe();
    const bool dispatched = g_scenario.notifyActionEvent("ACTION_FORCE_ETAPE2", now_ms);
    const ScenarioSnapshot after = g_scenario.snapshot();
    const bool changed = std::strcmp(stepIdFromSnapshot(before), stepIdFromSnapshot(after)) != 0;
    Serial.printf(
        "SC_REVALIDATE_STEPX event=action name=ACTION_FORCE_ETAPE2 dispatched=%u changed=%u anchor_step=%s step_after=%s\n",
        dispatched ? 1U : 0U,
        changed ? 1U : 0U,
        stepIdFromSnapshot(before),
        stepIdFromSnapshot(after));
  }

  Serial.println("SC_REVALIDATE_END");
}

void runScenarioRevalidateAll(uint32_t now_ms) {
  const char* previous_scenario = scenarioIdFromSnapshot(g_scenario.snapshot());
  Serial.println("SC_REVALIDATE_ALL_BEGIN");
  for (uint8_t index = 0U; index < storyScenarioV2Count(); ++index) {
    const char* scenario_id = storyScenarioV2IdAt(index);
    if (scenario_id == nullptr || scenario_id[0] == '\0') {
      continue;
    }
    if (!g_scenario.beginById(scenario_id)) {
      Serial.printf("SC_REVALIDATE_ALL_SKIP id=%s reason=load_failed\n", scenario_id);
      continue;
    }
    Serial.printf("SC_REVALIDATE_ALL_SCENARIO id=%s\n", scenario_id);
    runScenarioRevalidate(now_ms);
  }
  if (previous_scenario != nullptr && previous_scenario[0] != '\0') {
    g_scenario.beginById(previous_scenario);
  } else {
    g_scenario.begin(kDefaultScenarioFile);
  }
  Serial.println("SC_REVALIDATE_ALL_END");
}

bool dispatchControlAction(const String& action_raw, uint32_t now_ms, String* out_error) {
  return g_runtime_serial_service.dispatchControlAction(action_raw, now_ms, out_error);
}

void setupWebUi() {
  g_runtime_web_service.setupWebUi();
}

void refreshSceneIfNeeded(bool force_render) {
  g_runtime_scene_service.refreshSceneIfNeeded(force_render);
}

void handleSerialCommand(const char* command_line, uint32_t now_ms) {
  g_runtime_serial_service.handleSerialCommand(command_line, now_ms);
}

void runtimeTickBridge(uint32_t now_ms, RuntimeServices* services) {
  (void)services;
  ::runRuntimeIteration(now_ms);
}

void serialDispatchBridge(const char* command_line, uint32_t now_ms, RuntimeServices* services) {
  (void)services;
  handleSerialCommand(command_line, now_ms);
}

void refreshSceneIfNeededImpl(bool force_render) {
  const bool changed = g_scenario.consumeSceneChanged();
  const ScenarioSnapshot snapshot = g_scenario.snapshot();
  const SceneTransitionPlan transition =
      g_scene_fx_orchestrator.planTransition(snapshot.screen_scene_id, changed, force_render);
  if (!transition.should_apply) {
    return;
  }

  const uint32_t now_ms = millis();
  const bool warning_scene_transition =
      transition.scene_changed && snapshot.screen_scene_id != nullptr &&
      (std::strcmp(snapshot.screen_scene_id, "SCENE_WARNING") == 0);
  if (warning_scene_transition) {
    logLoopStackWatermark("transition_enter", snapshot.screen_scene_id);
  }

  // Hard transition cleanup ordering: always release scene-owned resources on scene changes.
  if (transition.scene_changed) {
#if defined(USE_AUDIO) && (USE_AUDIO != 0)
    setAmpSceneActive(false);
#endif
    setCameraSceneActive(false);
    g_la_dispatch_in_progress = false;
    resetLaTriggerState(false);
    g_win_etape_ui_refresh_pending = false;
    if (g_hardware_started) {
      g_hardware.clearManualLed();
      g_hardware.setSceneSingleRandomBlink(false, 0U, 0U, 0U, 0U, false);
    }
  }

  if (transition.scene_changed && !kForceTestLabSceneLock) {
    syncHotlineSceneState(snapshot);
    if (warning_scene_transition) {
      logLoopStackWatermark("after_hotline_sync", snapshot.screen_scene_id);
    }
  }

  const char* led_scene_id = snapshot.screen_scene_id;
  if (kForceTestLabSceneLock) {
    led_scene_id = kTestLabSceneId;
  }
  if (g_hardware_started && g_hardware_cfg.led_auto_from_scene && led_scene_id != nullptr) {
    g_hardware.setSceneHint(led_scene_id);
  }
  if (!kForceTestLabSceneLock) {
    executeStoryActionsForStep(snapshot, now_ms);
    if (warning_scene_transition) {
      logLoopStackWatermark("after_actions", snapshot.screen_scene_id);
    }
  }

  const char* step_id = (snapshot.step != nullptr && snapshot.step->id != nullptr) ? snapshot.step->id : "n/a";
  String screen_payload = g_storage.loadScenePayloadById(snapshot.screen_scene_id);
  if (warning_scene_transition) {
    logLoopStackWatermark("after_load_payload", snapshot.screen_scene_id);
  }
  const char* render_scene_id = snapshot.screen_scene_id;
  const char* render_step_id = step_id;
  if (kForceTestLabSceneLock) {
    render_scene_id = kTestLabSceneId;
    render_step_id = kTestLabLockStepId;
    const String test_lab_payload = g_storage.loadScenePayloadById(kTestLabSceneId);
    if (!test_lab_payload.isEmpty()) {
      screen_payload = test_lab_payload;
    }
  }
  if ((render_scene_id != nullptr) && render_scene_id[0] != '\0' && screen_payload.isEmpty()) {
    ZACUS_RL_LOG_MS(6000U,
                    "[UI] missing scene payload scenario=%s step=%s screen=%s\n",
                    scenarioIdFromSnapshot(snapshot),
                    render_step_id,
                    render_scene_id);
  }
  const char* visual_scene_id = render_scene_id;
  configureSceneVisualHardwareHints(visual_scene_id, screen_payload);
  if (warning_scene_transition) {
    logLoopStackWatermark("after_visual_hints", snapshot.screen_scene_id);
  }
  Serial.printf("[UI] render step=%s screen=%s pack=%s playing=%u\n",
                render_step_id,
                render_scene_id != nullptr ? render_scene_id : "n/a",
                snapshot.audio_pack_id != nullptr ? snapshot.audio_pack_id : "n/a",
                g_audio.isPlaying() ? 1U : 0U);
  applySceneResourcePolicy(snapshot);
  UiSceneFrame frame = {};
  frame.scenario = snapshot.scenario;
  frame.screen_scene_id = render_scene_id;
  frame.step_id = render_step_id;
  frame.audio_pack_id = snapshot.audio_pack_id;
  frame.audio_playing = g_audio.isPlaying();
  frame.screen_payload_json = screen_payload.isEmpty() ? nullptr : screen_payload.c_str();
  g_ui.submitSceneFrame(frame);
  if (warning_scene_transition) {
    logLoopStackWatermark("after_submit_scene", snapshot.screen_scene_id);
  }

  // Apply new owner resources after scene config is committed in UI.
  if (transition.scene_changed) {
#if defined(USE_AUDIO) && (USE_AUDIO != 0)
    if (transition.to_owner == SceneRuntimeOwner::kAmp) {
      setAmpSceneActive(true);
    }
#endif
    if (transition.to_owner == SceneRuntimeOwner::kCamera) {
      setCameraSceneActive(true);
    }
  }
  g_scene_fx_orchestrator.applyTransition(transition);
}

void startPendingAudioIfAny() {
#if defined(USE_AUDIO) && (USE_AUDIO != 0)
  if (g_amp_scene_active) {
    String ignored_pack;
    if (g_scenario.consumeAudioRequest(&ignored_pack)) {
      Serial.printf("[MAIN] skip story audio while AMP owns scene pack=%s\n", ignored_pack.c_str());
      g_scenario.notifyAudioDone(millis());
    }
    return;
  }
#endif
  const ScenarioSnapshot snapshot = g_scenario.snapshot();
  const char* scene_id = (snapshot.screen_scene_id != nullptr) ? snapshot.screen_scene_id : "";

  if (isCreditsSceneId(scene_id)) {
    const char* active_track = g_audio.currentTrack();
    const bool credits_track_active = isCreditsWinTrackPath(active_track);
    const bool startup_track_pending =
        (g_audio.isPlaying() && active_track != nullptr && std::strcmp(active_track, "-") == 0);
    if (!credits_track_active && !startup_track_pending) {
      const uint32_t now_ms = millis();
      if (now_ms >= g_next_credits_audio_retry_ms) {
        if (g_audio.isPlaying()) {
          g_audio.stop();
        }
        const char* selected_path = "/music/SCENE_WIN.mp3";
        const bool ok = playCreditsWinTrack(&selected_path);
        if (ok) {
          g_next_credits_audio_retry_ms = 0U;
          Serial.printf("[MAIN] credits boot audio scene=%s track=%s source=forced prev=%s\n",
                        scene_id,
                        selected_path,
                        (active_track != nullptr && active_track[0] != '\0') ? active_track : "none");
        } else {
          g_next_credits_audio_retry_ms = now_ms + 2000U;
          Serial.printf("[MAIN] credits boot audio retry scene=%s track=%s at=%lu prev=%s\n",
                        scene_id,
                        selected_path,
                        static_cast<unsigned long>(g_next_credits_audio_retry_ms),
                        (active_track != nullptr && active_track[0] != '\0') ? active_track : "none");
        }
      }
    } else {
      g_next_credits_audio_retry_ms = 0U;
    }
  }

  String audio_pack;
  if (!g_scenario.consumeAudioRequest(&audio_pack)) {
    return;
  }
  const bool is_win_etape_audio = (audio_pack == kPackWin &&
                                   snapshot.step != nullptr && snapshot.step->id != nullptr &&
                                   std::strcmp(snapshot.step->id, kStepWinEtape) == 0);

  const String configured_path = g_storage.resolveAudioPathByPackId(audio_pack.c_str());
  const char* mapped_path = audioPackToFile(audio_pack.c_str());
  if (configured_path.isEmpty() && mapped_path == nullptr) {
    if (g_audio.playDiagnosticTone()) {
      Serial.printf("[MAIN] audio pack=%s has no asset mapping, fallback=builtin_tone\n", audio_pack.c_str());
      return;
    }
    Serial.printf("[MAIN] audio pack=%s has no asset mapping and no fallback tone\n", audio_pack.c_str());
    g_scenario.notifyAudioDone(millis());
    return;
  }

  if (!configured_path.isEmpty() && g_audio.play(configured_path.c_str())) {
    Serial.printf("[MAIN] audio pack=%s path=%s source=story_audio_json\n",
                  audio_pack.c_str(),
                  configured_path.c_str());
    if (is_win_etape_audio) {
      g_win_etape_ui_refresh_pending = true;
    }
    return;
  }
  if (mapped_path != nullptr && g_audio.play(mapped_path)) {
    Serial.printf("[MAIN] audio pack=%s path=%s source=pack_map\n", audio_pack.c_str(), mapped_path);
    if (is_win_etape_audio) {
      g_win_etape_ui_refresh_pending = true;
    }
    return;
  }
  if (g_audio.play(kDiagAudioFile)) {
    Serial.printf("[MAIN] audio fallback for pack=%s fallback=%s\n", audio_pack.c_str(), kDiagAudioFile);
    if (is_win_etape_audio) {
      g_win_etape_ui_refresh_pending = true;
    }
    return;
  }
  if (g_audio.playDiagnosticTone()) {
    Serial.printf("[MAIN] audio fallback for pack=%s fallback=builtin_tone\n", audio_pack.c_str());
    if (is_win_etape_audio) {
      g_win_etape_ui_refresh_pending = true;
    }
    return;
  }

  // If audio cannot start (missing/invalid file), unblock scenario transitions.
  Serial.printf("[MAIN] audio fallback failed for pack=%s\n", audio_pack.c_str());
  g_scenario.notifyAudioDone(millis());
}

void handleSerialCommandImpl(const char* command_line, uint32_t now_ms) {
  if (command_line == nullptr || command_line[0] == '\0') {
    return;
  }

  char command[kSerialLineCapacity] = {0};
  std::strncpy(command, command_line, kSerialLineCapacity - 1U);
  char* argument = nullptr;
  for (size_t index = 0; index < kSerialLineCapacity && command[index] != '\0'; ++index) {
    if (command[index] == ' ') {
      command[index] = '\0';
      argument = &command[index + 1U];
      break;
    }
  }
  while (argument != nullptr && *argument == ' ') {
    ++argument;
  }
  if (argument != nullptr && *argument == '\0') {
    argument = nullptr;
  }

  if (std::strcmp(command, "PING") == 0) {
    Serial.println("PONG");
    return;
  }
  if (std::strcmp(command, "HELP") == 0) {
    Serial.println(
        "CMDS PING STATUS BTN_READ NEXT UNLOCK RESET "
        "SC_LIST SC_LOAD <id> SCENE_GOTO <scene_id> SC_COVERAGE SC_REVALIDATE SC_REVALIDATE_ALL SC_EVENT <type> [name] "
        "SC_EVENT_RAW <name> "
        "STORY_DEBUG_BYPASS <ON|OFF> "
        "STORY_REFRESH_SD STORY_SD_STATUS "
        "UI_GFX_STATUS UI_MEM_STATUS UI_SCENE_STATUS PERF_STATUS PERF_RESET RESOURCE_STATUS RESOURCE_PROFILE <gfx_focus|gfx_plus_mic|gfx_plus_cam_snapshot> RESOURCE_PROFILE_AUTO <on|off> "
        "SIMD_STATUS SIMD_SELFTEST SIMD_BENCH [loops] [pixels] "
        "HW_STATUS HW_STATUS_JSON HW_LED_SET <r> <g> <b> [brightness] [pulse] HW_LED_AUTO <ON|OFF> HW_MIC_STATUS HW_BAT_STATUS "
        "LCD_BACKLIGHT [0..255] "
        "MIC_TUNER_STATUS [ON|OFF|<period_ms>] "
        "CAM_STATUS CAM_ON CAM_OFF CAM_SNAPSHOT [filename] "
        "CAM_UI_SHOW CAM_UI_HIDE CAM_UI_TOGGLE CAM_REC_SNAP CAM_REC_SAVE [auto|bmp|jpg|raw] CAM_REC_GALLERY CAM_REC_NEXT CAM_REC_DELETE CAM_REC_STATUS "
        "QR_SIM <payload> "
        "MEDIA_LIST <picture|music|recorder> MEDIA_PLAY <path> MEDIA_STOP REC_START [seconds] [filename] REC_STOP REC_STATUS "
        "APP_OPEN <id> [mode] APP_CLOSE [reason] APP_ACTION <name> [payload] APP_STATUS "
        "BOOT_MODE_STATUS BOOT_MODE_SET <STORY|MEDIA_MANAGER> BOOT_MODE_CLEAR "
        "NET_STATUS WIFI_STATUS WIFI_TEST WIFI_STA <ssid> <pass> WIFI_CONNECT <ssid> <pass> WIFI_PROVISION <ssid> <pass> WIFI_FORGET WIFI_DISCONNECT "
        "AUTH_STATUS AUTH_TOKEN_ROTATE [token] "
        "WIFI_AP_ON [ssid] [pass] WIFI_AP_OFF "
        "ESPNOW_ON ESPNOW_OFF ESPNOW_STATUS ESPNOW_STATUS_JSON ESPNOW_DISCOVERY ESPNOW_DISCOVERY_RUNTIME [on|off] "
        "ESPNOW_DEVICE_NAME_GET ESPNOW_DEVICE_NAME_SET <name> "
        "ESPNOW_PEER_ADD <mac> ESPNOW_PEER_DEL <mac> ESPNOW_PEER_LIST "
        "ESPNOW_SEND <text|json> "
        "AMP_SHOW AMP_HIDE AMP_TOGGLE AMP_SCAN AMP_PLAY <idx|path> AMP_NEXT AMP_PREV AMP_STOP AMP_STATUS "
        "AUDIO_TEST AUDIO_TEST_FS AUDIO_PROFILE <idx> AUDIO_FX <idx> AUDIO_STATUS VOL <0..21> AUDIO_STOP STOP");
    return;
  }
  if (std::strcmp(command, "STATUS") == 0) {
    printRuntimeStatus();
    return;
  }
  if (std::strcmp(command, "UI_GFX_STATUS") == 0) {
    g_ui.dumpStatus(UiStatusTopic::kGraphics);
    return;
  }
  if (std::strcmp(command, "UI_MEM_STATUS") == 0) {
    g_ui.dumpStatus(UiStatusTopic::kMemory);
    return;
  }
  if (std::strcmp(command, "UI_SCENE_STATUS") == 0) {
    printUiSceneStatus();
    return;
  }
#if defined(USE_AUDIO) && (USE_AUDIO != 0)
  if (std::strcmp(command, "AMP_STATUS") == 0) {
    printAmpStatus();
    return;
  }
  if (std::strcmp(command, "AMP_SHOW") == 0) {
    const bool ok = ensureAmpInitialized();
    if (ok) {
      g_amp_player.show();
    }
    Serial.printf("ACK AMP_SHOW ok=%u\n", ok ? 1U : 0U);
    return;
  }
  if (std::strcmp(command, "AMP_HIDE") == 0) {
    const bool ok = ensureAmpInitialized();
    if (ok) {
      g_amp_player.hide();
    }
    Serial.printf("ACK AMP_HIDE ok=%u\n", ok ? 1U : 0U);
    return;
  }
  if (std::strcmp(command, "AMP_TOGGLE") == 0) {
    const bool ok = ensureAmpInitialized();
    if (ok) {
      g_amp_player.toggle();
    }
    Serial.printf("ACK AMP_TOGGLE ok=%u\n", ok ? 1U : 0U);
    return;
  }
  if (std::strcmp(command, "AMP_SCAN") == 0) {
    const size_t count = scanAmpPlaylistWithFallback();
    Serial.printf("ACK AMP_SCAN tracks=%u base=%s\n",
                  static_cast<unsigned int>(count),
                  g_amp_base_dir);
    return;
  }
  if (std::strcmp(command, "AMP_PLAY") == 0) {
    if (!ensureAmpInitialized()) {
      Serial.println("ERR AMP_NOT_READY");
      return;
    }
    if (argument == nullptr || argument[0] == '\0') {
      g_amp_player.service().playIndex(g_amp_player.service().currentIndex());
      Serial.println("ACK AMP_PLAY current");
      return;
    }
    char arg_text[kSerialLineCapacity] = {0};
    copyText(arg_text, sizeof(arg_text), argument);
    trimAsciiInPlace(arg_text);
    if (arg_text[0] == '\0') {
      g_amp_player.service().playIndex(g_amp_player.service().currentIndex());
      Serial.println("ACK AMP_PLAY current");
      return;
    }
    bool numeric = true;
    for (size_t i = 0U; arg_text[i] != '\0'; ++i) {
      if (!std::isdigit(static_cast<unsigned char>(arg_text[i]))) {
        numeric = false;
        break;
      }
    }
    if (numeric) {
      const unsigned long index = std::strtoul(arg_text, nullptr, 10);
      g_amp_player.service().playIndex(static_cast<size_t>(index));
      Serial.printf("ACK AMP_PLAY idx=%lu\n", index);
    } else {
      g_amp_player.service().playPath(arg_text);
      Serial.printf("ACK AMP_PLAY path=%s\n", arg_text);
    }
    return;
  }
  if (std::strcmp(command, "AMP_NEXT") == 0) {
    if (!ensureAmpInitialized()) {
      Serial.println("ERR AMP_NOT_READY");
      return;
    }
    g_amp_player.service().next();
    Serial.println("ACK AMP_NEXT");
    return;
  }
  if (std::strcmp(command, "AMP_PREV") == 0) {
    if (!ensureAmpInitialized()) {
      Serial.println("ERR AMP_NOT_READY");
      return;
    }
    g_amp_player.service().prev();
    Serial.println("ACK AMP_PREV");
    return;
  }
  if (std::strcmp(command, "AMP_STOP") == 0) {
    if (!ensureAmpInitialized()) {
      Serial.println("ERR AMP_NOT_READY");
      return;
    }
    g_amp_player.service().stop();
    Serial.println("ACK AMP_STOP");
    return;
  }
#endif
  if (std::strcmp(command, "PERF_STATUS") == 0) {
    perfMonitor().dumpStatus();
    return;
  }
  if (std::strcmp(command, "PERF_RESET") == 0) {
    perfMonitor().reset();
    Serial.println("ACK PERF_RESET");
    return;
  }
  if (std::strcmp(command, "RESOURCE_STATUS") == 0) {
    printResourceStatus();
    return;
  }
  if (std::strcmp(command, "RESOURCE_PROFILE") == 0) {
    if (argument == nullptr || argument[0] == '\0') {
      printResourceStatus();
      return;
    }
    char profile_arg[48] = {0};
    copyText(profile_arg, sizeof(profile_arg), argument);
    trimAsciiInPlace(profile_arg);
    if (!g_resource_coordinator.parseAndSetProfile(profile_arg)) {
      Serial.println("ERR RESOURCE_PROFILE_ARG");
      return;
    }
    g_resource_profile_auto = false;
    Serial.printf("ACK RESOURCE_PROFILE profile=%s\n", g_resource_coordinator.profileName());
    printResourceStatus();
    return;
  }
  if (std::strcmp(command, "RESOURCE_PROFILE_AUTO") == 0) {
    if (argument == nullptr || argument[0] == '\0') {
      Serial.printf("ERR RESOURCE_PROFILE_AUTO_ARG arg=%s\n", argument == nullptr ? "missing" : "empty");
      return;
    }
    char profile_auto_arg[24] = {0};
    copyText(profile_auto_arg, sizeof(profile_auto_arg), argument);
    trimAsciiInPlace(profile_auto_arg);
    bool parse_ok = false;
    applyResourceProfileAutoCommand(profile_auto_arg, &parse_ok);
    if (!parse_ok) {
      Serial.println("ERR RESOURCE_PROFILE_AUTO_ARG");
      return;
    }
    Serial.printf("ACK RESOURCE_PROFILE_AUTO profile=%s auto=%u\n",
                  g_resource_coordinator.profileName(),
                  g_resource_profile_auto ? 1U : 0U);
    printResourceStatus();
    return;
  }
  if (std::strcmp(command, "SIMD_STATUS") == 0) {
    printSimdStatus();
    return;
  }
  if (std::strcmp(command, "SIMD_SELFTEST") == 0) {
    const bool ok = runtime::simd::runSelfTestCommand();
    Serial.printf("ACK SIMD_SELFTEST ok=%u\n", ok ? 1U : 0U);
    printSimdStatus();
    return;
  }
  if (std::strcmp(command, "SIMD_BENCH") == 0) {
    uint32_t loops = 200U;
    uint32_t pixels = 7680U;
    if (argument != nullptr && argument[0] != '\0') {
      char args[40] = {0};
      copyText(args, sizeof(args), argument);
      trimAsciiInPlace(args);
      char* second = std::strchr(args, ' ');
      if (second != nullptr) {
        *second = '\0';
        ++second;
        while (*second == ' ') {
          ++second;
        }
      }
      if (args[0] != '\0') {
        loops = static_cast<uint32_t>(std::strtoul(args, nullptr, 10));
      }
      if (second != nullptr && second[0] != '\0') {
        pixels = static_cast<uint32_t>(std::strtoul(second, nullptr, 10));
      }
    }
    const runtime::simd::SimdBenchResult result = runtime::simd::runBenchCommand(loops, pixels);
    Serial.printf("SIMD_BENCH loops=%lu pixels=%lu l8_us=%lu idx_us=%lu rgb888_us=%lu gain_us=%lu\n",
                  static_cast<unsigned long>(result.loops),
                  static_cast<unsigned long>(result.pixels),
                  static_cast<unsigned long>(result.l8_to_rgb565_us),
                  static_cast<unsigned long>(result.idx8_to_rgb565_us),
                  static_cast<unsigned long>(result.rgb888_to_rgb565_us),
                  static_cast<unsigned long>(result.s16_gain_q15_us));
    printSimdStatus();
    return;
  }
  if (std::strcmp(command, "BTN_READ") == 0) {
    printButtonRead();
    return;
  }
  if (std::strcmp(command, "NEXT") == 0) {
    const bool ok = notifyScenarioButtonGuarded(5U, false, now_ms, "serial_next");
    Serial.printf("ACK NEXT ok=%u\n", ok ? 1U : 0U);
    return;
  }
  if (std::strcmp(command, "UNLOCK") == 0) {
    const bool ok = dispatchScenarioEventByName("UNLOCK", now_ms);
    Serial.printf("ACK UNLOCK ok=%u\n", ok ? 1U : 0U);
    return;
  }
  if (std::strcmp(command, "RESET") == 0) {
    g_audio.stop();
#if defined(USE_AUDIO) && (USE_AUDIO != 0)
    if (g_amp_ready) {
      g_amp_player.service().stop();
    }
#endif
    (void)g_media.stop(&g_audio);
    g_scenario.reset();
    if (g_boot_media_manager_mode) {
      (void)g_scenario.gotoScene(kMediaManagerSceneId, now_ms, "boot_mode_media_manager_reset");
    }
    g_last_action_step_key[0] = '\0';
    refreshSceneIfNeeded(true);
    startPendingAudioIfAny();
    Serial.println("ACK RESET");
    return;
  }
  if (std::strcmp(command, "STORY_DEBUG_BYPASS") == 0) {
    if (argument == nullptr || argument[0] == '\0') {
      Serial.printf("ACK STORY_DEBUG_BYPASS enabled=%u\n", g_scenario.debugTransitionBypassEnabled() ? 1U : 0U);
      return;
    }
    bool enabled = false;
    if (!parseBoolToken(argument, &enabled)) {
      Serial.println("ERR STORY_DEBUG_BYPASS_ARG");
      return;
    }
    g_scenario.setDebugTransitionBypassEnabled(enabled, "serial");
    Serial.printf("ACK STORY_DEBUG_BYPASS enabled=%u\n", enabled ? 1U : 0U);
    return;
  }
  if (std::strcmp(command, "SC_LIST") == 0) {
    printScenarioList();
    return;
  }
  if (std::strcmp(command, "SC_LOAD") == 0) {
    if (argument == nullptr) {
      Serial.println("ERR SC_LOAD_ARG");
      return;
    }
    char scenario_id[kSerialLineCapacity] = {0};
    std::strncpy(scenario_id, argument, sizeof(scenario_id) - 1U);
    toUpperAsciiInPlace(scenario_id);
    String load_source;
    String load_path;
    const bool ok = loadScenarioByIdPreferStoryFile(scenario_id, &load_source, &load_path);
    Serial.printf("ACK SC_LOAD id=%s ok=%u\n", scenario_id, ok ? 1U : 0U);
    if (ok) {
      if (!load_path.isEmpty()) {
        Serial.printf("[SCENARIO] load source=%s path=%s\n", load_source.c_str(), load_path.c_str());
      } else {
        Serial.printf("[SCENARIO] load source=%s id=%s\n", load_source.c_str(), scenario_id);
      }
    }
    if (ok) {
      g_last_action_step_key[0] = '\0';
      refreshSceneIfNeeded(true);
      startPendingAudioIfAny();
    }
    return;
  }
  if (std::strcmp(command, "STORY_REFRESH_SD") == 0) {
    const bool ok = refreshStoryFromSd();
    Serial.printf("ACK STORY_REFRESH_SD ok=%u\n", ok ? 1U : 0U);
    return;
  }
  if (std::strcmp(command, "STORY_SD_STATUS") == 0) {
    Serial.printf("STORY_SD_STATUS ready=%u\n", g_storage.hasSdCard() ? 1U : 0U);
    return;
  }
  if (std::strcmp(command, "HW_STATUS") == 0 || std::strcmp(command, "HW_MIC_STATUS") == 0 ||
      std::strcmp(command, "HW_BAT_STATUS") == 0) {
    printHardwareStatus();
    return;
  }
  if (std::strcmp(command, "HW_STATUS_JSON") == 0) {
    printHardwareStatusJson();
    return;
  }
  if (std::strcmp(command, "MIC_TUNER_STATUS") == 0) {
    if (argument == nullptr) {
      printMicTunerStatus();
      return;
    }

    char arg_copy[40] = {0};
    copyText(arg_copy, sizeof(arg_copy), argument);
    trimAsciiInPlace(arg_copy);
    if (arg_copy[0] == '\0') {
      printMicTunerStatus();
      return;
    }

    char* extra = std::strchr(arg_copy, ' ');
    if (extra != nullptr) {
      *extra = '\0';
      ++extra;
      while (*extra == ' ') {
        ++extra;
      }
    }

    bool stream_value = false;
    if (parseBoolToken(arg_copy, &stream_value)) {
      g_mic_tuner_stream_enabled = stream_value;
      if (extra != nullptr && extra[0] != '\0') {
        const long period_ms = std::strtol(extra, nullptr, 10);
        if (period_ms >= 50L && period_ms <= 5000L) {
          g_mic_tuner_stream_period_ms = static_cast<uint16_t>(period_ms);
        }
      }
      g_next_mic_tuner_stream_ms = now_ms + 20U;
      Serial.printf("ACK MIC_TUNER_STATUS stream=%u period_ms=%u\n",
                    g_mic_tuner_stream_enabled ? 1U : 0U,
                    static_cast<unsigned int>(g_mic_tuner_stream_period_ms));
      if (!g_mic_tuner_stream_enabled) {
        printMicTunerStatus();
      }
      return;
    }

    const long period_ms = std::strtol(arg_copy, nullptr, 10);
    if (period_ms >= 50L && period_ms <= 5000L) {
      g_mic_tuner_stream_enabled = true;
      g_mic_tuner_stream_period_ms = static_cast<uint16_t>(period_ms);
      g_next_mic_tuner_stream_ms = now_ms + 20U;
      Serial.printf("ACK MIC_TUNER_STATUS stream=1 period_ms=%u\n", static_cast<unsigned int>(g_mic_tuner_stream_period_ms));
      return;
    }

    Serial.println("ERR MIC_TUNER_STATUS_ARG");
    return;
  }
  if (std::strcmp(command, "CAM_STATUS") == 0) {
    printCameraStatus();
    return;
  }
  if (std::strcmp(command, "CAM_REC_STATUS") == 0) {
    printCameraRecorderStatus();
    return;
  }
  if (std::strcmp(command, "REC_STATUS") == 0) {
    printMediaStatus();
    return;
  }
  if (std::strcmp(command, "HW_LED_SET") == 0 || std::strcmp(command, "HW_LED_AUTO") == 0 ||
      std::strcmp(command, "LCD_BACKLIGHT") == 0 ||
      std::strcmp(command, "CAM_ON") == 0 || std::strcmp(command, "CAM_OFF") == 0 ||
      std::strcmp(command, "CAM_SNAPSHOT") == 0 || std::strcmp(command, "CAM_UI_SHOW") == 0 ||
      std::strcmp(command, "CAM_UI_HIDE") == 0 || std::strcmp(command, "CAM_UI_TOGGLE") == 0 ||
      std::strcmp(command, "CAM_REC_SNAP") == 0 || std::strcmp(command, "CAM_REC_SAVE") == 0 ||
      std::strcmp(command, "CAM_REC_GALLERY") == 0 || std::strcmp(command, "CAM_REC_NEXT") == 0 ||
      std::strcmp(command, "CAM_REC_DELETE") == 0 || std::strcmp(command, "MEDIA_LIST") == 0 ||
      std::strcmp(command, "MEDIA_PLAY") == 0 || std::strcmp(command, "MEDIA_STOP") == 0 ||
      std::strcmp(command, "REC_START") == 0 || std::strcmp(command, "REC_STOP") == 0 ||
      std::strcmp(command, "APP_OPEN") == 0 || std::strcmp(command, "APP_CLOSE") == 0 ||
      std::strcmp(command, "APP_ACTION") == 0 || std::strcmp(command, "APP_STATUS") == 0 ||
      std::strcmp(command, "SCENE_GOTO") == 0 || std::strcmp(command, "QR_SIM") == 0 ||
      std::strcmp(command, "BOOT_MODE_STATUS") == 0 || std::strcmp(command, "BOOT_MODE_SET") == 0 ||
      std::strcmp(command, "BOOT_MODE_CLEAR") == 0) {
    String action = command;
    if (argument != nullptr && argument[0] != '\0') {
      action += " ";
      action += argument;
    }
    String error;
    const bool ok = dispatchControlAction(action, now_ms, &error);
    Serial.printf("ACK %s ok=%u%s%s\n",
                  command,
                  ok ? 1U : 0U,
                  error.isEmpty() ? "" : " err=",
                  error.isEmpty() ? "" : error.c_str());
    return;
  }
  if (std::strcmp(command, "SC_COVERAGE") == 0) {
    printScenarioCoverage();
    return;
  }
  if (std::strcmp(command, "SC_REVALIDATE") == 0) {
    runScenarioRevalidate(now_ms);
    return;
  }
  if (std::strcmp(command, "SC_REVALIDATE_ALL") == 0) {
    runScenarioRevalidateAll(now_ms);
    return;
  }
  if (std::strcmp(command, "SC_EVENT") == 0) {
    if (argument == nullptr) {
      Serial.println("ERR SC_EVENT_USAGE");
      return;
    }
    char event_args[kSerialLineCapacity] = {0};
    std::strncpy(event_args, argument, kSerialLineCapacity - 1U);
    char* event_type_text = event_args;
    char* event_name_raw = nullptr;
    for (size_t index = 0; index < kSerialLineCapacity && event_args[index] != '\0'; ++index) {
      if (event_args[index] == ' ') {
        event_args[index] = '\0';
        event_name_raw = &event_args[index + 1U];
        break;
      }
    }
    while (event_name_raw != nullptr && *event_name_raw == ' ') {
      ++event_name_raw;
    }
    StoryEventType event_type = StoryEventType::kNone;
    if (!parseEventType(event_type_text, &event_type)) {
      Serial.println("ERR SC_EVENT_TYPE");
      return;
    }
    const char* event_name = event_name_raw;
    if (event_name == nullptr || event_name[0] == '\0') {
      event_name = defaultEventNameForType(event_type);
    }
    const ScenarioSnapshot before = g_scenario.snapshot();
    const bool dispatched = dispatchScenarioEventByType(event_type, event_name, now_ms);
    const ScenarioSnapshot after = g_scenario.snapshot();
    const bool changed = std::strcmp(stepIdFromSnapshot(before), stepIdFromSnapshot(after)) != 0;
    if (dispatched && changed) {
      g_last_action_step_key[0] = '\0';
      refreshSceneIfNeeded(true);
      startPendingAudioIfAny();
    }
    Serial.printf("ACK SC_EVENT type=%s name=%s dispatched=%u changed=%u step=%s\n",
                  eventTypeName(event_type),
                  event_name,
                  dispatched ? 1U : 0U,
                  changed ? 1U : 0U,
                  stepIdFromSnapshot(after));
    return;
  }
  if (std::strcmp(command, "SC_EVENT_RAW") == 0) {
    if (argument == nullptr || argument[0] == '\0') {
      Serial.println("ERR SC_EVENT_RAW_ARG");
      return;
    }
    const ScenarioSnapshot before = g_scenario.snapshot();
    const bool dispatched = dispatchScenarioEventByName(argument, now_ms);
    const ScenarioSnapshot after = g_scenario.snapshot();
    const bool changed = std::strcmp(stepIdFromSnapshot(before), stepIdFromSnapshot(after)) != 0;
    if (dispatched && changed) {
      g_last_action_step_key[0] = '\0';
      refreshSceneIfNeeded(true);
      startPendingAudioIfAny();
    }
    Serial.printf("ACK SC_EVENT_RAW name=%s dispatched=%u changed=%u step=%s\n",
                  argument,
                  dispatched ? 1U : 0U,
                  changed ? 1U : 0U,
                  stepIdFromSnapshot(after));
    return;
  }
  if (std::strcmp(command, "NET_STATUS") == 0 || std::strcmp(command, "WIFI_STATUS") == 0 ||
      std::strcmp(command, "ESPNOW_STATUS") == 0) {
    printNetworkStatus();
    return;
  }
  if (std::strcmp(command, "AUTH_STATUS") == 0) {
    Serial.printf("AUTH_STATUS setup_mode=%u auth_required=%u token_set=%u provisioned=%u\n",
                  g_setup_mode ? 1U : 0U,
                  g_web_auth_required ? 1U : 0U,
                  g_web_auth_token[0] != '\0' ? 1U : 0U,
                  g_credential_store.isProvisioned() ? 1U : 0U);
    return;
  }
  if (std::strcmp(command, "AUTH_TOKEN_ROTATE") == 0) {
    bool ok = false;
    if (argument != nullptr && argument[0] != '\0') {
      char token[kWebAuthTokenCapacity] = {0};
      copyText(token, sizeof(token), argument);
      trimAsciiInPlace(token);
      ok = token[0] != '\0' && g_credential_store.saveWebToken(token);
      if (ok) {
        copyText(g_web_auth_token, sizeof(g_web_auth_token), token);
      }
    } else {
      ok = ensureWebToken(true, false, nullptr);
    }
    Serial.printf("ACK AUTH_TOKEN_ROTATE ok=%u%s%s\n",
                  ok ? 1U : 0U,
                  ok ? " token=" : "",
                  ok ? g_web_auth_token : "");
    return;
  }
  if (std::strcmp(command, "ESPNOW_STATUS_JSON") == 0) {
    printEspNowStatusJson();
    return;
  }
  if (std::strcmp(command, "WIFI_TEST") == 0) {
    if (g_network_cfg.wifi_test_ssid[0] == '\0') {
      Serial.println("ERR WIFI_TEST_NO_CREDENTIALS");
      return;
    }
    const bool ok = g_network.connectSta(g_network_cfg.wifi_test_ssid, g_network_cfg.wifi_test_password);
    Serial.printf("ACK WIFI_TEST ssid=%s ok=%u\n", g_network_cfg.wifi_test_ssid, ok ? 1U : 0U);
    return;
  }
  if (std::strcmp(command, "WIFI_PROVISION") == 0) {
    if (argument == nullptr) {
      Serial.println("ERR WIFI_PROVISION_ARG");
      return;
    }
    String ssid;
    String pass;
    if (!splitSsidPass(argument, &ssid, &pass) || ssid.isEmpty()) {
      Serial.println("ERR WIFI_PROVISION_ARG");
      return;
    }
    bool connect_started = false;
    bool persisted = false;
    bool token_generated = false;
    const bool ok = provisionWifiCredentials(
        ssid.c_str(), pass.c_str(), true, &connect_started, &persisted, &token_generated);
    Serial.printf("ACK WIFI_PROVISION ssid=%s ok=%u persisted=%u connect_started=%u setup_mode=%u token_set=%u\n",
                  ssid.c_str(),
                  ok ? 1U : 0U,
                  persisted ? 1U : 0U,
                  connect_started ? 1U : 0U,
                  g_setup_mode ? 1U : 0U,
                  g_web_auth_token[0] != '\0' ? 1U : 0U);
    if (token_generated && g_web_auth_token[0] != '\0') {
      Serial.printf("AUTH_TOKEN %s\n", g_web_auth_token);
    }
    return;
  }
  if (std::strcmp(command, "WIFI_STA") == 0 || std::strcmp(command, "WIFI_CONNECT") == 0) {
    if (argument == nullptr) {
      Serial.println("ERR WIFI_STA_ARG");
      return;
    }
    String ssid;
    String pass;
    if (!splitSsidPass(argument, &ssid, &pass) || ssid.isEmpty()) {
      Serial.println("ERR WIFI_STA_ARG");
      return;
    }
    const bool ok = g_network.connectSta(ssid.c_str(), pass.c_str());
    Serial.printf("ACK WIFI_STA ssid=%s ok=%u\n", ssid.c_str(), ok ? 1U : 0U);
    return;
  }
  if (std::strcmp(command, "WIFI_FORGET") == 0) {
    const bool ok = forgetWifiCredentials();
    Serial.printf("ACK WIFI_FORGET ok=%u setup_mode=%u\n", ok ? 1U : 0U, g_setup_mode ? 1U : 0U);
    return;
  }
  if (std::strcmp(command, "WIFI_DISCONNECT") == 0) {
    g_network.disconnectSta();
    Serial.println("ACK WIFI_DISCONNECT");
    return;
  }
  if (std::strcmp(command, "WIFI_AP_ON") == 0) {
    String ssid = g_network_cfg.ap_default_ssid;
    String pass = g_network_cfg.ap_default_password;
    if (argument != nullptr) {
      String parsed_ssid;
      String parsed_pass;
      if (splitSsidPass(argument, &parsed_ssid, &parsed_pass) && !parsed_ssid.isEmpty()) {
        ssid = parsed_ssid;
        if (!parsed_pass.isEmpty()) {
          pass = parsed_pass;
        }
      } else if (std::strlen(argument) > 0U) {
        ssid = argument;
      }
    }
    const bool ok = g_network.startAp(ssid.c_str(), pass.c_str());
    Serial.printf("ACK WIFI_AP_ON ssid=%s ok=%u\n", ssid.c_str(), ok ? 1U : 0U);
    return;
  }
  if (std::strcmp(command, "WIFI_AP_OFF") == 0) {
    g_network.stopAp();
    Serial.println("ACK WIFI_AP_OFF");
    return;
  }
  if (std::strcmp(command, "ESPNOW_ON") == 0) {
    const bool ok = g_network.enableEspNow();
    Serial.printf("ACK ESPNOW_ON %u\n", ok ? 1U : 0U);
    return;
  }
  if (std::strcmp(command, "ESPNOW_OFF") == 0) {
    g_network.disableEspNow();
    Serial.println("ACK ESPNOW_OFF");
    return;
  }
  if (std::strcmp(command, "ESPNOW_DISCOVERY") == 0) {
    const uint8_t discovered = runEspNowDiscoverySweep();
    Serial.printf("ACK ESPNOW_DISCOVERY mode=broadcast+discovery peers=%u\n", discovered);
    for (uint8_t index = 0U; index < discovered; ++index) {
      char peer[18] = {0};
      if (!g_network.espNowPeerAt(index, peer, sizeof(peer))) {
        continue;
      }
      Serial.printf("ESPNOW_PEER idx=%u mac=%s\n", index, peer);
    }
    return;
  }
  if (std::strcmp(command, "ESPNOW_DISCOVERY_RUNTIME") == 0) {
    if (argument != nullptr && argument[0] != '\0') {
      bool enabled = false;
      if (!parseBoolToken(argument, &enabled)) {
        Serial.println("ERR ESPNOW_DISCOVERY_RUNTIME_ARG");
        return;
      }
      g_espnow_discovery_runtime_enabled = enabled;
      g_next_espnow_discovery_ms = now_ms + 50U;
    }
    Serial.printf("ACK ESPNOW_DISCOVERY_RUNTIME enabled=%u interval_ms=%lu\n",
                  g_espnow_discovery_runtime_enabled ? 1U : 0U,
                  static_cast<unsigned long>(kEspNowDiscoveryIntervalMs));
    return;
  }
  if (std::strcmp(command, "ESPNOW_DEVICE_NAME_GET") == 0) {
    Serial.printf("ACK ESPNOW_DEVICE_NAME_GET name=%s\n", g_espnow_device_name);
    return;
  }
  if (std::strcmp(command, "ESPNOW_DEVICE_NAME_SET") == 0) {
    if (argument == nullptr || argument[0] == '\0') {
      Serial.println("ERR ESPNOW_DEVICE_NAME_SET_ARG");
      return;
    }
    const bool ok = saveEspNowDeviceNameToNvs(argument);
    Serial.printf("ACK ESPNOW_DEVICE_NAME_SET ok=%u name=%s\n", ok ? 1U : 0U, g_espnow_device_name);
    return;
  }
  if (std::strcmp(command, "ESPNOW_PEER_ADD") == 0) {
    if (argument == nullptr || argument[0] == '\0') {
      Serial.println("ERR ESPNOW_PEER_ADD_ARG");
      return;
    }
    const bool ok = g_network.addEspNowPeer(argument);
    Serial.printf("ACK ESPNOW_PEER_ADD mac=%s ok=%u\n", argument, ok ? 1U : 0U);
    return;
  }
  if (std::strcmp(command, "ESPNOW_PEER_DEL") == 0) {
    if (argument == nullptr || argument[0] == '\0') {
      Serial.println("ERR ESPNOW_PEER_DEL_ARG");
      return;
    }
    const bool ok = g_network.removeEspNowPeer(argument);
    Serial.printf("ACK ESPNOW_PEER_DEL mac=%s ok=%u\n", argument, ok ? 1U : 0U);
    return;
  }
  if (std::strcmp(command, "ESPNOW_PEER_LIST") == 0) {
    const uint8_t count = g_network.espNowPeerCount();
    Serial.printf("ESPNOW_PEER_LIST count=%u\n", count);
    for (uint8_t index = 0U; index < count; ++index) {
      char peer[18] = {0};
      if (!g_network.espNowPeerAt(index, peer, sizeof(peer))) {
        continue;
      }
      Serial.printf("ESPNOW_PEER idx=%u mac=%s\n", index, peer);
    }
    return;
  }
  if (std::strcmp(command, "ESPNOW_SEND") == 0) {
    if (argument == nullptr) {
      Serial.println("ERR ESPNOW_SEND_ARG");
      return;
    }
    String payload;
    if (!parseEspNowSendPayload(argument, payload, nullptr)) {
      Serial.println("ERR ESPNOW_SEND_ARG");
      return;
    }
    const bool ok = g_network.sendEspNowTarget(kEspNowBroadcastTarget, payload.c_str());
    Serial.printf("ACK ESPNOW_SEND target=%s ok=%u\n", kEspNowBroadcastTarget, ok ? 1U : 0U);
    return;
  }
  if (std::strcmp(command, "AUDIO_TEST") == 0) {
    g_audio.stop();
    const bool ok = g_audio.playDiagnosticTone();
    Serial.printf("ACK AUDIO_TEST %u\n", ok ? 1 : 0);
    return;
  }
  if (std::strcmp(command, "AUDIO_TEST_FS") == 0) {
    g_audio.stop();
    const bool ok = g_audio.play(kDiagAudioFile);
    Serial.printf("ACK AUDIO_TEST_FS %u\n", ok ? 1 : 0);
    return;
  }
  if (std::strcmp(command, "AUDIO_PROFILE") == 0) {
    if (argument == nullptr) {
      Serial.printf("AUDIO_PROFILE current=%u label=%s count=%u\n",
                    g_audio.outputProfile(),
                    g_audio.outputProfileLabel(g_audio.outputProfile()),
                    g_audio.outputProfileCount());
      return;
    }
    char* end = nullptr;
    const unsigned long parsed = std::strtoul(argument, &end, 10);
    if (end == argument || (end != nullptr && *end != '\0') || parsed > 255UL) {
      Serial.println("ERR AUDIO_PROFILE_ARG");
      return;
    }
    const uint8_t profile = static_cast<uint8_t>(parsed);
    const bool ok = g_audio.setOutputProfile(profile);
    Serial.printf("ACK AUDIO_PROFILE %u %u %s\n",
                  profile,
                  ok ? 1U : 0U,
                  ok ? g_audio.outputProfileLabel(profile) : "invalid");
    return;
  }
  if (std::strcmp(command, "AUDIO_STATUS") == 0) {
    Serial.printf("AUDIO_STATUS playing=%u track=%s codec=%s bitrate=%u profile=%u:%s fx=%u:%s vol=%u\n",
                  g_audio.isPlaying() ? 1U : 0U,
                  g_audio.currentTrack(),
                  g_audio.activeCodec(),
                  g_audio.activeBitrateKbps(),
                  g_audio.outputProfile(),
                  g_audio.outputProfileLabel(g_audio.outputProfile()),
                  g_audio.fxProfile(),
                  g_audio.fxProfileLabel(g_audio.fxProfile()),
                  g_audio.volume());
    return;
  }
  if (std::strcmp(command, "AUDIO_FX") == 0) {
    if (argument == nullptr) {
      Serial.printf("AUDIO_FX current=%u label=%s count=%u\n",
                    g_audio.fxProfile(),
                    g_audio.fxProfileLabel(g_audio.fxProfile()),
                    g_audio.fxProfileCount());
      return;
    }
    char* end = nullptr;
    const unsigned long parsed = std::strtoul(argument, &end, 10);
    if (end == argument || (end != nullptr && *end != '\0') || parsed >= g_audio.fxProfileCount() || parsed > 255UL) {
      Serial.println("ERR AUDIO_FX_ARG");
      return;
    }
    const uint8_t fx = static_cast<uint8_t>(parsed);
    const bool ok = g_audio.setFxProfile(fx);
    Serial.printf("ACK AUDIO_FX %u %u %s\n",
                  fx,
                  ok ? 1U : 0U,
                  ok ? g_audio.fxProfileLabel(fx) : "invalid");
    return;
  }
  if (std::strcmp(command, "VOL") == 0) {
    if (argument == nullptr) {
      Serial.printf("VOL %u\n", g_audio.volume());
      return;
    }
    char* end = nullptr;
    const unsigned long parsed = std::strtoul(argument, &end, 10);
    if (end == argument || (end != nullptr && *end != '\0') || parsed > static_cast<unsigned long>(FREENOVE_AUDIO_MAX_VOLUME)) {
      Serial.println("ERR VOL_ARG");
      return;
    }
    g_audio.setVolume(static_cast<uint8_t>(parsed));
    Serial.printf("ACK VOL %u\n", g_audio.volume());
    return;
  }
  if (std::strcmp(command, "AUDIO_STOP") == 0) {
    g_audio.stop();
    Serial.println("ACK AUDIO_STOP");
    return;
  }
  if (std::strcmp(command, "STOP") == 0) {
    g_audio.stop();
    Serial.println("ACK STOP");
    return;
  }
  Serial.printf("UNKNOWN %s\n", command_line);
}

void pollSerialCommands(uint32_t now_ms) {
  while (Serial.available() > 0) {
    const int raw = Serial.read();
    if (raw < 0) {
      break;
    }
    const char ch = static_cast<char>(raw);
    if (ch == '\r' || ch == '\n') {
      if (g_serial_line_len == 0U) {
        continue;
      }
      g_serial_line[g_serial_line_len] = '\0';
      g_app_coordinator.onSerialLine(g_serial_line, now_ms);
      g_serial_line_len = 0U;
      continue;
    }
    if (g_serial_line_len + 1U >= kSerialLineCapacity) {
      g_serial_line_len = 0U;
      Serial.println("ERR CMD_TOO_LONG");
      continue;
    }
    g_serial_line[g_serial_line_len++] = ch;
  }
}

}  // namespace

void setup() {
  Serial.begin(115200);
  delay(100);
  RuntimeMetrics::instance().reset(bootResetReasonCode());
  Serial.println("[MAIN] Freenove all-in-one boot");
  bootPrintReport(kFirmwareName, ZACUS_FW_VERSION);
  logBuildMemoryPolicy();
  logBootMemoryProfile();
  g_runtime_serial_service.configure(handleSerialCommandImpl, dispatchControlActionImpl);
  g_runtime_scene_service.configure(refreshSceneIfNeededImpl, startPendingAudioIfAny);
  g_runtime_web_service.configure(setupWebUiImpl);

  if (!g_storage.begin()) {
    Serial.println("[MAIN] storage init failed");
  }
  g_storage.ensurePath("/data");
  g_storage.ensurePath("/scenarios");
  g_storage.ensurePath("/scenarios/data");
  g_storage.ensurePath("/screens");
  g_storage.ensurePath("/story");
  g_storage.ensurePath("/story/scenarios");
  g_storage.ensurePath("/story/screens");
  g_storage.ensurePath("/story/audio");
  g_storage.ensurePath("/story/apps");
  g_storage.ensurePath("/story/actions");
  g_storage.ensurePath("/apps");
  g_storage.ensurePath("/picture");
  g_storage.ensurePath("/music");
  g_storage.ensurePath("/audio");
  g_storage.ensurePath("/recorder");
  g_storage.ensureDefaultStoryBundle();
  if (kAutoSyncStoryFromSdOnBoot && g_storage.hasSdCard()) {
    g_storage.syncStoryTreeFromSd();
  }
  g_storage.ensureDefaultScenarioFile(kDefaultScenarioFile);
  if (kAutoSyncStoryFromSdOnBoot && g_storage.hasSdCard()) {
    g_storage.syncStoryFileFromSd(kDefaultScenarioFile);
  } else if (g_storage.hasSdCard()) {
    Serial.println("[MAIN] SD story sync disabled on boot (LittleFS is primary)");
  }
  RuntimeConfigService::load(g_storage, &g_network_cfg, &g_hardware_cfg, &g_camera_cfg, &g_media_cfg);
  if (kSprintDiagMode) {
    if (g_hardware_cfg.telemetry_period_ms < kSprintDiagTelemetryMinMs) {
      g_hardware_cfg.telemetry_period_ms = kSprintDiagTelemetryMinMs;
    }
    g_network_cfg.espnow_enabled_on_boot = false;
    g_espnow_discovery_runtime_enabled = false;
    Serial.printf("[DIAG] sprint_mode=1 telemetry_ms=%lu espnow_boot=0 discovery_runtime=0\n",
                  static_cast<unsigned long>(g_hardware_cfg.telemetry_period_ms));
  }
  loadBootProvisioningState();
  loadEspNowDeviceNameFromNvs();
  g_file_share_service.begin(g_network_cfg.hostname, g_espnow_device_name);
  {
    BootModeStore::StartupMode startup_mode = BootModeStore::StartupMode::kStory;
    if (g_boot_mode_store.loadMode(&startup_mode)) {
      applyStartupMode(startup_mode);
    } else {
      applyStartupMode(BootModeStore::StartupMode::kStory);
    }
    Serial.printf("[BOOT] startup_mode=%s media_validated=%u\n",
                  BootModeStore::modeLabel(currentStartupMode()),
                  g_boot_mode_store.isMediaValidated() ? 1U : 0U);
  }
  g_resource_coordinator.begin();
  const bool app_registry_loaded = g_app_registry.loadFromFs(g_storage);
  Serial.printf("[APP] registry loaded=%u count=%u\n",
                app_registry_loaded ? 1U : 0U,
                static_cast<unsigned int>(g_app_registry.descriptors().size()));
  Serial.printf("[MAIN] default scenario checksum=%lu\n",
                static_cast<unsigned long>(g_storage.checksum(kDefaultScenarioFile)));
  Serial.printf("[MAIN] story storage sd=%u\n", g_storage.hasSdCard() ? 1U : 0U);
  Serial.printf("[AUTH] setup_mode=%u auth_required=%u token_set=%u\n",
                g_setup_mode ? 1U : 0U,
                g_web_auth_required ? 1U : 0U,
                g_web_auth_token[0] != '\0' ? 1U : 0U);

  // Initialize audio before camera/network stacks to keep DMA heap pressure low during I2S bring-up.
  g_audio.begin();
  Serial.printf("[MAIN] audio profile=%u:%s count=%u\n",
                g_audio.outputProfile(),
                g_audio.outputProfileLabel(g_audio.outputProfile()),
                g_audio.outputProfileCount());
  g_audio.setAudioDoneCallback(onAudioFinished, nullptr);
  if (kBootDiagnosticTone) {
    g_audio.playDiagnosticTone();
  }

  g_media.begin(g_media_cfg);
  g_camera.begin(g_camera_cfg);
  if (g_camera_cfg.enabled_on_boot) {
    String cam_error;
    if (approveCameraOperation("boot_cam_on", &cam_error)) {
      const bool cam_ok = g_camera.start();
      Serial.printf("[CAM] boot start=%u\n", cam_ok ? 1U : 0U);
    } else {
      Serial.printf("[CAM] boot start blocked profile=%s\n", g_resource_coordinator.profileName());
    }
  }
  if (g_hardware_cfg.enabled_on_boot) {
    g_hardware_started = g_hardware.begin();
    g_next_hw_telemetry_ms = millis() + g_hardware_cfg.telemetry_period_ms;
    g_mic_event_armed = true;
    g_battery_low_latched = false;
    resetLaTriggerState(false);
  } else {
    g_hardware_started = false;
    Serial.println("[HW] disabled by APP_HARDWARE config");
  }

  g_buttons.begin();
  g_touch.begin();
  g_network.begin(g_network_cfg.hostname);
  if (kBootEspNowOnlyMode) {
    g_network.configureFallbackAp("", "");
    g_network.configureLocalPolicy("", "", false, g_network_cfg.local_retry_ms, false);
    Serial.println("[NET] wifi+web disabled (espnow_only_mode=1)");
  } else {
    g_network.configureFallbackAp(g_network_cfg.ap_default_ssid, g_network_cfg.ap_default_password);
    g_network.configureLocalPolicy(g_network_cfg.local_ssid,
                                   g_network_cfg.local_password,
                                   g_network_cfg.force_ap_if_not_local,
                                   g_network_cfg.local_retry_ms,
                                   g_network_cfg.pause_local_retry_when_ap_client);
    if ((g_setup_mode || kSprintDiagMode) && g_network_cfg.ap_default_ssid[0] != '\0') {
      g_network.startAp(g_network_cfg.ap_default_ssid, g_network_cfg.ap_default_password);
    }
    if (g_network_cfg.local_ssid[0] != '\0') {
      const bool connect_started = g_network.connectSta(g_network_cfg.local_ssid, g_network_cfg.local_password);
      Serial.printf("[NET] boot wifi target=%s started=%u\n",
                    g_network_cfg.local_ssid,
                    connect_started ? 1U : 0U);
    }
  }
  if (g_network_cfg.espnow_enabled_on_boot) {
    if (g_network.enableEspNow()) {
      for (uint8_t index = 0U; index < g_network_cfg.espnow_boot_peer_count; ++index) {
        const char* peer = g_network_cfg.espnow_boot_peers[index];
        if (peer == nullptr || peer[0] == '\0') {
          continue;
        }
        const bool ok = g_network.addEspNowPeer(peer);
        Serial.printf("[NET] boot peer add mac=%s ok=%u\n", peer, ok ? 1U : 0U);
      }
    }
  } else {
    Serial.println("[NET] ESP-NOW boot disabled by APP_ESPNOW config");
  }
  g_next_espnow_discovery_ms = millis() + 2000U;
  if (!kBootEspNowOnlyMode) {
    setupWebUi();
  } else {
    Serial.println("[WEB] disabled (espnow_only_mode=1)");
  }
  // Scenario loading disabled to boot directly to Amiga UI Shell grid launcher
  // if (!g_scenario.begin(kDefaultScenarioFile)) {
  //   Serial.println("[MAIN] scenario init failed");
  // }
  // Boot routing disabled - using AmigaUIShell instead
  // if (kForceTestLabSceneLock) {
  //   const bool routed = g_scenario.gotoScene(kTestLabSceneId, millis(), "boot_force_test_lab");
  //   Serial.printf("[BOOT] route test_lab scene=%s ok=%u lock=1\n", kTestLabSceneId, routed ? 1U : 0U);
  // } else if (g_boot_media_manager_mode) {
  //   const bool routed = g_scenario.gotoScene(kMediaManagerSceneId, millis(), "boot_mode_media_manager");
  //   Serial.printf("[BOOT] route media_manager scene=%s ok=%u\n", kMediaManagerSceneId, routed ? 1U : 0U);
  // } else {
  //   const bool routed = g_scenario.gotoScene(kDefaultBootSceneId, millis(), "boot_story_default");
  //   Serial.printf("[BOOT] route default scene=%s ok=%u\n", kDefaultBootSceneId, routed ? 1U : 0U);
  // }
  g_last_action_step_key[0] = '\0';

  g_ui.begin();
  
  // Initialize Amiga UI Shell for grid-based app launcher
  g_amiga_shell.init(&g_hardware, &g_ui, &g_app_registry);
  g_amiga_shell.onStart();
  
  applyLcdBacklight(g_lcd_backlight_level);
  g_ui.setHardwareController(&g_hardware);
  UiLaMetrics boot_la_metrics = {};
  boot_la_metrics.locked = false;
  boot_la_metrics.stability_pct = 0U;
  boot_la_metrics.stable_ms = 0U;
  boot_la_metrics.stable_target_ms = g_hardware_cfg.mic_la_stable_ms;
  boot_la_metrics.gate_elapsed_ms = 0U;
  boot_la_metrics.gate_timeout_ms = g_hardware_cfg.mic_la_timeout_ms;
  g_ui.setLaMetrics(boot_la_metrics);
  g_ui.setHardwareSnapshotRef(&g_hardware.snapshotRef());
#if defined(USE_AUDIO) && (USE_AUDIO != 0)
  g_amp_ready = false;
  g_amp_scene_active = false;
  copyText(g_amp_base_dir, sizeof(g_amp_base_dir), kAmpMusicPathPrimary);
  Serial.println("[AMP] lazy init (on SCENE_MP3_PLAYER)");
#endif
  g_camera_scene_active = false;
  g_camera_scene_ready = ensureCameraUiInitialized();
  // Boot directly to Amiga UI Shell (skip default scenario rendering)
  // refreshSceneIfNeeded(true);
  // startPendingAudioIfAny();

  g_runtime_services.audio = &g_audio;
  g_runtime_services.scenario = &g_scenario;
  g_runtime_services.ui = &g_ui;
  g_runtime_services.storage = &g_storage;
  g_runtime_services.buttons = &g_buttons;
  g_runtime_services.touch = &g_touch;
  g_runtime_services.network = &g_network;
  g_runtime_services.hardware = &g_hardware;
  g_runtime_services.camera = &g_camera;
  g_runtime_services.media = &g_media;
  g_runtime_services.resource_coordinator = &g_resource_coordinator;
  g_runtime_services.network_cfg = &g_network_cfg;
  g_runtime_services.hardware_cfg = &g_hardware_cfg;
  g_runtime_services.camera_cfg = &g_camera_cfg;
  g_runtime_services.media_cfg = &g_media_cfg;
  g_runtime_services.tick_runtime = runtimeTickBridge;
  g_runtime_services.dispatch_serial = serialDispatchBridge;

  AppContext app_context = {};
  app_context.audio = &g_audio;
  app_context.camera = &g_camera;
  app_context.hardware = &g_hardware;
  app_context.media = &g_media;
  app_context.network = &g_network;
  app_context.storage = &g_storage;
  app_context.ui = &g_ui;
  app_context.resource = &g_resource_coordinator;
  g_app_runtime_manager.configure(&g_app_registry, app_context);
  g_amiga_shell.setRuntimeBridge(&kAmigaAppRuntimeBridge);

  g_app_coordinator.begin(&g_runtime_services);
}

void runRuntimeIteration(uint32_t now_ms) {
  ButtonEvent event;
  while (g_buttons.pollEvent(&event)) {
    const uint32_t event_ms = (event.ms != 0U) ? event.ms : now_ms;
    ZACUS_RL_LOG_MS(250U,
                    "[MAIN] button key=%u long=%u ms=%lu\n",
                    event.key,
                    event.long_press ? 1U : 0U,
                    static_cast<unsigned long>(event_ms));
    UiInputEvent ui_event = {};
    ui_event.type = UiInputEventType::kButton;
    ui_event.key = event.key;
    ui_event.long_press = event.long_press;
    g_ui.submitInputEvent(ui_event);
    if (g_camera_scene_active) {
      (void)dispatchCameraSceneButton(event.key, event.long_press);
    }
#if defined(USE_AUDIO) && (USE_AUDIO != 0)
    if (!g_amp_scene_active && !g_camera_scene_active) {
      notifyScenarioButtonGuarded(event.key, event.long_press, event_ms, "physical_button");
    }
#else
    if (!g_camera_scene_active) {
      notifyScenarioButtonGuarded(event.key, event.long_press, event_ms, "physical_button");
    }
#endif
    if (g_hardware_started) {
      g_hardware.noteButton(event.key, event.long_press, event_ms);
    }
  }

  TouchPoint touch;
  if (g_touch.poll(&touch)) {
    UiInputEvent ui_event = {};
    ui_event.type = UiInputEventType::kTouch;
    ui_event.touch_x = touch.x;
    ui_event.touch_y = touch.y;
    ui_event.touch_pressed = touch.touched;
    g_ui.submitInputEvent(ui_event);
  } else {
    UiInputEvent ui_event = {};
    ui_event.type = UiInputEventType::kTouch;
    ui_event.touch_x = 0;
    ui_event.touch_y = 0;
    ui_event.touch_pressed = false;
    g_ui.submitInputEvent(ui_event);
  }

  const uint32_t network_started_us = perfMonitor().beginSample();
  g_network.update(now_ms);
  if (kSprintDiagMode && !g_diag_ap_stopped) {
    const NetworkManager::Snapshot net_snapshot = g_network.snapshot();
    if (net_snapshot.sta_connected && net_snapshot.ap_enabled) {
      g_network.stopAp();
      g_diag_ap_stopped = true;
      Serial.println("[DIAG] AP auto-stop after STA connect");
    }
  }
  g_file_share_service.update(now_ms);
  maybeRunEspNowDiscoveryRuntime(now_ms);
  perfMonitor().endSample(PerfSection::kNetworkUpdate, network_started_us);
  if (g_hardware_started) {
    applyMicRuntimePolicy();
    g_hardware.update(now_ms);
    updateSceneWs2812Fx(now_ms);
    maybeEmitHardwareEvents(now_ms);
    maybeLogHardwareTelemetry(now_ms);
    maybeStreamMicTunerStatus(now_ms);
  }
  char net_payload[192] = {0};
  char net_peer[18] = {0};
  char net_msg_id[32] = {0};
  char net_type[24] = {0};
  uint32_t net_seq = 0U;
  bool net_ack_requested = false;
  while (g_network.consumeEspNowMessage(net_payload,
                                        sizeof(net_payload),
                                        net_peer,
                                        sizeof(net_peer),
                                        net_msg_id,
                                        sizeof(net_msg_id),
                                        &net_seq,
                                        net_type,
                                        sizeof(net_type),
                                        &net_ack_requested)) {
    EspNowCommandResult command_result;
    bool handled_as_command = false;
    if (net_type[0] != '\0' && std::strcmp(net_type, "command") == 0) {
      handled_as_command = executeEspNowCommandPayload(net_payload, now_ms, &command_result);
      if (!command_result.handled) {
        command_result.handled = true;
        command_result.ok = false;
        command_result.code = "command";
        command_result.error = "unsupported_command";
      }
      sendEspNowAck(net_peer, net_msg_id, net_seq, command_result, net_ack_requested);
      Serial.printf("[NET] ESPNOW command peer=%s msg_id=%s seq=%lu ok=%u code=%s err=%s\n",
                    net_peer[0] != '\0' ? net_peer : "n/a",
                    net_msg_id[0] != '\0' ? net_msg_id : "n/a",
                    static_cast<unsigned long>(net_seq),
                    command_result.ok ? 1U : 0U,
                    command_result.code.c_str(),
                    command_result.error.c_str());
      if (handled_as_command) {
        continue;
      }
    }
    if (!g_network_cfg.espnow_bridge_to_story_event) {
      Serial.printf("[NET] ESPNOW peer=%s payload=%s type=%s bridge=off\n",
                    net_peer[0] != '\0' ? net_peer : "n/a",
                    net_payload,
                    net_type[0] != '\0' ? net_type : "legacy");
      continue;
    }
    char event_token[kSerialLineCapacity] = {0};
    if (!normalizeEspNowPayloadToScenarioEvent(net_payload, event_token, sizeof(event_token))) {
      Serial.printf("[NET] ESPNOW peer=%s payload=%s type=%s ignored=unsupported\n",
                    net_peer[0] != '\0' ? net_peer : "n/a",
                    net_payload,
                    net_type[0] != '\0' ? net_type : "legacy");
      continue;
    }
    const ScenarioSnapshot before = g_scenario.snapshot();
    const bool dispatched = dispatchScenarioEventByName(event_token, now_ms);
    const ScenarioSnapshot after = g_scenario.snapshot();
    const bool changed = std::strcmp(stepIdFromSnapshot(before), stepIdFromSnapshot(after)) != 0;
    Serial.printf("[NET] ESPNOW peer=%s payload=%s type=%s event=%s dispatched=%u changed=%u step=%s\n",
                  net_peer[0] != '\0' ? net_peer : "n/a",
                  net_payload,
                  net_type[0] != '\0' ? net_type : "legacy",
                  event_token,
                  dispatched ? 1U : 0U,
                  changed ? 1U : 0U,
                  stepIdFromSnapshot(after));
  }

  const uint32_t audio_started_us = perfMonitor().beginSample();
#if defined(USE_AUDIO) && (USE_AUDIO != 0)
  if (!g_amp_scene_active) {
    g_audio.update();
  }
#else
  g_audio.update();
#endif
  perfMonitor().endSample(PerfSection::kAudioUpdate, audio_started_us);
  g_media.update(now_ms, &g_audio);
  const uint32_t scenario_started_us = perfMonitor().beginSample();
  const ScenarioSnapshot scenario_before_tick = g_scenario.snapshot();
  if (kForceTestLabSceneLock) {
    if (!isFixedTestSceneActive(scenario_before_tick)) {
      const bool routed = g_scenario.gotoScene(kTestLabSceneId, now_ms, "runtime_force_test_lab");
      if (!routed) {
        ZACUS_RL_LOG_MS(4000U, "[SCENE] force test lab failed\n");
      }
    }
  } else if (isFixedTestSceneActive(scenario_before_tick)) {
    // Keep SCENE_TEST_LAB stable while calibrating colors (no automatic step transitions).
  } else {
    g_scenario.tick(now_ms);
  }
  perfMonitor().endSample(PerfSection::kScenarioTick, scenario_started_us);
  startPendingAudioIfAny();
  updateUsonProtoAmbientAudio(g_scenario.snapshot(), now_ms);
  if (g_win_etape_ui_refresh_pending) {
    g_win_etape_ui_refresh_pending = false;
    refreshSceneIfNeeded(true);
  }
  uint32_t la_gate_elapsed_ms = 0U;
  if (g_la_trigger.gate_active && g_la_trigger.gate_entered_ms > 0U) {
    la_gate_elapsed_ms = now_ms - g_la_trigger.gate_entered_ms;
  }
  UiLaMetrics la_metrics = {};
  la_metrics.locked = g_la_trigger.locked;
  la_metrics.stability_pct = laStablePercent();
  la_metrics.stable_ms = g_la_trigger.stable_ms;
  la_metrics.stable_target_ms = g_hardware_cfg.mic_la_stable_ms;
  la_metrics.gate_elapsed_ms = la_gate_elapsed_ms;
  la_metrics.gate_timeout_ms = g_hardware_cfg.mic_la_timeout_ms;
  g_ui.setLaMetrics(la_metrics);
  refreshSceneIfNeeded(false);
  updateWarningSirenFx(now_ms);
  updateSceneBacklightFx(now_ms);
  const uint32_t ui_started_us = perfMonitor().beginSample();
  g_ui.tick(now_ms);
  char runtime_event[24] = {0};
  while (g_ui.consumeRuntimeEvent(runtime_event, sizeof(runtime_event))) {
    char event_token[40] = "SERIAL:";
    std::strncat(event_token, runtime_event, sizeof(event_token) - std::strlen(event_token) - 1U);
    bool dispatched = dispatchScenarioEventByName(event_token, now_ms);
    if (!dispatched) {
      dispatched = dispatchScenarioEventByName(runtime_event, now_ms);
      if (dispatched) {
        std::strncpy(event_token, runtime_event, sizeof(event_token) - 1U);
        event_token[sizeof(event_token) - 1U] = '\0';
      }
    }
    Serial.printf("[UI_EVENT] event=%s dispatched=%u\n", event_token, dispatched ? 1U : 0U);
    if (dispatched) {
      refreshSceneIfNeeded(true);
    }
    runtime_event[0] = '\0';
  }
#if defined(USE_AUDIO) && (USE_AUDIO != 0)
  if (g_amp_ready) {
    g_amp_player.tick(now_ms);
  }
#endif
  g_app_runtime_manager.tick(now_ms);
  g_resource_coordinator.update(g_ui.memorySnapshot(), now_ms);
  applyMicRuntimePolicy();
  RuntimeMetrics::instance().noteUiFrame(now_ms);
  perfMonitor().endSample(PerfSection::kUiTick, ui_started_us);
  RuntimeMetrics::instance().logPeriodic(now_ms, kMetricsLogPeriodMs);
  if (g_web_started) {
    g_web_server.handleClient();
    if (g_web_disconnect_sta_pending &&
        static_cast<int32_t>(now_ms - g_web_disconnect_sta_at_ms) >= 0) {
      g_web_disconnect_sta_pending = false;
      g_network.disconnectSta();
    }
  }
  yield();
}

void loop() {
  const uint32_t now_ms = millis();
  pollSerialCommands(now_ms);
  g_app_coordinator.tick(now_ms);
}
