// cmd_exec.c — BOX-3 executor for structured ESP-NOW CMD frames (D5 contract).
// See cmd_exec.h for the wire format spec.
//
// Objective 1 — screen: full-scene faithful rendering
//   Palette: bg #0055AA / symbol #FF8800 / title #FFFFFF / subtitle #AAAAAA
//   Effects:  pulse  (opacity breathing on symbol via LVGL anim)
//             glitch (rapid opacity flicker on title via lv_timer)
//             gyro   (rotating arc ring around symbol)
//             none   (static)
//
// Objective 2 — play: real WAV streaming to I2S
//   1. Mount SD card (best-effort, /sdcard). Parse WAV header (PCM-16).
//   2. Stream PCM samples via the ES8311 codec (esp_codec_dev), same path as TTS.
//   3. If SD absent / file not found → fallback bip + log.
//   4. Embedded test cue always available at virtual path "embedded://" or
//      when the requested path starts with "embedded:" — proves WAV decode + I2S.

#include "cmd_exec.h"

#include <string.h>
#include <stdio.h>
#include <stdint.h>
#include <inttypes.h>

#include "freertos/FreeRTOS.h"
#include "freertos/task.h"

#include "bsp/esp-bsp.h"
#include "cJSON.h"
#include "esp_log.h"
#include "esp_vfs_fat.h"
#include "lvgl.h"

#include "embedded_wav.h"   // s_embedded_wav[], EMBEDDED_WAV_SIZE

// audio_play_tone is defined in main.c (writes through the ES8311 codec).
extern void audio_play_tone(float frequency, int duration_ms);

// Speaker codec (ES8311) handle — obtained from main.c after speaker_init().
// Audio is streamed through esp_codec_dev so the ES8311 DAC is actually driven
// (a raw I2S write leaves the codec muted).
#include "esp_codec_dev.h"
static esp_codec_dev_handle_t s_spk_handle_local = NULL;

void cmd_exec_set_spk_handle(esp_codec_dev_handle_t h)
{
    s_spk_handle_local = h;
}

#define TAG "cmd_exec"

// ─── Palette (matches idf_zacus/components/display_ui reference) ─────────────
#define COL_BG      0x0055AA  // Workbench blue
#define COL_SYMBOL  0xFF8800  // Workbench orange
#define COL_TITLE   0xFFFFFF  // white
#define COL_SUB     0xAAAAAA  // mid grey

#define SCREEN_LOCK_MS 1000

// ─── Screen overlay state ─────────────────────────────────────────────────────

static lv_obj_t *s_overlay    = NULL;
static lv_obj_t *s_title_lbl  = NULL;
static lv_obj_t *s_sub_lbl    = NULL;
static lv_obj_t *s_sym_lbl    = NULL;
static lv_obj_t *s_gyro_arc   = NULL;

// Active animations / timers (so we can stop them on next call)
static lv_timer_t *s_glitch_timer = NULL;

// ─── Effect callbacks ─────────────────────────────────────────────────────────

static void pulse_anim_cb(void *obj, int32_t v)
{
    lv_obj_set_style_opa((lv_obj_t *)obj, (lv_opa_t)v, 0);
}

static void gyro_anim_cb(void *obj, int32_t v)
{
    lv_arc_set_end_angle((lv_obj_t *)obj, (uint16_t)(v % 360));
}

static void glitch_timer_cb(lv_timer_t *t)
{
    (void)t;
    static bool flip = false;
    flip = !flip;
    lv_obj_set_style_opa(s_title_lbl,
                         flip ? LV_OPA_20 : LV_OPA_COVER, 0);
    // Slight X jitter: move label 0 or 3 pixels right alternately
    lv_obj_set_x(s_title_lbl, flip ? 3 : 0);
}

// ─── Stop all active effects ──────────────────────────────────────────────────

