/*
 * audio.c — I2S + ES8388 audio driver for the PLIP voice annex (Phase A/D).
 *
 * Init order:
 *   1. es8388_init()       — I2C master + codec register sequence
 *   2. i2s_new_channel()   — allocate TX (speaker) + RX (mic) channels
 *   3. i2s_channel_init_std_mode() — configure Philips 16-bit stereo @ 16kHz
 *   4. i2s_channel_enable()
 *   5. gpio PA_ENABLE = 1  (already done by es8388_init)
 *
 * Threading:
 *   - audio_play_tone() is blocking — call from a task, not the I2S callback.
 *   - audio_play_async() posts to a FreeRTOS queue; a worker task drains it.
 *   - The I2S handle is published via audio_spk_handle() for cmd_exec.
 */

#include "audio.h"
#include "board_config.h"
#include "es8388.h"
#include "phone.h"
#include "slic.h"

#include <inttypes.h>
#include <math.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>

#include "driver/gpio.h"
#include "esp_heap_caps.h"
#include "esp_log.h"
#include "esp_spiffs.h"
#include "esp_vfs_fat.h"
#include "driver/i2s_std.h"
#include "driver/sdspi_host.h"
#include "driver/spi_common.h"
#include "sdmmc_cmd.h"
#include "freertos/FreeRTOS.h"
#include "freertos/queue.h"
#include "freertos/task.h"

#define TAG "audio"

/* ── Audio parameters ────────────────────────────────────────────────────── */
#define SAMPLE_RATE     PLIP_SAMPLE_RATE     /* 16000 */
#define BITS            PLIP_BITS_PER_SAMPLE /* 16 */
#define CHANNELS        PLIP_CHANNELS        /* 2 = stereo (ES8388 I2S) */

/* ── WAV header ──────────────────────────────────────────────────────────── */
#define WAV_MIN_HEADER  44

typedef struct {
    uint16_t audio_format;
    uint16_t channels;
    uint32_t sample_rate;
    uint16_t bits_per_sample;
    uint32_t data_offset;
    uint32_t data_size;
} wav_info_t;

/* ── Async play queue ────────────────────────────────────────────────────── */
typedef enum { CMD_PLAY, CMD_STOP, CMD_RING } audio_cmd_kind_t;

typedef struct {
    audio_cmd_kind_t kind;
    char path[160];
} audio_cmd_t;

static QueueHandle_t      s_queue;
static i2s_chan_handle_t  s_spk_handle = NULL;
static i2s_chan_handle_t  s_mic_handle = NULL;
static volatile bool      s_stop_req   = false;
static volatile bool      s_playing    = false;  /* true while the worker plays a clip */
static bool               s_sd_mounted = false;
static bool               s_spiffs_mounted = false;

/* ── SPIFFS lazy mount ───────────────────────────────────────────────────── */

static void ensure_spiffs_audio(void)
{
    if (s_spiffs_mounted) return;
    esp_vfs_spiffs_conf_t conf = {
        .base_path              = "/spiffs",
        .partition_label        = "storage",
        .max_files              = 8,
        .format_if_mount_failed = true,  /* format a blank/corrupt partition at boot */
    };
    esp_err_t ret = esp_vfs_spiffs_register(&conf);
    if (ret == ESP_OK || ret == ESP_ERR_INVALID_STATE /* already mounted */) {
        s_spiffs_mounted = true;
        ESP_LOGI(TAG, "SPIFFS mounted at /spiffs");
    } else {
        ESP_LOGW(TAG, "SPIFFS mount failed: %s", esp_err_to_name(ret));
    }
}

/* ── SD card init (best-effort) ──────────────────────────────────────────── */

static void ensure_sd_mounted(void)
{
    if (s_sd_mounted) return;

    sdmmc_host_t host = SDSPI_HOST_DEFAULT();
    host.slot = SPI3_HOST;

    spi_bus_config_t bus_cfg = {
        .mosi_io_num     = PLIP_SD_MOSI,
        .miso_io_num     = PLIP_SD_MISO,
        .sclk_io_num     = PLIP_SD_SCK,
        .quadwp_io_num   = -1,
        .quadhd_io_num   = -1,
        .max_transfer_sz = 4000,
    };
    esp_err_t ret = spi_bus_initialize(SPI3_HOST, &bus_cfg, SDSPI_DEFAULT_DMA);
    if (ret != ESP_OK && ret != ESP_ERR_INVALID_STATE) {
        ESP_LOGW(TAG, "spi_bus_initialize: %s", esp_err_to_name(ret));
        return;
    }

    sdspi_device_config_t slot = SDSPI_DEVICE_CONFIG_DEFAULT();
    slot.gpio_cs = PLIP_SD_CS;
    slot.host_id = SPI3_HOST;

    esp_vfs_fat_sdmmc_mount_config_t mount_cfg = {
        .format_if_mount_failed = false,
        .max_files = 4,
        .allocation_unit_size = 16 * 1024,
    };
    sdmmc_card_t *card = NULL;
    ret = esp_vfs_fat_sdspi_mount(PLIP_SD_MOUNT, &host, &slot, &mount_cfg, &card);
    if (ret == ESP_OK) {
        s_sd_mounted = true;
        ESP_LOGI(TAG, "SD card mounted at %s", PLIP_SD_MOUNT);
        sdmmc_card_print_info(stdout, card);
    } else {
        ESP_LOGW(TAG, "SD card mount failed: %s", esp_err_to_name(ret));
    }
}

