// voice_pipeline — ESP-IDF implementation. See voice_pipeline.h for the
// scope of slices 5, 6 and 7. The AFE / WakeNet integration is gated on
// `cfg.enable_wake_word`; if init fails (PSRAM exhausted, model
// partition absent, etc.) we log + degrade silently to the slice-5
// I2S-only capture path so the rest of the firmware still boots.
//
// Slice (task 4): I2S RX ownership transferred to mic_broker. The
// voice_pipeline registers on_npc_frame() for MIC_NPC_LISTEN and the broker
// delivers PCM16 mono 320-sample frames; voice_pipeline accumulates them
// into the AFE feed buffer (or counts heartbeat bytes in stub mode).
// The I2S TX path (speaker / MAX98357A on I2S_NUM_1) is unchanged.

#include "voice_pipeline.h"
#include "voice_pipeline_ws.h"

#include <string.h>

#include "driver/i2s_std.h"
#include "esp_heap_caps.h"
#include "esp_log.h"
#include "esp_mac.h"
#include "freertos/FreeRTOS.h"
#include "freertos/task.h"
#include "freertos/semphr.h"

#include "esp_afe_config.h"
#include "esp_afe_sr_iface.h"
#include "esp_afe_sr_models.h"
#include "esp_wn_iface.h"
#include "esp_wn_models.h"
#include "model_path.h"

#include "mic_broker.h"

static const char *TAG = "voice_pipeline";

// Slice 6 placeholder wake word. Standard Espressif WakeNet9 model
// shipped under permissive license — no commercial agreement required.
// Custom "Professeur Zacus" model lands later (see voice spec P4).
#define VOICE_DEFAULT_WAKE_WORD_NAME "wn9_hiesp"

// Partition holding srmodels.bin (added in partitions.csv as a 1 MB
// SPIFFS region). Must match the partition `Name` column.
#define VOICE_SR_MODEL_PARTITION     "model"

#define CAPTURE_TASK_STACK   8192
#define CAPTURE_TASK_PRIO    5
#define CAPTURE_CHUNK_BYTES  1024  // fallback (slice-5 path) — 16-bit @16 kHz = 32 ms slice

// Slice 7: end-of-utterance detection. AFE feed chunk size at 16 kHz /
// AFE_MODE_LOW_COST is typically 512 samples = 32 ms. 50 consecutive
// silence chunks ≈ 1.5 s of silence, which matches the spec's
// "sustained ~1.5 s" end-of-speech criterion. A safety cap stops a
// runaway stream after ~10 s of audio so a stuck VAD never blocks the
// pipeline forever.
#define END_SILENCE_CHUNKS         50
#define STREAMING_MAX_CHUNKS       (16000 * 10 / 512)  // ~10 s

static struct {
    bool                     ready;
    voice_pipeline_config_t  cfg;
    // rx_chan removed: I2S RX is now owned by mic_broker (task 4).
    // Slice 9: I2S TX channel for TTS playback (MAX98357A DAC). NULL
    // if `enable_tts_playback == false` or alloc/init failed.
    i2s_chan_handle_t        tx_chan;
    bool                     tx_enabled;        // channel currently enabled
    uint32_t                 tx_sample_rate;    // current configured rate
    SemaphoreHandle_t        play_lock;         // serialises TTS vs media playback (one session at a time)
    voice_state_t            state;
    // capture_task / capture_run removed: mic_broker's task delivers frames
    // via on_npc_frame(); broker mode (MIC_NPC_LISTEN/MIC_IDLE) gates it.

    // Wake-word callback (set independently of init, may be NULL).
    voice_wake_callback_t    wake_cb;
    void                    *wake_cb_ctx;