static void stop_effects_locked(void)
{
    // Stop pulse animation on symbol
    lv_anim_del(s_sym_lbl, pulse_anim_cb);
    if (s_sym_lbl) {
        lv_obj_set_style_opa(s_sym_lbl, LV_OPA_COVER, 0);
    }

    // Stop glitch timer
    if (s_glitch_timer) {
        lv_timer_pause(s_glitch_timer);
    }
    if (s_title_lbl) {
        lv_obj_set_style_opa(s_title_lbl, LV_OPA_COVER, 0);
        lv_obj_set_x(s_title_lbl, 0);
    }

    // Stop gyro arc
    if (s_gyro_arc) {
        lv_anim_del(s_gyro_arc, gyro_anim_cb);
        lv_obj_add_flag(s_gyro_arc, LV_OBJ_FLAG_HIDDEN);
    }
}

// ─── Build overlay (lazy, first CMD only) ────────────────────────────────────

static void build_overlay_locked(void)
{
    if (s_overlay) return;

    lv_obj_t *scr = lv_screen_active();

    // Full-screen panel with Workbench blue background
    s_overlay = lv_obj_create(scr);
    lv_obj_set_size(s_overlay, LV_PCT(100), LV_PCT(100));
    lv_obj_set_pos(s_overlay, 0, 0);
    lv_obj_set_style_bg_color(s_overlay, lv_color_hex(COL_BG), 0);
    lv_obj_set_style_bg_opa(s_overlay, LV_OPA_COVER, 0);
    lv_obj_set_style_border_width(s_overlay, 0, 0);
    lv_obj_set_style_radius(s_overlay, 0, 0);
    lv_obj_set_style_pad_all(s_overlay, 10, 0);

    // Title — top, white, font 24
    s_title_lbl = lv_label_create(s_overlay);
    lv_label_set_text(s_title_lbl, "");
    lv_obj_set_style_text_font(s_title_lbl, &lv_font_montserrat_24, 0);
    lv_obj_set_style_text_color(s_title_lbl, lv_color_hex(COL_TITLE), 0);
    lv_label_set_long_mode(s_title_lbl, LV_LABEL_LONG_WRAP);
    lv_obj_set_width(s_title_lbl, LV_PCT(90));
    lv_obj_set_style_text_align(s_title_lbl, LV_TEXT_ALIGN_CENTER, 0);
    lv_obj_align(s_title_lbl, LV_ALIGN_TOP_MID, 0, 14);

    // Symbol — center, orange, font 48
    s_sym_lbl = lv_label_create(s_overlay);
    lv_label_set_text(s_sym_lbl, "");
    lv_obj_set_style_text_font(s_sym_lbl, &lv_font_montserrat_48, 0);
    lv_obj_set_style_text_color(s_sym_lbl, lv_color_hex(COL_SYMBOL), 0);
    lv_obj_align(s_sym_lbl, LV_ALIGN_CENTER, 0, -10);

    // Gyro arc: hidden rotating ring around symbol (gyro effect)
    s_gyro_arc = lv_arc_create(s_overlay);
    lv_obj_set_size(s_gyro_arc, 90, 90);
    lv_obj_align(s_gyro_arc, LV_ALIGN_CENTER, 0, -10);
    lv_arc_set_rotation(s_gyro_arc, 270);
    lv_arc_set_bg_angles(s_gyro_arc, 0, 360);
    lv_obj_set_style_arc_color(s_gyro_arc, lv_color_hex(COL_SYMBOL), LV_PART_INDICATOR);
    lv_obj_set_style_arc_width(s_gyro_arc, 4, LV_PART_INDICATOR);
    lv_obj_set_style_arc_opa(s_gyro_arc, LV_OPA_TRANSP, LV_PART_MAIN);
    lv_obj_set_style_bg_opa(s_gyro_arc, LV_OPA_TRANSP, 0);
    lv_obj_set_style_border_width(s_gyro_arc, 0, 0);
    lv_obj_add_flag(s_gyro_arc, LV_OBJ_FLAG_HIDDEN);

    // Subtitle — bottom, grey, font 14
    s_sub_lbl = lv_label_create(s_overlay);
    lv_label_set_text(s_sub_lbl, "");
    lv_obj_set_style_text_font(s_sub_lbl, &lv_font_montserrat_14, 0);
    lv_obj_set_style_text_color(s_sub_lbl, lv_color_hex(COL_SUB), 0);
    lv_label_set_long_mode(s_sub_lbl, LV_LABEL_LONG_WRAP);
    lv_obj_set_width(s_sub_lbl, LV_PCT(90));
    lv_obj_set_style_text_align(s_sub_lbl, LV_TEXT_ALIGN_CENTER, 0);
    lv_obj_align(s_sub_lbl, LV_ALIGN_BOTTOM_MID, 0, -14);

    // Glitch timer: created paused, resumed by glitch effect
    s_glitch_timer = lv_timer_create(glitch_timer_cb, 120, NULL);
    lv_timer_pause(s_glitch_timer);
}