bool audio_ensure_sd(void)
{
    ensure_sd_mounted();
    return s_sd_mounted;
}

/* ── WAV parser ──────────────────────────────────────────────────────────── */

static esp_err_t parse_wav_header(const uint8_t *buf, size_t len, wav_info_t *out)
{
    if (len < WAV_MIN_HEADER) return ESP_ERR_INVALID_SIZE;
    if (memcmp(buf, "RIFF", 4) != 0 || memcmp(buf + 8, "WAVE", 4) != 0)
        return ESP_ERR_INVALID_ARG;

    size_t pos = 12;
    bool got_fmt = false, got_data = false;
    while (pos + 8 <= 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->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 (chunk_size == 0) break;
    }
    if (!got_fmt || !got_data) return ESP_ERR_NOT_FOUND;
    if (out->audio_format != 1) return ESP_ERR_NOT_SUPPORTED; /* not PCM */
    return ESP_OK;
}

/* WAV streaming chunk size — keeps heap usage well under 8 KB. */
#define PLAY_CHUNK_BYTES 4096

/* Play WAV from filesystem (SPIFFS or SD) using streaming I/O.
 * Only the WAV header (≤512 bytes) is buffered; PCM is piped directly
 * to I2S in PLAY_CHUNK_BYTES slices, avoiding any large heap allocation.
 * Paths starting with /spiffs/ are read from SPIFFS; all others use SD. */
static void play_wav_file(const char *path)
{
    bool is_spiffs = (strncmp(path, "/spiffs/", 8) == 0 || strncmp(path, "/spiffs", 7) == 0);
    if (is_spiffs) {
        ensure_spiffs_audio();
        if (!s_spiffs_mounted) {
            ESP_LOGW(TAG, "SPIFFS not mounted — beep fallback");
            audio_play_tone(880.0f, 200);
            return;
        }
    } else {
        ensure_sd_mounted();
        if (!s_sd_mounted) {
            ESP_LOGW(TAG, "SD not mounted — beep fallback");
            audio_play_tone(880.0f, 200);
            return;
        }
    }

    char full[192];
    if (path[0] == '/') {
        snprintf(full, sizeof(full), "%s", path);
    } else {
        snprintf(full, sizeof(full), "%s/%s", PLIP_SD_MOUNT, path);
    }

    FILE *f = fopen(full, "rb");
    if (!f) {
        ESP_LOGW(TAG, "file not found: %s", full);
        audio_play_tone(880.0f, 200);
        return;
    }

    /* Read enough of the file to parse the WAV header (up to 512 bytes). */
    uint8_t hdr_buf[512];
    size_t hdr_read = fread(hdr_buf, 1, sizeof(hdr_buf), f);
    if (hdr_read < WAV_MIN_HEADER) {
        ESP_LOGW(TAG, "WAV too short: %zu bytes", hdr_read);
        fclose(f);
        audio_play_tone(880.0f, 200);
        return;
    }

    wav_info_t wi = {0};
    if (parse_wav_header(hdr_buf, hdr_read, &wi) != ESP_OK) {
        ESP_LOGW(TAG, "WAV parse failed: %s", full);
        fclose(f);
        audio_play_tone(880.0f, 200);
        return;
    }
    if (wi.bits_per_sample != 16) {
        ESP_LOGW(TAG, "WAV: %d-bit not supported (need 16-bit)", wi.bits_per_sample);
        fclose(f);
        return;
    }

    ESP_LOGI(TAG, "WAV: %"PRIu32" Hz %d-bit %d ch, %"PRIu32" bytes PCM",
             wi.sample_rate, wi.bits_per_sample, wi.channels, wi.data_size);

    /* Seek to the PCM data section. */
    if (fseek(f, (long)wi.data_offset, SEEK_SET) != 0) {
        ESP_LOGW(TAG, "fseek to data failed");
        fclose(f);
        return;
    }

    /* The I2S TX slot is STEREO @ SAMPLE_RATE. A mono WAV must be expanded to
     * L+R or it is consumed at 2x rate (the "chipmunk" fast/high-pitch bug).
     * A WAV whose rate differs from SAMPLE_RATE would also play at the wrong
     * speed — warn (the gateway TTS always returns 16 kHz, matching). */
    if (wi.sample_rate != SAMPLE_RATE) {
        ESP_LOGW(TAG, "WAV rate %"PRIu32" Hz != I2S %d Hz — playback speed will be off",
                 wi.sample_rate, SAMPLE_RATE);
    }
    const bool mono = (wi.channels == 1);

    /* Load the ENTIRE PCM into PSRAM BEFORE playing. Streaming fread() from
     * SPIFFS *between* I2S writes stalls the DMA → underrun → audible
     * distortion ("saturation"). Diagnostic confirmed: the stored WAV is clean
     * (0% clip) and RAM-generated tones play clean, but SPIFFS-streamed WAVs
     * distorted. Reading it all up-front = zero file I/O during playback. */
    uint8_t *pcm = heap_caps_malloc(wi.data_size, MALLOC_CAP_SPIRAM);
    if (!pcm) pcm = malloc(wi.data_size);
    if (!pcm) {
        ESP_LOGE(TAG, "play: OOM for %"PRIu32"-byte PCM buffer", wi.data_size);
        fclose(f);
        return;
    }
    size_t pcm_len = fread(pcm, 1, wi.data_size, f);
    fclose(f);

    static int16_t s_stereo_chunk[PLAY_CHUNK_BYTES];   /* mono→stereo scratch */
    size_t off = 0;
    size_t total_written = 0;
    const size_t step = mono ? (PLAY_CHUNK_BYTES / 2) : PLAY_CHUNK_BYTES;

    while (!s_stop_req && off < pcm_len) {
        size_t bytes = (pcm_len - off < step) ? (pcm_len - off) : step;
        const uint8_t *out = pcm + off;
        size_t out_len = bytes;
        if (mono) {
            /* Duplicate each 16-bit mono sample into L and R. */
            size_t samples = bytes / 2;
            const int16_t *src = (const int16_t *)(pcm + off);
            for (size_t k = 0; k < samples; k++) {
                s_stereo_chunk[2 * k]     = src[k];
                s_stereo_chunk[2 * k + 1] = src[k];
            }
            out = (const uint8_t *)s_stereo_chunk;
            out_len = samples * 4;  /* 2 channels × 2 bytes */
        }
        size_t w = 0;
        esp_err_t ret = i2s_channel_write(s_spk_handle, out, out_len, &w, pdMS_TO_TICKS(500));
        if (ret != ESP_OK) {
            ESP_LOGW(TAG, "I2S write error: %s", esp_err_to_name(ret));
            break;
        }
        total_written += w;
        off += bytes;
    }
    free(pcm);
    float dur = (float)total_written / (float)(SAMPLE_RATE * 2 * 2);  /* 16k stereo 16-bit */
    ESP_LOGI(TAG, "play done: %zu bytes written, %.2fs", total_written, dur);
}