    // ESP-SR handles. NULL if wake-word not enabled or alloc failed —
    // capture_task uses the dumb I2S read path in that case.
    srmodel_list_t          *sr_models;
    const esp_afe_sr_iface_t *afe_iface;
    esp_afe_sr_data_t       *afe_data;
    char                     wake_word_name[32];
    int                      afe_feed_chunk_samples;   // per-channel
    int                      afe_feed_channel_num;     // mic + ref
    int                      afe_fetch_chunk_samples;  // post-AFE, what we stream

    // Slice 7: streaming state. `stream_active` mirrors voice_ws_is_streaming
    // but is owned by the on_npc_frame callback so we don't race with the WS
    // event loop on transitions. `silence_chunks` counts sustained
    // AFE_VAD_SILENCE fetches; reaching END_SILENCE_CHUNKS closes the upload.
    bool                     stream_active;
    uint32_t                 silence_chunks;
    uint32_t                 streamed_chunks;
    char                     session_id[32];

    // AFE feed accumulator: broker delivers 320-sample chunks; the AFE may
    // require a larger feed_chunk_samples. We accumulate here before feeding.
    int16_t                 *afe_accum;        // PSRAM alloc; size = feed_bytes
    size_t                   afe_accum_fill;   // samples already buffered
} s_pipe = {
    .state = VOICE_STATE_IDLE,
};

void voice_pipeline_default_config(voice_pipeline_config_t *out) {
    if (!out) return;
    out->i2s_bclk_pin       = 14;   // GPIO14 -> SCK
    out->i2s_ws_pin         = 15;   // GPIO15 -> WS
    out->i2s_din_pin        = 22;   // GPIO22 -> SD (INMP441)
    out->sample_rate_hz     = 16000;
    out->auto_start_capture = false;
    out->enable_wake_word   = false;
    out->voice_bridge_ws_url = NULL;
    // Slice 9: TTS playback defaults — disabled. Pinout follows the
    // suggested Freenove ESP32-S3 convention: BCLK=11, LRC=12, DIN=13.
    // Confirm at flash time before driving the DAC.
    out->enable_tts_playback = false;
    out->i2s_out_bclk_pin    = 11;
    out->i2s_out_lrc_pin     = 12;
    out->i2s_out_din_pin     = 13;
}

bool voice_pipeline_wake_word_active(void) {
    return (s_pipe.afe_iface != NULL && s_pipe.afe_data != NULL);
}

esp_err_t voice_pipeline_set_wake_callback(voice_wake_callback_t cb,
                                           void *user_ctx) {
    s_pipe.wake_cb     = cb;
    s_pipe.wake_cb_ctx = user_ctx;
    return ESP_OK;
}

esp_err_t voice_pipeline_set_stt_callback(voice_stt_callback_t cb,
                                          void *user_ctx) {
    voice_ws_set_stt_callback(cb, user_ctx);
    return ESP_OK;
}

bool voice_pipeline_is_streaming(void) {
    return s_pipe.stream_active;
}

static void session_id_init(void) {
    uint8_t mac[6] = {0};
    if (esp_efuse_mac_get_default(mac) == ESP_OK) {
        snprintf(s_pipe.session_id, sizeof(s_pipe.session_id),
                 "%02x%02x%02x%02x%02x%02x",
                 mac[0], mac[1], mac[2], mac[3], mac[4], mac[5]);
    } else {
        strncpy(s_pipe.session_id, "unknown",
                sizeof(s_pipe.session_id) - 1);
        s_pipe.session_id[sizeof(s_pipe.session_id) - 1] = '\0';
    }
}

// Open the WebSocket lazily on the first wake (or on
// voice_pipeline_start_streaming). Returns ESP_OK on success or if the
// stream is already open.
static esp_err_t streaming_begin(void) {
    if (s_pipe.stream_active) return ESP_OK;
    if (!voice_ws_is_configured()) return ESP_ERR_INVALID_STATE;

    esp_err_t err = voice_ws_open_streaming();
    if (err != ESP_OK) {
        ESP_LOGW(TAG, "voice_ws_open_streaming failed: %s",
                 esp_err_to_name(err));
        return err;
    }
    s_pipe.stream_active   = true;
    s_pipe.silence_chunks  = 0;
    s_pipe.streamed_chunks = 0;
    ESP_LOGI(TAG, "streaming started");
    return ESP_OK;
}

