#include "cloud_client.h"

#include <cstdio>
#include <cstring>
#include <string>
#include <atomic>
#include "audio_backend.h"
#include "input_sender_timing.h"
#include "websocket_text_receiver.h"
#ifdef CONFIG_DEVICE_LAB_SPEECH_TRANSPORT_TRACE
#include "speech_transport_trace.h"
#endif
#include "mbedtls/base64.h"

#include "board.h"
#include "cJSON.h"
#include "esp_check.h"
#include "esp_crt_bundle.h"
#include "esp_log.h"
#include "esp_heap_caps.h"
#include "esp_mac.h"
#include "esp_timer.h"
#include "esp_wifi.h"
#include "esp_websocket_client.h"
#include "freertos/FreeRTOS.h"
#include "freertos/event_groups.h"
#include "freertos/task.h"
#include "wifi_manager.h"

static const char *TAG = "CLOUD";
static constexpr int CLOUD_RECONNECT_TIMEOUT_MS = 5000;
static constexpr int CLOUD_RX_BUFFER_BYTES = 16 * 1024;
static esp_websocket_client_handle_t s_client;
static std::atomic<bool> s_connected{false};
static char s_device_id[24];
static WebSocketTextReceiver s_text_receiver;
static int64_t s_rx_started_us, s_last_rx_trace_us;
static unsigned s_rx_parts;
#ifdef CONFIG_DEVICE_LAB_SPEECH_TRACE
static SpeechFragmentClock s_fragment_clock;
static void trace_partial_receive() {
    const auto &f=s_fragment_clock.frame;
    if (f.parts && !f.complete_us)
        ESP_LOGI(TAG,"[SPEECH_RX_PARTIAL] frame=%lu wire_bytes=%u parts=%lu first_ms=%lld at_ms=%lld largest_inter_part_gap_ms=%lld",
                 static_cast<unsigned long>(f.identity),static_cast<unsigned>(s_text_receiver.size()),
                 static_cast<unsigned long>(f.parts),static_cast<long long>(f.first_us/1000),
                 static_cast<long long>(esp_timer_get_time()/1000),static_cast<long long>(f.max_part_gap_us/1000));
    s_fragment_clock.frame.parts=0;
}
#endif

static void log_link_health(const char *reason, size_t bytes, unsigned parts, int64_t elapsed_ms)
{
    wifi_ap_record_t ap = {};
    const int rssi = esp_wifi_sta_get_ap_info(&ap) == ESP_OK ? ap.rssi : -127;
    wifi_ps_type_t power_save = WIFI_PS_NONE;
    esp_wifi_get_ps(&power_save);
    ESP_LOGI(TAG, "[CLOUD] link reason=%s bytes=%u parts=%u elapsed_ms=%lld rssi=%d ps=%d internal_free=%u dma_largest=%u",
             reason, static_cast<unsigned>(bytes), parts, static_cast<long long>(elapsed_ms), rssi,
             static_cast<int>(power_save), static_cast<unsigned>(heap_caps_get_free_size(MALLOC_CAP_INTERNAL)),
             static_cast<unsigned>(heap_caps_get_largest_free_block(MALLOC_CAP_DMA)));
}

static double json_number(const cJSON *root, const char *name, double fallback = 0)
{
    const cJSON *value = cJSON_GetObjectItemCaseSensitive(root, name);
    return cJSON_IsNumber(value) ? value->valuedouble : fallback;
}

static std::string json_string(cJSON *root)
{
    char *text = cJSON_PrintUnformatted(root);
    std::string result = text == nullptr ? "{}" : text;
    cJSON_free(text);
    cJSON_Delete(root);
    return result;
}

static bool send_json(cJSON *root)
{
    std::string text = json_string(root);
    if (!s_connected || s_client == nullptr) {
        ESP_LOGW(TAG, "[CLOUD] not connected; dropped event");
        return false;
    }
    const int64_t started = esp_timer_get_time();
    int result = esp_websocket_client_send_text(s_client, text.c_str(), text.size(), pdMS_TO_TICKS(VOICE_SEND_TIMEOUT_MS));
    if (result != static_cast<int>(text.size()))
        log_link_health("send-failed", text.size(), 0, (esp_timer_get_time() - started) / 1000);
    return result == static_cast<int>(text.size());
}