/* Generate a simple embedded 3-tone cue C5-E5-G5 as proof-of-life. */
static void play_embedded_cue(void)
{
    /* Three 200ms tones: C5 523Hz, E5 659Hz, G5 784Hz */
    const float freqs[] = {523.0f, 659.0f, 784.0f};
    for (int i = 0; i < 3 && !s_stop_req; i++) {
        audio_play_tone(freqs[i], 200);
        vTaskDelay(pdMS_TO_TICKS(50));
    }
}

/* ── Ring tone task ──────────────────────────────────────────────────────── */

static void ring_task(void *arg)
{
    (void)arg;
    /* French ring: 1.5s ON at ~440Hz, 3.5s OFF, repeat until stop */
    while (!s_stop_req) {
        /* ON: 1.5s at 440 Hz */
        const int on_ms = 1500;
        const int total_samples = SAMPLE_RATE * on_ms / 1000;
        const float freq = 440.0f;
        const float amp  = 12000.0f;
#define RING_CHUNK 128
        int16_t buf[RING_CHUNK * 2];  /* stereo buffer */
        int sample_idx = 0;
        while (!s_stop_req && sample_idx < total_samples) {
            int chunk = (total_samples - sample_idx < RING_CHUNK)
                            ? (total_samples - sample_idx) : RING_CHUNK;
            for (int i = 0; i < chunk; i++) {
                float t = (float)(sample_idx + i) / (float)SAMPLE_RATE;
                int16_t v = (int16_t)(amp * sinf(2.0f * (float)M_PI * freq * t));
                buf[i * 2]     = v; /* L */
                buf[i * 2 + 1] = v; /* R */
            }
            size_t written = 0;
            i2s_channel_write(s_spk_handle, buf, (size_t)chunk * 2 * sizeof(int16_t),
                              &written, pdMS_TO_TICKS(500));
            sample_idx += chunk;
        }
#undef RING_CHUNK
        if (s_stop_req) break;
        /* OFF: 3.5s silence */
        const int off_ms = 3500;
        const int steps = off_ms / 50;
        for (int i = 0; i < steps && !s_stop_req; i++) {
            vTaskDelay(pdMS_TO_TICKS(50));
        }
    }
    vTaskDelete(NULL);
}

/* ── Worker task ─────────────────────────────────────────────────────────── */