// ─── op=screen ────────────────────────────────────────────────────────────────

static esp_err_t exec_screen(const cJSON *a)
{
    const cJSON *title = a ? cJSON_GetObjectItemCaseSensitive(a, "t") : NULL;
    const cJSON *sub   = a ? cJSON_GetObjectItemCaseSensitive(a, "u") : NULL;
    const cJSON *sym   = a ? cJSON_GetObjectItemCaseSensitive(a, "y") : NULL;
    const cJSON *eff   = a ? cJSON_GetObjectItemCaseSensitive(a, "e") : NULL;

    const char *t_str = cJSON_IsString(title) ? title->valuestring : "";
    const char *u_str = cJSON_IsString(sub)   ? sub->valuestring   : "";
    const char *y_str = cJSON_IsString(sym)   ? sym->valuestring   : "";
    const char *e_str = cJSON_IsString(eff)   ? eff->valuestring   : "none";

    ESP_LOGI(TAG, "CMD op=screen t=\"%s\" u=\"%s\" y=\"%s\" e=\"%s\"",
             t_str, u_str, y_str, e_str);

    if (!bsp_display_lock(SCREEN_LOCK_MS)) {
        ESP_LOGW(TAG, "screen: display lock timeout");
        return ESP_FAIL;
    }

    build_overlay_locked();
    stop_effects_locked();

    // Update labels
    lv_label_set_text(s_title_lbl, t_str);
    lv_label_set_text(s_sym_lbl,   y_str);
    lv_label_set_text(s_sub_lbl,   u_str);

    // Re-align after text update
    lv_obj_align(s_title_lbl, LV_ALIGN_TOP_MID, 0, 14);
    lv_obj_align(s_sym_lbl,   LV_ALIGN_CENTER,  0, -10);
    lv_obj_align(s_sub_lbl,   LV_ALIGN_BOTTOM_MID, 0, -14);

    // Bring overlay to front
    lv_obj_move_foreground(s_overlay);

    // Apply effect
    if (strcmp(e_str, "pulse") == 0) {
        // Opacity breathing: COVER → 50 → COVER, 600ms per half
        lv_anim_t a;
        lv_anim_init(&a);
        lv_anim_set_var(&a, s_sym_lbl);
        lv_anim_set_exec_cb(&a, pulse_anim_cb);
        lv_anim_set_values(&a, LV_OPA_COVER, LV_OPA_50);
        lv_anim_set_time(&a, 600);
        lv_anim_set_playback_time(&a, 600);
        lv_anim_set_repeat_count(&a, LV_ANIM_REPEAT_INFINITE);
        lv_anim_start(&a);
        ESP_LOGI(TAG, "screen: pulse effect started");

    } else if (strcmp(e_str, "glitch") == 0) {
        // Rapid opacity + X-jitter on title
        if (s_glitch_timer) {
            lv_timer_resume(s_glitch_timer);
        }
        ESP_LOGI(TAG, "screen: glitch effect started");

    } else if (strcmp(e_str, "gyro") == 0) {
        // Rotating arc ring around the symbol
        if (s_gyro_arc) {
            lv_obj_clear_flag(s_gyro_arc, LV_OBJ_FLAG_HIDDEN);
            lv_anim_t a;
            lv_anim_init(&a);
            lv_anim_set_var(&a, s_gyro_arc);
            lv_anim_set_exec_cb(&a, gyro_anim_cb);
            lv_anim_set_values(&a, 0, 360);
            lv_anim_set_time(&a, 1200);
            lv_anim_set_repeat_count(&a, LV_ANIM_REPEAT_INFINITE);
            lv_anim_start(&a);
        }
        ESP_LOGI(TAG, "screen: gyro effect started");

    } else {
        // "none" or unrecognised → static
        ESP_LOGI(TAG, "screen: static (no effect)");
    }

    bsp_display_unlock();
    return ESP_OK;
}