static void send_hello(void)
{
    cJSON *root = cJSON_CreateObject();
    cJSON_AddStringToObject(root, "type", "hello");
    cJSON_AddStringToObject(root, "device_id", s_device_id);
    cJSON_AddStringToObject(root, "device_token", CONFIG_DEVICE_LAB_DEVICE_TOKEN);
    cJSON_AddStringToObject(root, "board", board_slug());
    cJSON_AddStringToObject(root, "firmware", DEVICE_LAB_FIRMWARE_VERSION);
    const char *capabilities[]={"agent_target","approval","voice_input","settings","speech_playback"};
    cJSON_AddItemToObject(root,"capabilities",cJSON_CreateStringArray(capabilities,5));
    if (send_json(root)) {
        ESP_LOGI(TAG, "[CLOUD] sent hello device_id=%s", s_device_id);
    }
}

static void handle_message(const char *payload, size_t length, const SpeechRxFrame *rx = nullptr)
{
    cJSON *root = cJSON_ParseWithLength(payload, length);
    if (root == nullptr) {
        ESP_LOGW(TAG, "[CLOUD] ignored invalid JSON");
        return;
    }
    const cJSON *type = cJSON_GetObjectItemCaseSensitive(root, "type");
    if (cJSON_IsString(type) && strcmp(type->valuestring, "hello_ack") == 0) {
        ESP_LOGI(TAG, "[CLOUD] hello acknowledged");
    } else if (cJSON_IsString(type) && strcmp(type->valuestring, "approval_request") == 0) {
        const auto *id = cJSON_GetObjectItemCaseSensitive(root, "approval_id");
        const auto *agent = cJSON_GetObjectItemCaseSensitive(root, "agent");
        const auto *title = cJSON_GetObjectItemCaseSensitive(root, "title");
        const auto *detail = cJSON_GetObjectItemCaseSensitive(root, "detail");
        if (cJSON_IsString(id) && cJSON_IsString(agent) && cJSON_IsString(title) && cJSON_IsString(detail))
            board_approval_request(id->valuestring, agent->valuestring, title->valuestring, detail->valuestring, 0);
    } else if (cJSON_IsString(type) && strcmp(type->valuestring, "approval_cancelled") == 0) {
        const auto *id = cJSON_GetObjectItemCaseSensitive(root, "approval_id");
        const auto *state = cJSON_GetObjectItemCaseSensitive(root, "state");
        if (cJSON_IsString(id) && cJSON_IsString(state)) board_approval_cancelled(id->valuestring, state->valuestring);
    } else if (cJSON_IsString(type) && strcmp(type->valuestring, "server_snapshot") == 0) {
        const cJSON *hostname = cJSON_GetObjectItemCaseSensitive(root, "hostname");
        board_server_snapshot_t snapshot = {};
        snapshot.hostname = cJSON_IsString(hostname) ? hostname->valuestring : "lighthouse";
        snapshot.cpu_percent = json_number(root, "cpu_percent");
        snapshot.load_1m = json_number(root, "load_1m");
        snapshot.load_5m = json_number(root, "load_5m");
        snapshot.memory_used = json_number(root, "memory_used");
        snapshot.memory_total = json_number(root, "memory_total");
        snapshot.memory_percent = json_number(root, "memory_percent");
        snapshot.swap_percent = json_number(root, "swap_percent");
        snapshot.disk_used = json_number(root, "disk_used");
        snapshot.disk_total = json_number(root, "disk_total");
        snapshot.disk_percent = json_number(root, "disk_percent");
        snapshot.network_rx_bytes = json_number(root, "network_rx_bytes");
        snapshot.network_tx_bytes = json_number(root, "network_tx_bytes");
        snapshot.network_rx_bps = json_number(root, "network_rx_bps");
        snapshot.network_tx_bps = json_number(root, "network_tx_bps");
        snapshot.uptime = json_number(root, "uptime");
        board_set_server_snapshot(&snapshot);
        ESP_LOGD(TAG, "[CLOUD] server_snapshot host=%s cpu=%.1f", snapshot.hostname,
                 snapshot.cpu_percent);
    } else if (cJSON_IsString(type) && (strcmp(type->valuestring, "target_state") == 0 || strcmp(type->valuestring, "agent_state") == 0)) {
        const cJSON *target = cJSON_GetObjectItemCaseSensitive(root, "target");
        if (cJSON_IsString(target) && strcmp(target->valuestring, "server") == 0) {
            board_select_server_target();
            ESP_LOGI(TAG, "[CLOUD] target server");
        } else {
            const cJSON *session_id = cJSON_GetObjectItemCaseSensitive(root, "session_id");
            const cJSON *name = cJSON_GetObjectItemCaseSensitive(root, "name");
            const cJSON *cwd = cJSON_GetObjectItemCaseSensitive(root, "cwd");
            const cJSON *state = cJSON_GetObjectItemCaseSensitive(root, "state");
            const cJSON *activity_type = cJSON_GetObjectItemCaseSensitive(root, "activity_type");
            const cJSON *latest_activity = cJSON_GetObjectItemCaseSensitive(root, "latest_activity");
            const cJSON *recent_text = cJSON_GetObjectItemCaseSensitive(root, "recent_text");
            const cJSON *final_text = cJSON_GetObjectItemCaseSensitive(root, "final_text");
            const cJSON *notification = cJSON_GetObjectItemCaseSensitive(root, "notification");
            board_pi_snapshot_t snapshot = {};
            const auto *agent=cJSON_GetObjectItemCaseSensitive(root,"agent");
            const auto *model=cJSON_GetObjectItemCaseSensitive(root,"model");
            const auto *effort=cJSON_GetObjectItemCaseSensitive(root,"effort");
            snapshot.agent=cJSON_IsString(agent)?agent->valuestring:"pi";
            snapshot.model=cJSON_IsString(model)?model->valuestring:"";
            snapshot.effort=cJSON_IsString(effort)?effort->valuestring:"";
            snapshot.session_id = cJSON_IsString(session_id) ? session_id->valuestring : "unknown";
            snapshot.name = cJSON_IsString(name) ? name->valuestring : snapshot.session_id;
            snapshot.cwd = cJSON_IsString(cwd) ? cwd->valuestring : "";
            snapshot.state = cJSON_IsString(state) ? state->valuestring : "OFFLINE";
            snapshot.activity_type = cJSON_IsString(activity_type) ? activity_type->valuestring : "";
            snapshot.latest_activity = cJSON_IsString(latest_activity) ? latest_activity->valuestring : "";
            snapshot.recent_text = cJSON_IsString(recent_text) ? recent_text->valuestring : "";
            snapshot.final_text = cJSON_IsString(final_text) ? final_text->valuestring : nullptr;
            snapshot.elapsed_seconds = json_number(root, "elapsed_seconds");
            board_set_pi_snapshot(&snapshot);
            if (cJSON_IsString(notification)) {
                if (strcmp(notification->valuestring, "done") == 0) {
                    board_notify_pi(false);
                } else if (strcmp(notification->valuestring, "failed") == 0) {
                    board_notify_pi(true);
                }
            }
            ESP_LOGD(TAG, "[CLOUD] Agent state=%s", snapshot.state);
        }
    } else if (cJSON_IsString(type) && (strcmp(type->valuestring, "pi_activity") == 0 || strcmp(type->valuestring, "agent_activity") == 0)) {
        const cJSON *activity_type = cJSON_GetObjectItemCaseSensitive(root, "activity_type");
        const cJSON *activity = cJSON_GetObjectItemCaseSensitive(root, "activity");
        if (cJSON_IsString(activity)) {
            const auto *agent=cJSON_GetObjectItemCaseSensitive(root,"agent");
            const auto *sid=cJSON_GetObjectItemCaseSensitive(root,"session_id");
            const auto *recent=cJSON_GetObjectItemCaseSensitive(root,"recent_text");
            if (cJSON_IsString(sid)) board_agent_activity(cJSON_IsString(agent)?agent->valuestring:"pi",sid->valuestring,
                cJSON_IsString(activity_type)?activity_type->valuestring:"text",activity->valuestring,
                cJSON_IsString(recent)?recent->valuestring:nullptr);
        }
    } else if (cJSON_IsString(type) && (strncmp(type->valuestring,"voice_",6)==0 || strncmp(type->valuestring,"settings_",9)==0 || strncmp(type->valuestring,"speech_",7)==0)) {
        char *wire=cJSON_PrintUnformatted(root);
        if (wire) {board_control_message(wire, rx);cJSON_free(wire);}
    } else if (cJSON_IsString(type) && strcmp(type->valuestring, "notification_test") == 0) {
        const cJSON *kind = cJSON_GetObjectItemCaseSensitive(root, "kind");
        if (cJSON_IsString(kind) && strcmp(kind->valuestring, "failed") == 0) {
            board_notify_pi(true);
        } else if (cJSON_IsString(kind) && strcmp(kind->valuestring, "done") == 0) {
            board_notify_pi(false);
        }
    } else if (cJSON_IsString(type) && strcmp(type->valuestring, "display_message") == 0) {
        const cJSON *title = cJSON_GetObjectItemCaseSensitive(root, "title");
        const cJSON *body = cJSON_GetObjectItemCaseSensitive(root, "body");
        const char *title_text = cJSON_IsString(title) ? title->valuestring : "Message";
        const char *body_text = cJSON_IsString(body) ? body->valuestring : "";
        board_show_message(title_text, body_text);
        board_vibrate(80);
        ESP_LOGI(TAG, "[CLOUD] displayed display_message");
    }
    cJSON_Delete(root);
}