static void audio_worker_task(void *arg)
{
    (void)arg;
    ESP_LOGI(TAG, "audio worker ready");
    audio_cmd_t cmd;
    for (;;) {
        if (xQueueReceive(s_queue, &cmd, portMAX_DELAY) != pdTRUE) continue;
        s_stop_req = false;

        switch (cmd.kind) {
        case CMD_STOP:
            s_stop_req = true;
            ESP_LOGI(TAG, "stop");
            break;
        case CMD_RING:
            /* Ring must sound on-hook (caller hears it before picking up).
             * Force PA on for the duration of the ring regardless of hook state. */
            ESP_LOGI(TAG, "ring start — forcing PA on");
            audio_pa_set(true);
            xTaskCreate(ring_task, "ring", 4096, NULL, 4, NULL);
            break;
        case CMD_PLAY: {
            /* Gate playback on the REAL hook line (raw SLIC SHK), not phone.c's
             * debounced flag. The debounced flag lags or misses the pickup while
             * the bell is ringing, so an incoming-call greeting enqueued right
             * after pickup would be dropped here ("on-hook") even though the
             * handset is physically up. The raw SHK reads the pickup cleanly, and
             * is the single source of truth for hook state. */
            if (!slic_is_offhook()) {
                ESP_LOGI(TAG, "play ignored: on-hook (handset down)");
                break;
            }
            s_playing = true;
            const char *p = cmd.path;
            if (!p || !*p || strncmp(p, "embedded:", 9) == 0) {
                ESP_LOGI(TAG, "play: embedded cue");
                play_embedded_cue();
            } else {
                ESP_LOGI(TAG, "play: %s", p);
                play_wav_file(p);
            }
            s_playing = false;
            break;
        }
        default:
            break;
        }
    }
}

/* ── Public API ──────────────────────────────────────────────────────────── */

void audio_pa_set(bool enable)
{
    gpio_set_level(PLIP_PA_ENABLE, enable ? 1 : 0);
    ESP_LOGI(TAG, "PA %s", enable ? "ON" : "OFF");
}

bool audio_is_playing(void)
{
    return s_playing;
}

esp_err_t audio_init(void)
{
    /* 1. ES8388 I2C init + register sequence. */
    esp_err_t ret = es8388_init();
    if (ret != ESP_OK) {
        ESP_LOGE(TAG, "es8388_init failed: %s", esp_err_to_name(ret));
        return ret;
    }

    /* Apply the configured output volume. es8388_init() leaves OUT2 at 0 dB
     * (max) — too loud for a handset earpiece — so set it explicitly here. */
    es8388_set_volume(CONFIG_PLIP_SPEAKER_VOLUME);

    /* 2. Create I2S channels (TX = speaker, RX = mic — full-duplex pair). */
    i2s_chan_config_t chan_cfg = I2S_CHANNEL_DEFAULT_CONFIG(PLIP_I2S_NUM,
                                                             I2S_ROLE_MASTER);
    chan_cfg.auto_clear = true;
    ret = i2s_new_channel(&chan_cfg, &s_spk_handle, &s_mic_handle);
    if (ret != ESP_OK) {
        ESP_LOGE(TAG, "i2s_new_channel: %s", esp_err_to_name(ret));
        return ret;
    }

    /* 3a+3b. Full-duplex I2S config: SAME gpio_cfg for TX and RX.
     * IDF5 i2s_std constitutes full-duplex ONLY when TX and RX configs are
     * identical (memcmp). If they differ, on ESP32 HW v1 it tries to move
     * RX to I2S_NUM_1, breaking clock routing and yielding permanent zeros.
     * Fix: set both dout=PLIP_I2S_DOUT and din=PLIP_I2S_DIN in the same
     * config and apply it to both handles. The IDF GPIO driver handles
     * direction internally (dout→output, din→input). */
    i2s_std_config_t std_cfg = {
        .clk_cfg  = I2S_STD_CLK_DEFAULT_CONFIG(SAMPLE_RATE),
        .slot_cfg = I2S_STD_PHILIPS_SLOT_DEFAULT_CONFIG(
                        I2S_DATA_BIT_WIDTH_16BIT, I2S_SLOT_MODE_STEREO),
        .gpio_cfg = {
            .mclk = PLIP_I2S_MCLK,
            .bclk = PLIP_I2S_BCLK,
            .ws   = PLIP_I2S_WS,
            .dout = PLIP_I2S_DOUT,
            .din  = PLIP_I2S_DIN,
            .invert_flags = {
                .mclk_inv = false,
                .bclk_inv = false,
                .ws_inv   = false,
            },
        },
    };
    ret = i2s_channel_init_std_mode(s_spk_handle, &std_cfg);
    if (ret != ESP_OK) {
        ESP_LOGE(TAG, "i2s_channel_init_std_mode (TX): %s", esp_err_to_name(ret));
        return ret;
    }
    ret = i2s_channel_init_std_mode(s_mic_handle, &std_cfg);
    if (ret != ESP_OK) {
        ESP_LOGE(TAG, "i2s_channel_init_std_mode (RX): %s", esp_err_to_name(ret));
        return ret;
    }

    /* Enable both TX and RX at boot for full-duplex operation.
     * Both must be enabled for the I2S hardware to route ASDOUT→DIN correctly.
     * RX data will be read only during capture; TX stays active for clock/DAC. */
    ret = i2s_channel_enable(s_spk_handle);
    if (ret != ESP_OK) {
        ESP_LOGE(TAG, "i2s_channel_enable (TX): %s", esp_err_to_name(ret));
        return ret;
    }
    ret = i2s_channel_enable(s_mic_handle);
    if (ret != ESP_OK) {
        ESP_LOGE(TAG, "i2s_channel_enable (RX): %s", esp_err_to_name(ret));
        /* Non-fatal: TX still works for playback; capture will fail gracefully. */
        ESP_LOGW(TAG, "RX enable failed — capture disabled, playback unaffected");
        s_mic_handle = NULL;
    }

    /* 3c. Quick RX sanity: read multiple frames to check DMA and signal quality.
     * Do this before the 440Hz boot tone to see baseline ADC output. */
    if (s_mic_handle) {
        int16_t test_buf[320 * 2];
        size_t test_read = 0;
        /* Drain any startup garbage (first few frames may be codec settling). */
        for (int flush = 0; flush < 5; flush++) {
            i2s_channel_read(s_mic_handle, test_buf, sizeof(test_buf), &test_read, pdMS_TO_TICKS(50));
        }
        /* Read a real frame and report. */
        esp_err_t rx_ret = i2s_channel_read(s_mic_handle, test_buf,
                                             sizeof(test_buf), &test_read,
                                             pdMS_TO_TICKS(200));
        int32_t max_val = 0, rms_sq = 0;
        int n = (int)(test_read / 2);
        for (int i = 0; i < n; i++) {
            int32_t v = test_buf[i] < 0 ? -test_buf[i] : test_buf[i];
            if (v > max_val) max_val = v;
            rms_sq += (int32_t)test_buf[i] * test_buf[i];
        }
        rms_sq /= (n > 0 ? n : 1);
        ESP_LOGI(TAG, "RX sanity: ret=%s read=%zu max=%"PRId32" rms²=%"PRId32" s[0..3]=(%d,%d,%d,%d)",
                 esp_err_to_name(rx_ret), test_read, max_val, rms_sq,
                 n>0?test_buf[0]:0, n>0?test_buf[1]:0, n>1?test_buf[2]:0, n>1?test_buf[3]:0);
    }

    /* 4. Create async command queue + worker. */
    s_queue = xQueueCreate(8, sizeof(audio_cmd_t));
    if (!s_queue) return ESP_ERR_NO_MEM;

    BaseType_t ok = xTaskCreatePinnedToCore(audio_worker_task, "audio_work",
                                             8192, NULL, 5, NULL, 0);
    if (ok != pdPASS) return ESP_ERR_NO_MEM;

    /* Mount SPIFFS at init (not lazily) so turn_client can WRITE the NPC WAV to
     * /spiffs before any playback has triggered the lazy mount. */
    ensure_spiffs_audio();

    ESP_LOGI(TAG, "audio init OK (I2S TX ready, RX handle allocated, ES8388 live)");
    return ESP_OK;
}