// ─── WAV player ───────────────────────────────────────────────────────────────
//
// WAV header layout (canonical PCM, 44 bytes):
//   RIFF header : 12 bytes  (RIFF, chunk size, WAVE)
//   fmt  chunk  : 24 bytes  (fmt , 16, pcm=1, channels, rate, byte_rate, align, bits)
//   data chunk  : 8 bytes   (data, data_size)
//   PCM samples : data_size bytes

#define WAV_HDR_RIFF_OFS   0
#define WAV_HDR_FMT_OFS    12
#define WAV_HDR_DATA_OFS   36
#define WAV_MIN_HEADER     44

typedef struct {
    uint16_t audio_format;
    uint16_t channels;
    uint32_t sample_rate;
    uint32_t byte_rate;
    uint16_t block_align;
    uint16_t bits_per_sample;
    uint32_t data_offset;   // offset of PCM data in the buffer/file
    uint32_t data_size;
} wav_info_t;

static esp_err_t parse_wav_header(const uint8_t *buf, size_t buf_len, wav_info_t *out)
{
    if (buf_len < WAV_MIN_HEADER) return ESP_ERR_INVALID_SIZE;

    // RIFF
    if (memcmp(buf, "RIFF", 4) != 0) return ESP_ERR_INVALID_ARG;
    if (memcmp(buf + 8, "WAVE", 4) != 0) return ESP_ERR_INVALID_ARG;

    // Walk chunks to find fmt and data (handles non-standard ordering / extra chunks)
    size_t pos = 12;
    bool got_fmt = false, got_data = false;

    while (pos + 8 <= buf_len && !(got_fmt && got_data)) {
        uint32_t chunk_size;
        memcpy(&chunk_size, buf + pos + 4, 4);

        if (memcmp(buf + pos, "fmt ", 4) == 0 && chunk_size >= 16) {
            memcpy(&out->audio_format,   buf + pos + 8,  2);
            memcpy(&out->channels,       buf + pos + 10, 2);
            memcpy(&out->sample_rate,    buf + pos + 12, 4);
            memcpy(&out->byte_rate,      buf + pos + 16, 4);
            memcpy(&out->block_align,    buf + pos + 20, 2);
            memcpy(&out->bits_per_sample,buf + pos + 22, 2);
            got_fmt = true;
        } else if (memcmp(buf + pos, "data", 4) == 0) {
            out->data_offset = (uint32_t)(pos + 8);
            out->data_size   = chunk_size;
            got_data = true;
        }
        pos += 8 + chunk_size;
    }

    if (!got_fmt || !got_data) return ESP_ERR_NOT_FOUND;
    if (out->audio_format != 1) {
        ESP_LOGW(TAG, "WAV format %d is not PCM (1) — unsupported", out->audio_format);
        return ESP_ERR_NOT_SUPPORTED;
    }

    return ESP_OK;
}

// Stream raw PCM-16 samples to the speaker I2S channel.
// Handles any sample rate (simple rate-adaptation via repeat/skip is NOT done here;
// the test WAV and typical cues should be 16 kHz mono = native rate).
static esp_err_t stream_pcm16(const uint8_t *pcm, size_t byte_len,
                               bool loop, int max_loops)
{
    if (!s_spk_handle_local) {
        ESP_LOGW(TAG, "play: no speaker handle — skip stream");
        return ESP_ERR_INVALID_STATE;
    }

    int loops = 0;
    do {
        size_t offset = 0;
        while (offset < byte_len) {
            size_t chunk = (byte_len - offset < 1024) ? (byte_len - offset) : 1024;
            int ret = esp_codec_dev_write(s_spk_handle_local,
                                          (void *)(pcm + offset), (int)chunk);
            if (ret != ESP_CODEC_DEV_OK) {
                ESP_LOGW(TAG, "codec write error: %d", ret);
                return ESP_FAIL;
            }
            offset += chunk;
        }
        loops++;
    } while (loop && (max_loops == 0 || loops < max_loops));

    return ESP_OK;
}

// ─── SD card mount (best-effort, guarded) ────────────────────────────────────

static bool s_sd_mounted = false;