static void websocket_event(void *, esp_event_base_t, int32_t event_id, void *event_data)
{
#ifdef CONFIG_DEVICE_LAB_SPEECH_TRANSPORT_TRACE
    if (event_id == WEBSOCKET_EVENT_BEGIN)
        speech_transport_trace_receive_task();
#endif
    auto *event = static_cast<esp_websocket_event_data_t *>(event_data);
    switch (event_id) {
    case WEBSOCKET_EVENT_CONNECTED:
        s_text_receiver.reset();
        s_connected = true;
        board_set_cloud_status(true);
        ESP_LOGI(TAG, "[CLOUD] connected");
        log_link_health("connected", 0, 0, 0);
        send_hello();
        break;
    case WEBSOCKET_EVENT_DISCONNECTED:
#ifdef CONFIG_DEVICE_LAB_SPEECH_TRACE
        trace_partial_receive();
#endif
        s_text_receiver.reset();
        s_connected = false;
        board_set_cloud_status(false);
        ESP_LOGW(TAG, "[CLOUD] disconnected");
        break;
    case WEBSOCKET_EVENT_CLOSED:
        s_text_receiver.reset();
        s_connected = false;
        board_set_cloud_status(false);
        ESP_LOGW(TAG, "[CLOUD] disconnected");
        ESP_LOGI(TAG, "[CLOUD] reconnecting in %d ms", CLOUD_RECONNECT_TIMEOUT_MS);
        break;
    case WEBSOCKET_EVENT_DATA: {
#ifdef CONFIG_DEVICE_LAB_SPEECH_TRACE
        if (event->op_code == 0x1 || event->op_code == 0x0)
            s_fragment_clock.part(event->op_code == 0x1 && event->payload_offset == 0, esp_timer_get_time());
#endif
        if (event->op_code == 0x1 && event->payload_offset == 0) {
            s_rx_started_us = esp_timer_get_time();
            s_rx_parts = 0;
        }
        if (event->op_code == 0x1 || event->op_code == 0x0) ++s_rx_parts;
        const auto result = s_text_receiver.receive(event->op_code, event->fin, event->data_ptr,
            event->data_len, event->payload_len, event->payload_offset);
        if (result == WebSocketTextReceiver::Result::COMPLETE) {
            const int64_t now = esp_timer_get_time();
            const int64_t elapsed_ms = (now - s_rx_started_us) / 1000;
            // Trace a prolonged receive at most once per five seconds, never its payload.
            if (elapsed_ms > 250 && now - s_last_rx_trace_us > 5000000) {
                s_last_rx_trace_us = now;
                log_link_health("slow-text", s_text_receiver.size(), s_rx_parts, elapsed_ms);
            }
#ifdef CONFIG_DEVICE_LAB_SPEECH_TRACE
            s_fragment_clock.frame.complete_us = now;
            s_fragment_clock.frame.wire_bytes = s_text_receiver.size();
            handle_message(s_text_receiver.data(), s_text_receiver.size(), &s_fragment_clock.frame);
#else
            handle_message(s_text_receiver.data(), s_text_receiver.size());
#endif
        } else if (result == WebSocketTextReceiver::Result::REJECTED)
            ESP_LOGW(TAG, "[CLOUD] rejected text frame opcode=%u offset=%d bytes=%d total=%d",
                     event->op_code, event->payload_offset, event->data_len, event->payload_len);
        break;
    }
    case WEBSOCKET_EVENT_ERROR:
#ifdef CONFIG_DEVICE_LAB_SPEECH_TRACE
        trace_partial_receive();
#endif
        log_link_health("receive-error", s_text_receiver.size(), s_rx_parts,
                        (esp_timer_get_time() - s_rx_started_us) / 1000);
        s_text_receiver.reset();
        ESP_LOGE(TAG, "[CLOUD] WebSocket error");
        break;
    default:
        break;
    }
}