/* ── Mic capture ─────────────────────────────────────────────────────────── */

/*
 * audio_capture_wav() — capture microphone audio and return a WAV buffer.
 *
 * Strategy: half-duplex. Disable TX, enable RX, read until VAD silence timeout
 * or hard cap, then disable RX, re-enable TX.
 *
 * ES8388 delivers stereo I2S frames (L+R int16) even though the mic is mono.
 * We average L+R into a single mono sample for the output WAV.
 *
 * Returns number of bytes written to out (header + PCM), or -1 on error.
 */
int audio_capture_wav(uint8_t *out, size_t out_max, int max_ms, int silence_ms)
{
    if (!s_mic_handle || !s_spk_handle) {
        ESP_LOGE(TAG, "capture: I2S handles not ready");
        return -1;
    }
    if (!out || out_max < 44 + 320) {
        ESP_LOGE(TAG, "capture: output buffer too small");
        return -1;
    }

    /* WAV parameters for output: 16 kHz, mono, S16-LE. */
    const uint32_t cap_rate   = SAMPLE_RATE;   /* 16000 */
    const uint16_t cap_ch     = 1;             /* mono output */
    const uint16_t cap_bits   = 16;
    const size_t   hdr_bytes  = 44;

    /* Per-frame: 20 ms at 16kHz = 320 mono samples = 640 bytes PCM out.
     * ES8388 stereo frame = 640 bytes input (320 samples × 2 ch × 2 bytes). */
    const int      frame_samples = 320;        /* mono samples per frame */
    const size_t   frame_in_bytes  = (size_t)frame_samples * 2 * sizeof(int16_t); /* stereo */
    const size_t   frame_out_bytes = (size_t)frame_samples * sizeof(int16_t);     /* mono */

    /* Allocate scratch buffer in SPIRAM if available, else internal heap. */
    int16_t *rx_buf = (int16_t *)heap_caps_malloc(frame_in_bytes,
                                                    MALLOC_CAP_SPIRAM);
    if (!rx_buf) {
        rx_buf = (int16_t *)malloc(frame_in_bytes);
    }
    if (!rx_buf) {
        ESP_LOGE(TAG, "capture: OOM for rx_buf");
        return -1;
    }

    /* RX is already enabled at boot (full-duplex). Calling enable again returns
     * ESP_ERR_INVALID_STATE — that's fine, it just means RX is already running.
     * Only a genuinely different error is fatal. */
    esp_err_t ret = i2s_channel_enable(s_mic_handle);
    if (ret != ESP_OK && ret != ESP_ERR_INVALID_STATE) {
        ESP_LOGE(TAG, "capture: i2s_channel_enable(RX): %s", esp_err_to_name(ret));
        free(rx_buf);
        return -1;
    }

    /* Reserve space for WAV header at start of out buffer. */
    uint8_t  *pcm_out    = out + hdr_bytes;
    size_t    pcm_max    = out_max - hdr_bytes;
    size_t    pcm_written = 0;

    const int max_frames     = (max_ms     / 20);
    const int silence_frames = (silence_ms / 20);

    /* Calibrated to the QUIET handset voice (DC-blocked: body 0.16-0.8 % FS RMS,
     * loud syllables ~2-4 %, noise floor ~0.3 %). The old onset (1.4 %) NEVER
     * triggered → "no sustained voice"; the old silence (0.6 %) sat ABOVE the
     * body → cut mid-sentence. Now: onset catches a real syllable just above the
     * floor (3-frame confirm guards clicks); silence sits just above the floor so
     * the turn ends only on a true pause, holding the whole sentence. */
    const int32_t  vad_onset_rms_sq   = 230 * 230;   /* ~0.70% FS² — quiet-voice onset */
    const int32_t  vad_silence_rms_sq = 110 * 110;   /* ~0.34% FS² — just above noise floor */

    int  silent_frames = 0;
    int  total_frames  = 0;

    /* One-pole DC blocker (high-pass ~80 Hz) applied to the captured mono.
     * The SLIC/handset path carries a huge DC/sub-audio offset (~80 % FS,
     * measured). Left in, it (a) swamps the faint voice and (b) keeps the VAD
     * RMS permanently above the silence threshold, so the capture never ends
     * and always runs the full max_ms — and the gateway then sees only a DC
     * transient, transcribing empty. Removing it lets the VAD track the real
     * speech envelope (onset/silence work) and hands the gateway a clean,
     * voice-dominated signal. Telephone band (300-3400 Hz) is untouched.
     * y[n] = x[n] - x[n-1] + R*y[n-1]; R=0.97 → cutoff ~80 Hz at 16 kHz. */
    float       dc_x1 = 0.0f, dc_y1 = 0.0f;
    const float dc_R  = 0.97f;

    ESP_LOGI(TAG, "capture: RX enabled, max_ms=%d silence_ms=%d", max_ms, silence_ms);

    /* ── Phase A: wait for SUSTAINED voice before committing to a capture ─────
     * Only start recording when the caller actually speaks. We require the
     * DC-blocked RMS to stay above the onset threshold for ONSET_CONFIRM
     * consecutive frames (~60 ms) — a single loud frame is rejected as a
     * transient (e.g. the click when the PA mutes just before capture). Audio
     * is discarded during this phase. If no sustained voice arrives within
     * max_ms we return an empty WAV so the caller posts nothing (the NPC must
     * not "reply" to silence). The listen loop simply calls us again. */
    const int ONSET_CONFIRM = 3;
    int  onset_run  = 0;
    bool got_onset  = false;
    for (int f = 0; f < max_frames; f++) {
        size_t bytes_read = 0;
        ret = i2s_channel_read(s_mic_handle, rx_buf, frame_in_bytes,
                               &bytes_read, pdMS_TO_TICKS(100));
        if (ret != ESP_OK || bytes_read == 0) continue;
        int64_t rms_sq = 0;
        int     n_stereo = (int)(bytes_read / (2 * sizeof(int16_t)));
        for (int i = 0; i < n_stereo; i++) {
            float xf = (float)((rx_buf[i * 2] + rx_buf[i * 2 + 1]) / 2);
            float yf = xf - dc_x1 + dc_R * dc_y1;     /* DC-blocking high-pass */
            dc_x1 = xf; dc_y1 = yf;
            int32_t s = (int32_t)yf;
            rms_sq += (int64_t)s * s;
        }
        rms_sq /= (n_stereo > 0 ? n_stereo : 1);
        if (rms_sq >= vad_onset_rms_sq) {
            if (++onset_run >= ONSET_CONFIRM) {
                got_onset = true;
                ESP_LOGI(TAG, "capture: sustained voice onset at frame %d (rms²=%"PRId64")",
                         f, rms_sq);
                break;
            }
        } else {
            onset_run = 0;
        }
    }

    if (got_onset) {
        /* ── Phase B: record the utterance until silence_ms of silence ──────── */
        for (int f = 0; f < max_frames && pcm_written + frame_out_bytes <= pcm_max; f++) {
            size_t bytes_read = 0;
            ret = i2s_channel_read(s_mic_handle, rx_buf, frame_in_bytes,
                                   &bytes_read, pdMS_TO_TICKS(100));
            if (ret != ESP_OK || bytes_read == 0) {
                ESP_LOGW(TAG, "capture: read error f=%d: %s", f, esp_err_to_name(ret));
                continue;
            }

            /* Downmix stereo → mono (DC-blocked), compute RMS². */
            int16_t *out_frame = (int16_t *)(pcm_out + pcm_written);
            int64_t  rms_sq    = 0;
            int      n_stereo  = (int)(bytes_read / (2 * sizeof(int16_t)));
            for (int i = 0; i < n_stereo; i++) {
                float   xf = (float)((rx_buf[i * 2] + rx_buf[i * 2 + 1]) / 2);
                float   yf = xf - dc_x1 + dc_R * dc_y1;
                dc_x1 = xf; dc_y1 = yf;
                if (yf > 32767.0f)       yf = 32767.0f;
                else if (yf < -32768.0f) yf = -32768.0f;
                int16_t mono = (int16_t)yf;
                out_frame[i] = mono;
                rms_sq += (int64_t)mono * mono;
            }
            rms_sq /= (n_stereo > 0 ? n_stereo : 1);

            pcm_written += (size_t)n_stereo * sizeof(int16_t);
            total_frames = f + 1;

            if (rms_sq < vad_silence_rms_sq) {
                if (++silent_frames >= silence_frames) {
                    ESP_LOGI(TAG, "capture: VAD end (silence %d frames at f=%d)",
                             silent_frames, f);
                    break;
                }
            } else {
                silent_frames = 0;
            }
        }
    } else {
        ESP_LOGI(TAG, "capture: no sustained voice in %d ms — nothing to send", max_ms);
        /* pcm_written stays 0 → empty WAV → caller skips posting. */
    }

    /* Leave RX enabled (full-duplex, as at boot). Disabling it here would make
     * the next capture's enable a no-op INVALID_STATE AND break other RX users
     * (streaming capture / DTMF) that assume RX stays running. */
    free(rx_buf);

    if (pcm_written == 0) {
        ESP_LOGW(TAG, "capture: no audio captured (silence or timeout)");
        /* Return a valid but minimal WAV with 0 PCM bytes so caller can inspect. */
    }

    uint32_t data_size     = (uint32_t)pcm_written;
    uint32_t byte_rate     = cap_rate * cap_ch * (cap_bits / 8);
    uint16_t block_align   = (uint16_t)(cap_ch * (cap_bits / 8));
    uint32_t riff_size     = 36 + data_size;

    /* Write WAV header (44 bytes, little-endian). */
    memcpy(out +  0, "RIFF", 4);
    memcpy(out +  4, &riff_size, 4);
    memcpy(out +  8, "WAVE", 4);
    memcpy(out + 12, "fmt ", 4);
    uint32_t fmt_size = 16;
    uint16_t fmt_pcm  = 1;
    memcpy(out + 16, &fmt_size,    4);
    memcpy(out + 20, &fmt_pcm,     2);
    memcpy(out + 22, &cap_ch,      2);
    memcpy(out + 24, &cap_rate,    4);
    memcpy(out + 28, &byte_rate,   4);
    memcpy(out + 32, &block_align, 2);
    memcpy(out + 34, &cap_bits,    2);
    memcpy(out + 36, "data", 4);
    memcpy(out + 40, &data_size,   4);

    int total_bytes = (int)(hdr_bytes + pcm_written);
    float duration_s = (float)pcm_written / (float)(cap_rate * (cap_bits / 8) * cap_ch);
    ESP_LOGI(TAG, "capture done: %d frames, %.2fs, %zu PCM bytes, %d total bytes",
             total_frames, duration_s, pcm_written, total_bytes);
    return total_bytes;
}