static void streaming_end(const char *reason) {
    if (!s_pipe.stream_active) return;
    ESP_LOGI(TAG, "streaming end (%s) — sent %u chunks, silence=%u",
             reason ? reason : "?",
             (unsigned) s_pipe.streamed_chunks,
             (unsigned) s_pipe.silence_chunks);
    voice_ws_close_streaming();
    s_pipe.stream_active   = false;
    s_pipe.silence_chunks  = 0;
    s_pipe.streamed_chunks = 0;
    voice_pipeline_set_state(VOICE_STATE_IDLE);
}

esp_err_t voice_pipeline_start_streaming(void) {
    if (!s_pipe.ready) return ESP_ERR_INVALID_STATE;
    if (!voice_ws_is_configured()) {
        ESP_LOGW(TAG, "start_streaming: no voice_bridge_ws_url configured");
        return ESP_ERR_INVALID_STATE;
    }
    voice_pipeline_set_state(VOICE_STATE_LISTENING);
    return streaming_begin();
}

esp_err_t voice_pipeline_stop_streaming(void) {
    streaming_end("manual_stop");
    return ESP_OK;
}

// i2s_setup() removed: I2S RX is now owned by mic_broker (task 4).
// mic_broker_init() is called from voice_pipeline_init() instead.

// Slice 9: bring up the I2S TX channel for TTS playback on a separate
// I2S port (I2S_NUM_1) so the mic capture on I2S_NUM_0 keeps running
// untouched. Configures Philips std mode, mono, 16-bit, at the
// pipeline's default sample rate (typically 16 kHz). The actual TTS
// rate may differ (Piper f5_tts ≈ 24 kHz) — voice_pipeline_play_start
// reconfigures the clock on the fly when needed.
static esp_err_t i2s_tx_setup(void) {
    i2s_chan_config_t chan_cfg = I2S_CHANNEL_DEFAULT_CONFIG(I2S_NUM_1, I2S_ROLE_MASTER);
    esp_err_t err = i2s_new_channel(&chan_cfg, &s_pipe.tx_chan, NULL);
    if (err != ESP_OK) return err;

    // Initial clock = same as mic; will be reconfigured at play_start
    // if the bridge announces a different rate.
    s_pipe.tx_sample_rate = s_pipe.cfg.sample_rate_hz;

    i2s_std_config_t std_cfg = {
        .clk_cfg  = I2S_STD_CLK_DEFAULT_CONFIG(s_pipe.tx_sample_rate),
        .slot_cfg = I2S_STD_PHILIPS_SLOT_DEFAULT_CONFIG(I2S_DATA_BIT_WIDTH_16BIT,
                                                        I2S_SLOT_MODE_MONO),
        .gpio_cfg = {
            .mclk = I2S_GPIO_UNUSED,
            .bclk = s_pipe.cfg.i2s_out_bclk_pin,
            .ws   = s_pipe.cfg.i2s_out_lrc_pin,
            .dout = s_pipe.cfg.i2s_out_din_pin,
            .din  = I2S_GPIO_UNUSED,
            .invert_flags = {
                .mclk_inv = false,
                .bclk_inv = false,
                .ws_inv   = false,
            },
        },
    };
    err = i2s_channel_init_std_mode(s_pipe.tx_chan, &std_cfg);
    if (err != ESP_OK) {
        i2s_del_channel(s_pipe.tx_chan);
        s_pipe.tx_chan = NULL;
        return err;
    }
    s_pipe.tx_enabled = false;
    if (s_pipe.play_lock == NULL) {
        s_pipe.play_lock = xSemaphoreCreateMutex();
    }
    return ESP_OK;
}