static void input_sender_task(void *)
{
    auto *frame = new AudioCaptureFrame{};
    auto *encoded = new unsigned char[4705];
    auto *control = new char[4096];
    uint64_t active=0, last_dropped=0;unsigned sequence=0, pcm_bytes=0;
    int64_t max_send_ms=0;bool reported_stall=false;
    while (true) {
        while (board_take_control_message(control,4096)) {
            auto *root=cJSON_Parse(control);
#ifdef CONFIG_DEVICE_LAB_SPEECH_TRANSPORT_TRACE
            const auto *type=cJSON_GetObjectItemCaseSensitive(root,"type");
            if (cJSON_IsString(type) && strcmp(type->valuestring,"speech_request")==0)
                speech_transport_trace_arm();
#endif
            if (root && !send_json(root)) {
                auto *failed=cJSON_Parse(control);
                const auto *kind=cJSON_GetObjectItemCaseSensitive(failed,"type");
                const bool speech=cJSON_IsString(kind) && strncmp(kind->valuestring,"speech_",7)==0;
                const bool setting=cJSON_IsString(kind) && strncmp(kind->valuestring,"settings_",9)==0;
                cJSON_ReplaceItemInObject(failed,"type",cJSON_CreateString(speech?"speech_error":setting?"settings_error":"voice_error"));
                cJSON_AddStringToObject(failed,"message","Connection failed; check Agent status");
                char *error=cJSON_PrintUnformatted(failed);
                if (error) {board_control_message(error);cJSON_free(error);}
                cJSON_Delete(failed);audio_backend_record_stop(true);
            }
        }
#ifdef CONFIG_DEVICE_LAB_SPEECH_TRANSPORT_TRACE
        speech_transport_trace_flush();
#endif
        if (audio_backend_take_capture(frame)) {
            char id[17];snprintf(id,sizeof(id),"%016llx",static_cast<unsigned long long>(frame->id));
            if (!s_connected || !board_voice_active(frame->id)) {
                if (last_dropped!=frame->id) {
                    ESP_LOGI(TAG,"[VOICE] tx-drop id=%s reason=%s kind=%d",id,s_connected?"inactive-modal":"cloud-disconnect",static_cast<int>(frame->kind));
                    last_dropped=frame->id;
                }
                audio_backend_record_stop(true);active=0;continue;
            }
            auto *root=cJSON_CreateObject();
            cJSON_AddStringToObject(root,"voice_id",id);
            if (frame->kind==CaptureKind::START) {
                active=frame->id;sequence=0;pcm_bytes=0;max_send_ms=0;reported_stall=false;
                cJSON_AddStringToObject(root,"type","voice_start");
                cJSON_AddNumberToObject(root,"sample_rate",44100);
                cJSON_AddNumberToObject(root,"bits",16);cJSON_AddNumberToObject(root,"channels",1);
            } else if (frame->kind==CaptureKind::PCM && frame->id==active) {
                size_t count=0;
                mbedtls_base64_encode(encoded,4705,&count,frame->pcm,frame->size);encoded[count]=0;
                cJSON_AddStringToObject(root,"type","voice_chunk");
                cJSON_AddNumberToObject(root,"sequence",sequence++);
                pcm_bytes+=frame->size;
                cJSON_AddStringToObject(root,"audio",reinterpret_cast<char *>(encoded));
            } else if (frame->kind==CaptureKind::END || frame->kind==CaptureKind::CANCEL) {
                cJSON_AddStringToObject(root,"type",frame->kind==CaptureKind::END?"voice_end":"voice_cancel");
                board_capture_finished(frame->id,frame->kind==CaptureKind::CANCEL);active=0;
            } else {cJSON_Delete(root);continue;}
            const char *wire_type=frame->kind==CaptureKind::START?"voice_start":frame->kind==CaptureKind::PCM?"voice_chunk":frame->kind==CaptureKind::END?"voice_end":"voice_cancel";
            const int64_t send_started=esp_timer_get_time();
            const bool sent=send_json(root);
            const int64_t send_ms=(esp_timer_get_time()-send_started)/1000;
            if (send_ms>max_send_ms) max_send_ms=send_ms;
            if (send_ms>250 && !reported_stall) {
                ESP_LOGI(TAG,"[VOICE] tx-stall type=%s id=%s elapsed_ms=%lld internal_free=%u dma_largest=%u",wire_type,id,static_cast<long long>(send_ms),
                         static_cast<unsigned>(heap_caps_get_free_size(MALLOC_CAP_INTERNAL)),
                         static_cast<unsigned>(heap_caps_get_largest_free_block(MALLOC_CAP_DMA)));
                reported_stall=true;
            }
            if (frame->kind!=CaptureKind::PCM || sequence==1 || !sent)
                ESP_LOGI(TAG,"[VOICE] tx type=%s id=%s sent=%d chunks=%u pcm_bytes=%u max_send_ms=%lld",wire_type,id,sent,sequence,pcm_bytes,static_cast<long long>(max_send_ms));
            if (!sent) {
                audio_backend_record_stop(true);board_capture_finished(frame->id,true);
                auto *cancel=cJSON_CreateObject();cJSON_AddStringToObject(cancel,"type","voice_cancel");
                cJSON_AddStringToObject(cancel,"voice_id",id);send_json(cancel);active=0;
            }
        } else vTaskDelay(input_sender_idle_ticks(pdMS_TO_TICKS(5)));
    }
}