/* ── Streaming capture API ───────────────────────────────────────────────── */

/* Scratch buffer reused across frames (lives for the duration of a capture). */
static int16_t *s_rx_scratch = NULL;  /* stereo frame: 320*2 int16_t = 1280 bytes */
static int      s_frame_debug_count = 0;
#define CAP_FRAME_SAMPLES 320         /* 20 ms @ 16kHz */
#define CAP_FRAME_IN_BYTES (CAP_FRAME_SAMPLES * 2 * sizeof(int16_t))

int audio_capture_begin(int max_ms, int silence_ms)
{
    (void)max_ms; (void)silence_ms;  /* caller drives VAD/timeout */
    if (!s_mic_handle) {
        ESP_LOGE(TAG, "capture_begin: RX handle not available");
        return -1;
    }
    s_rx_scratch = (int16_t *)heap_caps_malloc(CAP_FRAME_IN_BYTES, MALLOC_CAP_SPIRAM);
    if (!s_rx_scratch) s_rx_scratch = (int16_t *)malloc(CAP_FRAME_IN_BYTES);
    if (!s_rx_scratch) {
        ESP_LOGE(TAG, "capture_begin: OOM for scratch buf");
        return -1;
    }
    s_frame_debug_count = 0;

    /* Full-duplex: keep TX running (provides MCLK/BCLK/WS clocks to codec).
     * RX is already enabled at boot. */
    ESP_LOGI(TAG, "capture_begin: ready (full-duplex, TX running)");
    return 0;
}