static void ensure_sd_mounted(void)
{
    if (s_sd_mounted) return;
    ESP_LOGI(TAG, "play: attempting SD card mount...");
    esp_err_t ret = bsp_sdcard_mount();
    if (ret == ESP_OK) {
        s_sd_mounted = true;
        ESP_LOGI(TAG, "play: SD card mounted at %s", BSP_SD_MOUNT_POINT);
    } else {
        ESP_LOGW(TAG, "play: SD card mount failed: %s (will use fallback)", esp_err_to_name(ret));
    }
}

// ─── play task (off main task to allow loop without blocking CMD recv) ────────

typedef struct {
    char path[128];
    bool loop;
} play_args_t;

static void play_task(void *arg)
{
    play_args_t *pargs = (play_args_t *)arg;
    char path[128];
    bool loop = pargs->loop;
    strncpy(path, pargs->path, sizeof(path) - 1);
    path[sizeof(path) - 1] = '\0';
    free(pargs);

    ESP_LOGI(TAG, "play_task: path=\"%s\" loop=%d", path, loop);

    // Check for embedded asset path
    bool use_embedded = (strncmp(path, "embedded:", 9) == 0 ||
                         strcmp(path, "<none>") == 0 ||
                         strlen(path) == 0);

    if (!use_embedded) {
        // Try SD card
        ensure_sd_mounted();
        if (!s_sd_mounted) {
            ESP_LOGW(TAG, "play: SD not mounted, falling back to embedded asset");
            use_embedded = true;
        }
    }

    if (!use_embedded) {
        // Build full path if not absolute
        char full_path[160];
        if (path[0] == '/') {
            snprintf(full_path, sizeof(full_path), "%s", path);
        } else {
            snprintf(full_path, sizeof(full_path), "%s/%s", BSP_SD_MOUNT_POINT, path);
        }

        FILE *f = fopen(full_path, "rb");
        if (!f) {
            ESP_LOGW(TAG, "play: file not found: %s — falling back to embedded", full_path);
            use_embedded = true;
        } else {
            // Read WAV from file
            fseek(f, 0, SEEK_END);
            long fsize = ftell(f);
            fseek(f, 0, SEEK_SET);

            if (fsize < WAV_MIN_HEADER || fsize > 512 * 1024) {
                ESP_LOGW(TAG, "play: file size %ld bytes — out of range, fallback", fsize);
                fclose(f);
                use_embedded = true;
            } else {
                uint8_t *fbuf = malloc((size_t)fsize);
                if (!fbuf) {
                    ESP_LOGW(TAG, "play: alloc failed for %ld bytes — fallback", fsize);
                    fclose(f);
                    use_embedded = true;
                } else {
                    size_t nread = fread(fbuf, 1, (size_t)fsize, f);
                    fclose(f);

                    wav_info_t wi;
                    esp_err_t ret = parse_wav_header(fbuf, nread, &wi);
                    if (ret != ESP_OK) {
                        ESP_LOGW(TAG, "play: WAV parse error %s — fallback", esp_err_to_name(ret));
                        free(fbuf);
                        use_embedded = true;
                    } else {
                        ESP_LOGI(TAG, "play: WAV ok — %" PRIu32 " Hz %" PRIu16 "-bit %" PRIu16 " ch, %" PRIu32 " bytes PCM",
                                 wi.sample_rate, wi.bits_per_sample,
                                 wi.channels, wi.data_size);
                        if (wi.bits_per_sample != 16) {
                            ESP_LOGW(TAG, "play: %" PRIu16 "-bit WAV — only 16-bit supported, fallback",
                                     wi.bits_per_sample);
                            free(fbuf);
                            use_embedded = true;
                        } else {
                            // Real streaming from file
                            stream_pcm16(fbuf + wi.data_offset, wi.data_size, loop, 3);
                            free(fbuf);
                            use_embedded = false;
                        }
                    }
                }
            }
        }
    }

    if (use_embedded) {
        // Play embedded C5-E5-G5 cue (proves WAV decode + I2S path)
        wav_info_t wi;
        esp_err_t ret = parse_wav_header(s_embedded_wav, EMBEDDED_WAV_SIZE, &wi);
        if (ret == ESP_OK && wi.bits_per_sample == 16) {
            ESP_LOGI(TAG, "play: streaming embedded cue (%" PRIu32 " Hz %" PRIu16 "-bit, %" PRIu32 " bytes PCM)",
                     wi.sample_rate, wi.bits_per_sample, wi.data_size);
            stream_pcm16(s_embedded_wav + wi.data_offset, wi.data_size, false, 1);
        } else {
            // Last-resort: beep
            ESP_LOGW(TAG, "play: embedded WAV parse error %s — beep fallback",
                     esp_err_to_name(ret));
            audio_play_tone(880.0f, 200);
        }
    }

    vTaskDelete(NULL);
}