// Bring up esp-sr AFE + WakeNet. Returns ESP_OK on success. On failure
// the caller logs and falls back to the dumb I2S capture path.
static esp_err_t wake_word_setup(void) {
    s_pipe.sr_models = esp_srmodel_init(VOICE_SR_MODEL_PARTITION);
    if (!s_pipe.sr_models || s_pipe.sr_models->num <= 0) {
        ESP_LOGW(TAG, "esp_srmodel_init('%s') returned no models — "
                       "wake word disabled (check srmodels.bin flashed)",
                 VOICE_SR_MODEL_PARTITION);
        return ESP_ERR_NOT_FOUND;
    }
    ESP_LOGI(TAG, "esp-sr loaded %d model(s) from partition '%s'",
             s_pipe.sr_models->num, VOICE_SR_MODEL_PARTITION);
    for (int i = 0; i < s_pipe.sr_models->num; i++) {
        ESP_LOGI(TAG, "  model[%d] = %s", i, s_pipe.sr_models->model_name[i]);
    }

    char *wn_name = esp_srmodel_filter(s_pipe.sr_models, ESP_WN_PREFIX, NULL);
    if (!wn_name) {
        ESP_LOGW(TAG, "no WakeNet model found in partition — wake disabled");
        return ESP_ERR_NOT_FOUND;
    }
    strncpy(s_pipe.wake_word_name, wn_name, sizeof(s_pipe.wake_word_name) - 1);
    s_pipe.wake_word_name[sizeof(s_pipe.wake_word_name) - 1] = '\0';
    ESP_LOGI(TAG, "selected wake model = %s (placeholder, slice 6)",
             s_pipe.wake_word_name);

    // Single mic, no reference channel: input format "M" (one micropohne).
    afe_config_t *afe_cfg = afe_config_init("M", s_pipe.sr_models,
                                            AFE_TYPE_SR, AFE_MODE_LOW_COST);
    if (!afe_cfg) {
        ESP_LOGW(TAG, "afe_config_init returned NULL");
        return ESP_FAIL;
    }
    // Force the wake model we just discovered, in case the default
    // selection logic picks something we don't want.
    afe_cfg->wakenet_init      = true;
    afe_cfg->wakenet_model_name = s_pipe.wake_word_name;
    // 1-mic SR: AEC needs a reference channel we don't have, SE (BSS)
    // needs >= 2 mics. Disable both, keep NS + VAD + AGC.
    afe_cfg->aec_init = false;
    afe_cfg->se_init  = false;
    afe_cfg->memory_alloc_mode = AFE_MEMORY_ALLOC_MORE_PSRAM;

    s_pipe.afe_iface = esp_afe_handle_from_config(afe_cfg);
    if (!s_pipe.afe_iface) {
        ESP_LOGW(TAG, "esp_afe_handle_from_config returned NULL");
        afe_config_free(afe_cfg);
        return ESP_FAIL;
    }
    s_pipe.afe_data = s_pipe.afe_iface->create_from_config(afe_cfg);
    afe_config_free(afe_cfg);
    if (!s_pipe.afe_data) {
        ESP_LOGW(TAG, "AFE create_from_config failed (likely OOM in PSRAM)");
        s_pipe.afe_iface = NULL;
        return ESP_ERR_NO_MEM;
    }

    s_pipe.afe_feed_chunk_samples  = s_pipe.afe_iface->get_feed_chunksize(s_pipe.afe_data);
    s_pipe.afe_feed_channel_num    = s_pipe.afe_iface->get_feed_channel_num(s_pipe.afe_data);
    s_pipe.afe_fetch_chunk_samples = s_pipe.afe_iface->get_fetch_chunksize(s_pipe.afe_data);

    ESP_LOGI(TAG, "AFE up: feed_chunk=%d samples × %d ch, fetch_chunk=%d, sample_rate=%d Hz",
             s_pipe.afe_feed_chunk_samples,
             s_pipe.afe_feed_channel_num,
             s_pipe.afe_fetch_chunk_samples,
             s_pipe.afe_iface->get_samp_rate(s_pipe.afe_data));
    if (s_pipe.afe_iface->print_pipeline) {
        s_pipe.afe_iface->print_pipeline(s_pipe.afe_data);
    }
    return ESP_OK;
}