static void cloud_task(void *)
{
    xEventGroupWaitBits(wifi_manager_event_group(), WIFI_CONNECTED_BIT, pdFALSE, pdTRUE, portMAX_DELAY);

    esp_websocket_client_config_t config = {};
    config.uri = CONFIG_DEVICE_LAB_CLOUD_WS_URL;
    config.network_timeout_ms = 10000;
    config.reconnect_timeout_ms = CLOUD_RECONNECT_TIMEOUT_MS;
    config.buffer_size = CLOUD_RX_BUFFER_BYTES;
    config.enable_close_reconnect = true;
#if defined(CONFIG_DEVICE_LAB_SPEECH_TRACE) && defined(CONFIG_ESP_WS_CLIENT_SEPARATE_TX_LOCK)
    ESP_LOGI(TAG, "[CLOUD] separate_tx_lock=1 timeout_ms=%d", CONFIG_ESP_WS_CLIENT_TX_LOCK_TIMEOUT_MS);
#endif
    if (strncmp(config.uri, "wss://", 6) == 0) {
        config.crt_bundle_attach = esp_crt_bundle_attach;
    }

    ESP_LOGI(TAG, "[CLOUD] connecting to %s", config.uri);
    s_client = esp_websocket_client_init(&config);
    if (s_client == nullptr) {
        ESP_LOGE(TAG, "[CLOUD] client allocation failed");
        vTaskDelete(nullptr);
        return;
    }
    esp_websocket_register_events(s_client, WEBSOCKET_EVENT_ANY, websocket_event, nullptr);
    ESP_ERROR_CHECK(esp_websocket_client_start(s_client));

    while (true) {
        vTaskDelay(pdMS_TO_TICKS(CONFIG_DEVICE_LAB_HEARTBEAT_SECONDS * 1000));
        if (s_connected) {
            cJSON *root = cJSON_CreateObject();
            cJSON_AddStringToObject(root, "type", "heartbeat");
            cJSON_AddNumberToObject(root, "uptime", esp_timer_get_time() / 1000000);
            if (send_json(root)) {
                ESP_LOGI(TAG, "[CLOUD] sent heartbeat");
            }
        }
    }
}