// ─── Gamebook narration player (async, streamed, interruptible) ──────────────
// One persistent task plays one WAV at a time, streaming straight from the SD
// in small chunks (no full-file malloc → any length, RAM-safe). A new request
// or a stop flag breaks the stream so tapping a choice cuts the narration.

static TaskHandle_t   s_gb_task = NULL;
static char           s_gb_path[160];
static volatile bool  s_gb_new  = false;
static volatile bool  s_gb_stop = false;

static void gb_play_task(void *arg)
{
    (void) arg;
    /* The task is a singleton (guarded by s_gb_task), so these large buffers
     * are kept static — on the stack they ate ~2.1 KB and, together with the
     * FATFS fread internals + i2s + logging, overflowed the 4 KB stack and
     * rebooted the board when a story started. */
    static uint8_t hdr[64];
    static uint8_t buf[2048];
    for (;;) {
        if (!s_gb_new) { vTaskDelay(pdMS_TO_TICKS(20)); continue; }
        char path[160];
        strncpy(path, s_gb_path, sizeof(path) - 1);
        path[sizeof(path) - 1] = '\0';
        s_gb_new  = false;
        s_gb_stop = false;

        /* The SD is normally already mounted (the gamebook mounts it at init).
         * Try the file directly first — re-mounting an already-mounted card via
         * bsp_sdcard_mount() FAILS and tears down the existing VFS, which made
         * the very first narration clip silent. Only mount if the open fails. */
        FILE *f = fopen(path, "rb");
        if (!f) {
            ensure_sd_mounted();
            f = fopen(path, "rb");
        }
        if (!f) { ESP_LOGW(TAG, "gb: open %s failed", path); continue; }
        size_t hn = fread(hdr, 1, sizeof(hdr), f);
        wav_info_t wi;
        if (parse_wav_header(hdr, hn, &wi) != ESP_OK || wi.bits_per_sample != 16) {
            ESP_LOGW(TAG, "gb: bad/usupported WAV %s", path);
            fclose(f);
            continue;
        }
        fseek(f, wi.data_offset, SEEK_SET);
        /* Wake the codec/PA before the real audio: after idle the ES8311 mutes
         * and ramps up on the first write, swallowing the start of the clip.
         * A short silent lead-in lets the unmute settle so the narration plays
         * from the very first word. */
        memset(buf, 0, sizeof(buf));
        for (int i = 0; i < 4 && s_spk_handle_local && !s_gb_stop && !s_gb_new; i++) {
            esp_codec_dev_write(s_spk_handle_local, buf, (int)sizeof(buf));
        }
        size_t remaining = wi.data_size;
        ESP_LOGI(TAG, "gb: play %s (%u B, %u Hz)", path,
                 (unsigned)wi.data_size, (unsigned)wi.sample_rate);
        while (remaining > 0 && !s_gb_stop && !s_gb_new) {
            size_t want = remaining < sizeof(buf) ? remaining : sizeof(buf);
            size_t got = fread(buf, 1, want, f);
            if (got == 0) break;
            if (s_spk_handle_local) {
                esp_codec_dev_write(s_spk_handle_local, buf, (int)got);
            }
            remaining -= got;
        }
        fclose(f);
    }
}

void cmd_exec_play_file_async(const char *path)
{
    if (!path || !path[0]) return;
    strncpy(s_gb_path, path, sizeof(s_gb_path) - 1);
    s_gb_path[sizeof(s_gb_path) - 1] = '\0';
    s_gb_stop = true;    // interrupt whatever is playing
    s_gb_new  = true;    // … and pick up the new clip
    if (!s_gb_task) {
        xTaskCreate(gb_play_task, "gb_play", 6144, NULL, 4, &s_gb_task);
    }
}