static void wake_word_teardown(void) {
    if (s_pipe.afe_data && s_pipe.afe_iface && s_pipe.afe_iface->destroy) {
        s_pipe.afe_iface->destroy(s_pipe.afe_data);
    }
    s_pipe.afe_data  = NULL;
    s_pipe.afe_iface = NULL;
    if (s_pipe.sr_models) {
        esp_srmodel_deinit(s_pipe.sr_models);
        s_pipe.sr_models = NULL;
    }
}

// NPC mic callback — invoked by mic_broker's capture task for every
// MB_FRAME-sample (320 samples = 20 ms) frame while MIC_NPC_LISTEN is active.
//
// Two paths:
//   * AFE active : accumulate frames into s_pipe.afe_accum until we have a
//                  full afe_feed_chunk_samples worth, then feed+fetch+react.
//   * Stub (no esp-sr): count bytes for heartbeat logging.
//
// Called from mic_broker's task context (stack 4096); must not block long.
static void on_npc_frame(const int16_t *pcm, size_t samples, void *ctx) {
    (void)ctx;

    if (voice_pipeline_wake_word_active()) {
        // ── AFE path ─────────────────────────────────────────────────────
        const int need = s_pipe.afe_feed_chunk_samples * s_pipe.afe_feed_channel_num;
        if (!s_pipe.afe_accum) return;  // alloc failed at init; silently skip

        // Slice 9: mute-during-TTS gate — drain the mic but do not feed AFE.
        if (s_pipe.state == VOICE_STATE_SPEAKING) {
            s_pipe.afe_accum_fill = 0;  // also reset accumulator on mute
            return;
        }

        size_t copied = 0;
        while (copied < samples) {
            size_t space = (size_t)need - s_pipe.afe_accum_fill;
            size_t take  = samples - copied;
            if (take > space) take = space;
            memcpy(s_pipe.afe_accum + s_pipe.afe_accum_fill, pcm + copied,
                   take * sizeof(int16_t));
            s_pipe.afe_accum_fill += take;
            copied += take;

            if ((int)s_pipe.afe_accum_fill < need) break;  // not full yet

            // Buffer full — feed AFE.
            // INMP441 single-mic: afe_feed_channel_num == 1, so accum IS
            // the feed buffer directly (no interleaving needed).
            s_pipe.afe_iface->feed(s_pipe.afe_data, s_pipe.afe_accum);
            s_pipe.afe_accum_fill = 0;

            // Fetch result (non-blocking).
            static uint32_t s_feeds = 0;
            afe_fetch_result_t *res = s_pipe.afe_iface->fetch_with_delay(
                s_pipe.afe_data, 0);
            if (res && res->ret_value == ESP_OK) {
                if (res->wakeup_state == WAKENET_DETECTED) {
                    const char *word = s_pipe.wake_word_name;
                    ESP_LOGI(TAG, "WAKE detected: word=%s vol=%.1fdB chan=%d",
                             word, res->data_volume, res->trigger_channel_id);
                    voice_pipeline_set_state(VOICE_STATE_LISTENING);
                    if (s_pipe.wake_cb) {
                        s_pipe.wake_cb(word, s_pipe.wake_cb_ctx);
                    }
                    // Slice 7: open WS stream immediately on wake so
                    // the player's first words are captured.
                    if (voice_ws_is_configured() && !s_pipe.stream_active) {
                        if (streaming_begin() != ESP_OK) {
                            ESP_LOGW(TAG, "streaming_begin failed at wake — "
                                          "returning to IDLE");
                            voice_pipeline_set_state(VOICE_STATE_IDLE);
                        }
                    }
                }

                // Slice 7: push post-AFE PCM and watch VAD.
                if (s_pipe.stream_active && res->data && res->data_size > 0) {
                    esp_err_t serr = voice_ws_send_chunk(
                        res->data, res->data_size / sizeof(int16_t));
                    if (serr != ESP_OK) {
                        ESP_LOGW(TAG, "send_chunk err %s — closing stream",
                                 esp_err_to_name(serr));
                        streaming_end("send_error");
                    } else {
                        s_pipe.streamed_chunks++;
                        // res->vad_state: VAD_SILENCE=0, VAD_SPEECH=1
                        if (res->vad_state == VAD_SILENCE) {
                            s_pipe.silence_chunks++;
                        } else {
                            s_pipe.silence_chunks = 0;
                        }
                        if (s_pipe.silence_chunks >= END_SILENCE_CHUNKS) {
                            streaming_end("vad_silence");
                        } else if (s_pipe.streamed_chunks >= STREAMING_MAX_CHUNKS) {
                            streaming_end("max_duration");
                        }
                    }
                }
            }

            if (++s_feeds % 100 == 0) {
                ESP_LOGD(TAG, "AFE feed heartbeat: %u chunks", (unsigned)s_feeds);
            }
        }
    } else {
        // ── Stub path (no esp-sr) ─────────────────────────────────────────
        static uint32_t s_total  = 0;
        static uint32_t s_ticks  = 0;
        s_total += (uint32_t)(samples * sizeof(int16_t));
        if (++s_ticks % 80 == 0) {   // ~80 × 20 ms ≈ 1.6 s
            ESP_LOGI(TAG, "capture heartbeat: %u bytes total", (unsigned)s_total);
        }
    }
}