/*
 * Read one 20 ms frame from the mic, downmix stereo→mono.
 * mono_out must hold n_samples (320) int16_t values.
 * Sets *rms_sq_out to mean-squared energy of the frame.
 * Returns number of mono samples written, 0 on read timeout, -1 on error.
 */
int audio_capture_read_frame(int16_t *mono_out, int n_samples, int64_t *rms_sq_out)
{
    if (!s_rx_scratch) return -1;
    size_t bytes_read = 0;
    esp_err_t ret = i2s_channel_read(s_mic_handle, s_rx_scratch,
                                      CAP_FRAME_IN_BYTES, &bytes_read,
                                      pdMS_TO_TICKS(100));
    if (ret != ESP_OK || bytes_read == 0) return 0;

    int n = (int)(bytes_read / (2 * sizeof(int16_t)));
    if (n > n_samples) n = n_samples;

    /* Debug: log first 3 frames raw stereo values to diagnose zero data. */
    if (s_frame_debug_count < 3) {
        int16_t r0 = (n > 0) ? s_rx_scratch[0] : 0;
        int16_t r1 = (n > 0) ? s_rx_scratch[1] : 0;
        int16_t r2 = (n > 1) ? s_rx_scratch[2] : 0;
        int16_t r3 = (n > 1) ? s_rx_scratch[3] : 0;
        ESP_LOGI(TAG, "RX frame %d: bytes_read=%zu n=%d raw[0..3]=(%d,%d,%d,%d)",
                 s_frame_debug_count, bytes_read, n, r0, r1, r2, r3);
        s_frame_debug_count++;
    }

    int64_t rms_sq = 0;
    for (int i = 0; i < n; i++) {
        int32_t l = s_rx_scratch[i * 2];
        int32_t r = s_rx_scratch[i * 2 + 1];
        int16_t mono = (int16_t)((l + r) / 2);
        mono_out[i] = mono;
        rms_sq += (int64_t)mono * mono;
    }
    if (rms_sq_out) *rms_sq_out = (n > 0) ? rms_sq / n : 0;
    return n;
}