esp_err_t cloud_client_start(void)
{
#ifdef CONFIG_DEVICE_LAB_SPEECH_TRANSPORT_TRACE
    ESP_LOGI(TAG,"[TRANSPORT_RX_CONFIG] enabled=1 window_ms=120000 slow_ms=250 sample_every=32 buffered_events=16 payload_logging=0");
#endif
#ifdef CONFIG_DEVICE_LAB_SPEECH_TRACE
    ESP_LOGI(TAG,"[SPEECH_RX_CONFIG] enabled=1 sample_every=8 slow_frame_ms=250 slow_gap_ms=250 queue_threshold=6");
#endif
    auto *text_storage = static_cast<char *>(heap_caps_malloc(CLOUD_RX_BUFFER_BYTES + 1,
        MALLOC_CAP_SPIRAM | MALLOC_CAP_8BIT));
    if (!text_storage) return ESP_ERR_NO_MEM;
    s_text_receiver.set_storage(text_storage, CLOUD_RX_BUFFER_BYTES);
    uint8_t mac[6] = {};
    ESP_RETURN_ON_ERROR(esp_read_mac(mac, ESP_MAC_WIFI_STA), TAG, "read MAC");
    snprintf(s_device_id, sizeof(s_device_id), "esp32s3-%02x%02x%02x%02x%02x%02x",
             mac[0], mac[1], mac[2], mac[3], mac[4], mac[5]);
    board_set_device_id(s_device_id);
    if (xTaskCreate(input_sender_task,"input_sender",8192,nullptr,5,nullptr)!=pdPASS) return ESP_ERR_NO_MEM;
    return xTaskCreate(cloud_task, "cloud", 8192, nullptr, 5, nullptr) == pdPASS
        ? ESP_OK
        : ESP_ERR_NO_MEM;
}

bool cloud_client_send_button(const char *button)
{
    cJSON *root = cJSON_CreateObject();
    cJSON_AddStringToObject(root, "type", "button_event");
    cJSON_AddStringToObject(root, "button", button);
    cJSON_AddStringToObject(root, "action", "click");
    cJSON_AddNumberToObject(root, "timestamp", esp_timer_get_time() / 1000);
    bool sent = send_json(root);
    ESP_LOGI(TAG, "[CLOUD] %s button_event button=%s", sent ? "sent" : "did not send", button);
    return sent;
}

bool cloud_client_send_approval(const char *approval_id, bool approve)
{
    cJSON *root = cJSON_CreateObject();
    cJSON_AddStringToObject(root, "type", "approval_response");
    cJSON_AddStringToObject(root, "approval_id", approval_id);
    cJSON_AddStringToObject(root, "decision", approve ? "approve" : "reject");
    return send_json(root); // No retry queue: stale decisions are never replayed.
}