esp_err_t voice_pipeline_init(const voice_pipeline_config_t *config) {
    if (s_pipe.ready) return ESP_OK;
    voice_pipeline_config_t cfg;
    if (config) {
        cfg = *config;
    } else {
        voice_pipeline_default_config(&cfg);
    }
    s_pipe.cfg = cfg;

    session_id_init();

    // Bring up mic_broker (single I2S RX owner).
    // ESP_ERR_INVALID_STATE means the broker is already running (another
    // caller initialised it first) — treat as success.
    esp_err_t err = mic_broker_init(cfg.i2s_bclk_pin, cfg.i2s_ws_pin,
                                    cfg.i2s_din_pin, 16000);
    if (err != ESP_OK && err != ESP_ERR_INVALID_STATE) {
        ESP_LOGW(TAG, "mic_broker_init failed: %s — staying idle without capture",
                 esp_err_to_name(err));
        // Don't fail init: state machine stays usable.
        s_pipe.ready = true;
        s_pipe.state = VOICE_STATE_IDLE;
        return ESP_OK;
    }
    mic_broker_register(MIC_NPC_LISTEN, on_npc_frame, &s_pipe);

    // Slice 9: optional I2S TX bring-up for TTS playback. Failure is
    // non-fatal — the rest of the voice loop still works.
    if (cfg.enable_tts_playback) {
        esp_err_t te = i2s_tx_setup();
        if (te != ESP_OK) {
            ESP_LOGW(TAG, "i2s_tx_setup failed: %s — TTS playback disabled",
                     esp_err_to_name(te));
        } else {
            ESP_LOGI(TAG, "I2S TX ready (BCLK=%d LRC=%d DIN=%d) — TTS enabled",
                     cfg.i2s_out_bclk_pin, cfg.i2s_out_lrc_pin,
                     cfg.i2s_out_din_pin);
        }
    }

    if (cfg.enable_wake_word) {
        esp_err_t we = wake_word_setup();
        if (we != ESP_OK) {
            ESP_LOGW(TAG, "wake_word_setup failed: %s — degrading to stub capture",
                     esp_err_to_name(we));
            // Don't fail init — the rest of the firmware should still
            // come up. The callback will run in slice-5 stub mode.
            wake_word_teardown();
        } else {
            // Broker delivers linear mono; AFE must be configured for 1 channel.
            if (s_pipe.afe_feed_channel_num != 1) {
                ESP_LOGE(TAG, "AFE feed_channel_num=%d but broker delivers mono (1) — "
                              "configuration mismatch", s_pipe.afe_feed_channel_num);
                wake_word_teardown();
                return ESP_ERR_NOT_SUPPORTED;
            }
            // Allocate the AFE accumulator now that we know chunk sizes.
            size_t feed_bytes = (size_t)s_pipe.afe_feed_chunk_samples
                                * s_pipe.afe_feed_channel_num * sizeof(int16_t);
            s_pipe.afe_accum = heap_caps_malloc(feed_bytes,
                                                MALLOC_CAP_SPIRAM | MALLOC_CAP_8BIT);
            if (!s_pipe.afe_accum) {
                ESP_LOGW(TAG, "PSRAM alloc for AFE accum failed (%u B) — "
                              "degrading to stub capture", (unsigned)feed_bytes);
                wake_word_teardown();
            } else {
                s_pipe.afe_accum_fill = 0;
                ESP_LOGI(TAG, "AFE accum alloc OK (%u B)", (unsigned)feed_bytes);
            }
        }
    }

    // Slice 7: configure the WS layer if the caller provided a URL.
    // The actual connection is opened lazily on the first wake (or
    // on voice_pipeline_start_streaming).
    if (cfg.voice_bridge_ws_url && *cfg.voice_bridge_ws_url) {
        esp_err_t wsc = voice_ws_configure(cfg.voice_bridge_ws_url,
                                           s_pipe.session_id,
                                           cfg.sample_rate_hz);
        if (wsc != ESP_OK) {
            ESP_LOGW(TAG, "voice_ws_configure failed: %s — streaming disabled",
                     esp_err_to_name(wsc));
        } else {
            ESP_LOGI(TAG, "voice-bridge streaming wired: %s",
                     cfg.voice_bridge_ws_url);
        }
    }

    s_pipe.ready = true;
    s_pipe.state = VOICE_STATE_IDLE;
    ESP_LOGI(TAG, "ready (BCLK=%d WS=%d DIN=%d @%u Hz, wake=%s, stream=%s)",
             cfg.i2s_bclk_pin, cfg.i2s_ws_pin, cfg.i2s_din_pin,
             (unsigned) cfg.sample_rate_hz,
             voice_pipeline_wake_word_active() ? s_pipe.wake_word_name : "off",
             voice_ws_is_configured() ? "on" : "off");

    if (cfg.auto_start_capture) {
        return voice_pipeline_start_capture();
    }
    return ESP_OK;
}