void audio_capture_end(void)
{
    if (s_rx_scratch) { free(s_rx_scratch); s_rx_scratch = NULL; }
    ESP_LOGI(TAG, "capture_end: done");
}

i2s_chan_handle_t audio_spk_handle(void)
{
    return s_spk_handle;
}

void audio_play_tone(float frequency_hz, int duration_ms)
{
    if (!s_spk_handle || duration_ms <= 0) return;
    const int total_samples = SAMPLE_RATE * duration_ms / 1000;
    const float amp = 12000.0f;
    /* Stereo: each sample = L + R = 2 int16_t values. */
    /* Process 128 mono samples per iteration = 256 stereo int16_t = 512 bytes. */
#define TONE_CHUNK 128
    int16_t buf[TONE_CHUNK * 2];   /* stereo buffer: 2 samples per frame */
    size_t written = 0;
    int idx = 0;
    while (idx < total_samples) {
        int chunk = (total_samples - idx < TONE_CHUNK)
                        ? (total_samples - idx) : TONE_CHUNK;
        for (int i = 0; i < chunk; i++) {
            float t = (float)(idx + i) / (float)SAMPLE_RATE;
            int16_t v = (int16_t)(amp * sinf(2.0f * (float)M_PI * frequency_hz * t));
            buf[i * 2]     = v;   /* L */
            buf[i * 2 + 1] = v;  /* R */
        }
        i2s_channel_write(s_spk_handle, buf, (size_t)chunk * 2 * sizeof(int16_t),
                          &written, pdMS_TO_TICKS(500));
        idx += chunk;
    }
#undef TONE_CHUNK
}

esp_err_t audio_play_async(const char *path)
{
    if (!s_queue) return ESP_ERR_INVALID_STATE;
    audio_cmd_t cmd = { .kind = CMD_PLAY };
    if (path && *path) {
        strncpy(cmd.path, path, sizeof(cmd.path) - 1);
    }
    if (xQueueSend(s_queue, &cmd, 0) != pdTRUE) {
        ESP_LOGW(TAG, "play queue full");
        return ESP_FAIL;
    }
    return ESP_OK;
}

esp_err_t audio_stop(void)
{
    s_stop_req = true;
    if (!s_queue) return ESP_ERR_INVALID_STATE;
    audio_cmd_t cmd = { .kind = CMD_STOP };
    xQueueSend(s_queue, &cmd, 0);
    return ESP_OK;
}

esp_err_t audio_ring_start(void)
{
    s_stop_req = false;
    if (!s_queue) return ESP_ERR_INVALID_STATE;
    audio_cmd_t cmd = { .kind = CMD_RING };
    if (xQueueSend(s_queue, &cmd, 0) != pdTRUE) {
        return ESP_FAIL;
    }
    return ESP_OK;
}