void cmd_exec_stop_play(void)
{
    s_gb_stop = true;
    s_gb_new  = false;
}

// ─── op=play ──────────────────────────────────────────────────────────────────

static esp_err_t exec_play(const cJSON *a)
{
    const cJSON *path_j = a ? cJSON_GetObjectItemCaseSensitive(a, "p") : NULL;
    const cJSON *loop_j = a ? cJSON_GetObjectItemCaseSensitive(a, "l") : NULL;
    const char *path = cJSON_IsString(path_j) ? path_j->valuestring : "embedded://cue";
    bool loop        = cJSON_IsNumber(loop_j)  ? ((int)loop_j->valuedouble != 0) : false;

    ESP_LOGI(TAG, "CMD op=play path=\"%s\" loop=%d", path, loop);

    play_args_t *pargs = malloc(sizeof(play_args_t));
    if (!pargs) {
        ESP_LOGW(TAG, "play: alloc failed — beep fallback");
        audio_play_tone(880.0f, 200);
        return ESP_ERR_NO_MEM;
    }
    strncpy(pargs->path, path, sizeof(pargs->path) - 1);
    pargs->path[sizeof(pargs->path) - 1] = '\0';
    pargs->loop = loop;

    // Spawn a dedicated task (4 KB stack) so we don't block the CMD receive path.
    // Priority 4 = same as mic_capture.
    if (xTaskCreate(play_task, "cmd_play", 4096, pargs, 4, NULL) != pdPASS) {
        ESP_LOGW(TAG, "play: task create failed — beep fallback");
        free(pargs);
        audio_play_tone(880.0f, 200);
        return ESP_FAIL;
    }

    return ESP_OK;
}

// ─── op=evt ───────────────────────────────────────────────────────────────────

static esp_err_t exec_evt(const cJSON *a)
{
    const cJSON *name_j = a ? cJSON_GetObjectItemCaseSensitive(a, "n") : NULL;
    const char *name = cJSON_IsString(name_j) ? name_j->valuestring : "<none>";
    ESP_LOGI(TAG, "CMD op=evt n=\"%s\" (evt received on BOX-3 — logging only)", name);
    return ESP_OK;
}

// ─── op=led ───────────────────────────────────────────────────────────────────

static esp_err_t exec_led(const cJSON *a)
{
    const cJSON *pat_j = a ? cJSON_GetObjectItemCaseSensitive(a, "p") : NULL;
    const char *pat = cJSON_IsString(pat_j) ? pat_j->valuestring : "<none>";
    ESP_LOGI(TAG, "CMD op=led pattern=\"%s\" (stub — no LED driver in box3_voice)", pat);
    return ESP_OK;
}

// ─── public entry point ───────────────────────────────────────────────────────

esp_err_t cmd_exec_handle(const char *data, size_t len)
{
    if (!data || len == 0) return ESP_ERR_INVALID_ARG;

    cJSON *root = cJSON_ParseWithLength(data, len);
    if (!root) {
        ESP_LOGW(TAG, "CMD parse error: not valid JSON (%.40s...)", data);
        return ESP_ERR_INVALID_ARG;
    }

    const cJSON *op_j = cJSON_GetObjectItemCaseSensitive(root, "op");
    if (!cJSON_IsString(op_j) || !op_j->valuestring || !op_j->valuestring[0]) {
        ESP_LOGW(TAG, "CMD missing or invalid 'op' field");
        cJSON_Delete(root);
        return ESP_ERR_INVALID_ARG;
    }

    const char  *op = op_j->valuestring;
    const cJSON *a  = cJSON_GetObjectItemCaseSensitive(root, "a");

    esp_err_t err;
    if (strcmp(op, "screen") == 0) {
        err = exec_screen(a);
    } else if (strcmp(op, "play") == 0) {
        err = exec_play(a);
    } else if (strcmp(op, "evt") == 0) {
        err = exec_evt(a);
    } else if (strcmp(op, "led") == 0) {
        err = exec_led(a);
    } else {
        ESP_LOGW(TAG, "CMD op=\"%s\" unknown — ignored", op);
        err = ESP_ERR_NOT_SUPPORTED;
    }

    cJSON_Delete(root);
    return err;
}