esp_err_t voice_pipeline_start_capture(void) {
    if (!s_pipe.ready) return ESP_ERR_INVALID_STATE;
    // Reset stale partial accumulator from a previous stop before new session.
    s_pipe.afe_accum_fill = 0;
    // Route mic frames to the NPC callback via the broker.
    mic_broker_set_mode(MIC_NPC_LISTEN);
    voice_pipeline_set_state(VOICE_STATE_LISTENING);
    return ESP_OK;
}

esp_err_t voice_pipeline_stop_capture(void) {
    if (!s_pipe.ready) return ESP_ERR_INVALID_STATE;
    // Stop delivery to the NPC callback.
    mic_broker_set_mode(MIC_IDLE);
    // Close any in-flight stream so we don't leak a WS connection.
    if (s_pipe.stream_active) {
        streaming_end("capture_stop");
    }
    voice_pipeline_set_state(VOICE_STATE_IDLE);
    return ESP_OK;
}

voice_state_t voice_pipeline_get_state(void) {
    return s_pipe.state;
}

esp_err_t voice_pipeline_set_state(voice_state_t state) {
    s_pipe.state = state;
    return ESP_OK;
}

// ── Slice 9: TTS playback over I2S TX (I2S_NUM_1) ────────────────────────────

esp_err_t voice_pipeline_play_start(uint32_t sample_rate, const char *format) {
    if (!s_pipe.ready) return ESP_ERR_INVALID_STATE;
    if (!s_pipe.tx_chan) {
        ESP_LOGW(TAG, "play_start: no TX channel (enable_tts_playback=false?)");
        return ESP_ERR_INVALID_STATE;
    }
    // Serialise the shared TX channel: only one playback session (TTS or
    // media_manager) may own the DAC at a time. Held until play_end().
    if (s_pipe.play_lock &&
        xSemaphoreTake(s_pipe.play_lock, pdMS_TO_TICKS(2000)) != pdTRUE) {
        ESP_LOGW(TAG, "play_start: busy — another playback owns the I2S TX");
        return ESP_ERR_TIMEOUT;
    }
    // Reconfigure the I2S clock if the bridge announces a different rate.
    // F5-TTS produces 24 kHz; the mic side runs at 16 kHz by default.
    if (sample_rate != 0 && sample_rate != s_pipe.tx_sample_rate) {
        if (s_pipe.tx_enabled) {
            i2s_channel_disable(s_pipe.tx_chan);
            s_pipe.tx_enabled = false;
        }
        i2s_std_clk_config_t clk = I2S_STD_CLK_DEFAULT_CONFIG(sample_rate);
        esp_err_t err = i2s_channel_reconfig_std_clock(s_pipe.tx_chan, &clk);
        if (err != ESP_OK) {
            ESP_LOGW(TAG, "play_start: clk reconfig %u Hz failed: %s",
                     (unsigned) sample_rate, esp_err_to_name(err));
            if (s_pipe.play_lock) xSemaphoreGive(s_pipe.play_lock);
            return err;
        }
        s_pipe.tx_sample_rate = sample_rate;
    }
    if (!s_pipe.tx_enabled) {
        esp_err_t err = i2s_channel_enable(s_pipe.tx_chan);
        if (err != ESP_OK) {
            if (s_pipe.play_lock) xSemaphoreGive(s_pipe.play_lock);
            return err;
        }
        s_pipe.tx_enabled = true;
    }
    voice_pipeline_set_state(VOICE_STATE_SPEAKING);
    ESP_LOGI(TAG, "play_start: sr=%u format=%s",
             (unsigned) sample_rate, format ? format : "(null)");
    return ESP_OK;
}

esp_err_t voice_pipeline_play_chunk(const uint8_t *buf, size_t len) {
    if (!s_pipe.ready || !s_pipe.tx_chan || !s_pipe.tx_enabled) {
        return ESP_ERR_INVALID_STATE;
    }
    if (!buf || len == 0) return ESP_ERR_INVALID_ARG;
    size_t written = 0;
    esp_err_t err = i2s_channel_write(s_pipe.tx_chan, buf, len, &written,
                                      pdMS_TO_TICKS(100));
    if (err != ESP_OK) {
        ESP_LOGW(TAG, "play_chunk: i2s write err=%s wrote=%u/%u",
                 esp_err_to_name(err), (unsigned) written, (unsigned) len);
    }
    return err;
}

esp_err_t voice_pipeline_play_end(void) {
    if (!s_pipe.ready) return ESP_ERR_INVALID_STATE;
    if (s_pipe.tx_chan && s_pipe.tx_enabled) {
        i2s_channel_disable(s_pipe.tx_chan);
        s_pipe.tx_enabled = false;
    }
    voice_pipeline_set_state(VOICE_STATE_IDLE);
    ESP_LOGI(TAG, "play_end");
    if (s_pipe.play_lock) xSemaphoreGive(s_pipe.play_lock);
    return ESP_OK;
}
