/*
 * net.c — WiFi STA + minimal HTTP server for the PLIP voice annex.
 *
 * WiFi credentials: read from NVS namespace "wifi" (keys "ssid"/"pwd")
 * first; fall back to Kconfig compile-time defaults if NVS is empty.
 * This mirrors the pattern used in the idf_zacus master (main.c:load_wifi_creds).
 *
 * HTTP endpoints (Phase B):
 *   GET  /status              → { "ip":"...", "playing":false, "off_hook":false }
 *   POST /game/scenario       → store Runtime 3 IR to SPIFFS (same as box3_voice)
 *   POST /game/file?path=...  → write an arbitrary file to SPIFFS (Phase B proof)
 *
 * The httpd handle is exposed via net_httpd_handle() so other modules
 * (hook_client, cmd_exec) can register additional routes.
 */

#include "net.h"
#include "dialer.h"

#include <inttypes.h>
#include <stdlib.h>
#include <string.h>
#include <stdio.h>
#include <sys/stat.h>
#include <errno.h>

#include "audio.h"
#include "cmd_exec.h"
#include "phone.h"
#include "es8388.h"
#include "slic.h"
#include "board_config.h"
#include "driver/gpio.h"
#include "esp_err.h"
#include "esp_event.h"
#include "esp_http_server.h"
#include "esp_log.h"
#include "esp_netif.h"
#include "esp_spiffs.h"
#include "esp_wifi.h"
#include "freertos/FreeRTOS.h"
#include "freertos/event_groups.h"
#include "freertos/task.h"
#include "nvs.h"
#include "nvs_flash.h"
#include "cJSON.h"

#define TAG "net"

#define WIFI_CONNECT_TIMEOUT_MS 30000
#define WIFI_POLL_MS            250

#define SPIFFS_LABEL  "storage"
#define SPIFFS_BASE   "/spiffs"
#define MAX_BODY      (256 * 1024)

static volatile bool    s_connected  = false;
static httpd_handle_t   s_httpd      = NULL;
static bool             s_spiffs_ok  = false;

/* ── NVS WiFi cred loader (mirrors idf_zacus main.c) ─────────────────────── */

static esp_err_t load_wifi_creds(char *ssid, size_t ssid_len,
                                  char *pwd,  size_t pwd_len)
{
    nvs_handle_t h;
    esp_err_t err = nvs_open("wifi", NVS_READONLY, &h);
    if (err != ESP_OK) return err;

    size_t sl = ssid_len, pl = pwd_len;
    err  = nvs_get_str(h, "ssid", ssid, &sl);
    err |= nvs_get_str(h, "pwd",  pwd,  &pl);
    nvs_close(h);

    if (err != ESP_OK || ssid[0] == '\0') return ESP_ERR_NOT_FOUND;
    return ESP_OK;
}

/* ── WiFi event handler ───────────────────────────────────────────────────── */

static void wifi_event_handler(void *arg, esp_event_base_t base,
                               int32_t event_id, void *data)
{
    if (base == WIFI_EVENT && event_id == WIFI_EVENT_STA_START) {
        esp_wifi_connect();
    } else if (base == WIFI_EVENT && event_id == WIFI_EVENT_STA_DISCONNECTED) {
        if (!s_connected) return;   /* still in initial connect loop */
        ESP_LOGW(TAG, "WiFi disconnected, reconnecting...");
        s_connected = false;
        vTaskDelay(pdMS_TO_TICKS(2000));
        esp_wifi_connect();
    } else if (base == IP_EVENT && event_id == IP_EVENT_STA_GOT_IP) {
        ip_event_got_ip_t *ev = (ip_event_got_ip_t *)data;
        ESP_LOGI(TAG, "WiFi connected — IP: " IPSTR, IP2STR(&ev->ip_info.ip));
        s_connected = true;
    }
}

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

static void ensure_spiffs(void)
{
    if (s_spiffs_ok) return;
    esp_vfs_spiffs_conf_t conf = {
        .base_path              = SPIFFS_BASE,
        .partition_label        = SPIFFS_LABEL,
        .max_files              = 8,
        .format_if_mount_failed = true,
    };
    esp_err_t ret = esp_vfs_spiffs_register(&conf);
    if (ret == ESP_OK || ret == ESP_ERR_INVALID_STATE) {
        s_spiffs_ok = true;
        ESP_LOGI(TAG, "SPIFFS mounted at %s", SPIFFS_BASE);
    } else {
        ESP_LOGE(TAG, "SPIFFS mount failed: %s", esp_err_to_name(ret));
    }
}

/* ── HTTP helpers ─────────────────────────────────────────────────────────── */

static esp_err_t send_json(httpd_req_t *req, const char *status,
                            const char *body)
{
    httpd_resp_set_status(req, status);
    httpd_resp_set_type(req, "application/json");
    httpd_resp_set_hdr(req, "Access-Control-Allow-Origin", "*");
    return httpd_resp_sendstr(req, body);
}

/* ── GET /status ──────────────────────────────────────────────────────────── */

static esp_err_t handle_status(httpd_req_t *req)
{
    char buf[128];
    esp_netif_ip_info_t ip_info = {0};
    esp_netif_t *sta = esp_netif_get_handle_from_ifkey("WIFI_STA_DEF");
    if (sta) esp_netif_get_ip_info(sta, &ip_info);

    snprintf(buf, sizeof(buf),
             "{\"board\":\"plip\",\"ip\":\"" IPSTR "\",\"playing\":false,\"off_hook\":false}",
             IP2STR(&ip_info.ip));
    return send_json(req, "200 OK", buf);
}

/* ── POST /game/scenario ──────────────────────────────────────────────────── */

static esp_err_t handle_scenario_post(httpd_req_t *req)
{
    if (req->content_len <= 0 || req->content_len > MAX_BODY)
        return send_json(req, "413 Payload Too Large", "{\"error\":\"too large\"}");

    ensure_spiffs();

    char *body = malloc((size_t)req->content_len + 1);
    if (!body) return send_json(req, "500 Internal Server Error", "{\"error\":\"oom\"}");

    int total = 0;
    while (total < (int)req->content_len) {
        int got = httpd_req_recv(req, body + total, req->content_len - total);
        if (got <= 0) { free(body); return ESP_FAIL; }
        total += got;
    }
    body[total] = '\0';

    const char *path = SPIFFS_BASE "/scenario.json";
    FILE *f = fopen(path, "wb");
    if (!f) {
        free(body);
        return send_json(req, "500 Internal Server Error", "{\"error\":\"write failed\"}");
    }
    fwrite(body, 1, (size_t)total, f);
    fclose(f);
    free(body);

    char resp[128];
    snprintf(resp, sizeof(resp),
             "{\"status\":\"ok\",\"board\":\"plip\",\"bytes\":%d}", total);
    ESP_LOGI(TAG, "scenario stored: %d bytes", total);
    return send_json(req, "200 OK", resp);
}

/* ── POST /game/file?path=...  (Phase B proof: write file to SPIFFS) ─────── */

/* Streaming chunk size for /game/file — keeps heap usage under 4 KB. */
#define FILE_RECV_CHUNK 2048

static esp_err_t handle_file_post(httpd_req_t *req)
{
    /* Extract ?path= query parameter */
    char query[256] = {0};
    httpd_req_get_url_query_str(req, query, sizeof(query));
    char fpath[128] = {0};
    if (httpd_query_key_value(query, "path", fpath, sizeof(fpath)) != ESP_OK) {
        return send_json(req, "400 Bad Request", "{\"error\":\"missing path param\"}");
    }

    if (req->content_len <= 0 || req->content_len > MAX_BODY)
        return send_json(req, "413 Payload Too Large", "{\"error\":\"too large\"}");

    ensure_spiffs();

    /* Build full SPIFFS path. */
    char full[160];
    if (fpath[0] == '/') {
        snprintf(full, sizeof(full), "%s%s", SPIFFS_BASE, fpath);
    } else {
        snprintf(full, sizeof(full), "%s/%s", SPIFFS_BASE, fpath);
    }

    FILE *f = fopen(full, "wb");
    if (!f) {
        ESP_LOGW(TAG, "fopen %s failed: errno=%d", full, errno);
        return send_json(req, "500 Internal Server Error", "{\"error\":\"write failed\"}");
    }

    /* Stream body to file using a small stack buffer — no large malloc needed. */
    char chunk[FILE_RECV_CHUNK];
    int remaining = (int)req->content_len;
    size_t written = 0;
    while (remaining > 0) {
        int to_recv = (remaining < FILE_RECV_CHUNK) ? remaining : FILE_RECV_CHUNK;
        int got = httpd_req_recv(req, chunk, to_recv);
        if (got <= 0) {
            fclose(f);
            return ESP_FAIL;
        }
        size_t fw = fwrite(chunk, 1, (size_t)got, f);
        if ((int)fw != got) {
            ESP_LOGW(TAG, "fwrite short: %zu/%d (disk full?)", fw, got);
            fclose(f);
            return send_json(req, "500 Internal Server Error", "{\"error\":\"write failed\"}");
        }
        written += fw;
        remaining -= got;
    }
    fclose(f);

    char resp[256];
    snprintf(resp, sizeof(resp),
             "{\"status\":\"ok\",\"path\":\"%s\",\"bytes\":%zu}", full, written);
    ESP_LOGI(TAG, "file stored: %s (%zu bytes)", full, written);
    return send_json(req, "200 OK", resp);
}

/* ── POST /voice/capture ──────────────────────────────────────────────────── */

#define CAP_MAX_MS_DEFAULT      5000
#define CAP_SILENCE_MS_DEFAULT   800
/* Max capture duration: 10 s (no large buffer needed — streaming approach). */
#define CAP_MAX_ALLOWED_MS  10000

/*
 * Streaming WAV capture handler.
 *
 * Memory budget: only two small buffers on stack:
 *   - WAV header : 44 bytes
 *   - mono frame : 320 × 2 = 640 bytes
 * The audio driver uses an additional 1280-byte scratch buf for the stereo I2S read.
 * Total heap usage during capture: ~2 KB (vs 128+ KB for buffer-at-once approach).
 *
 * The response uses chunked transfer encoding via httpd_resp_send_chunk().
 * We send the WAV header first (with data_size = 0xFFFFFFFF as placeholder since
 * length is unknown), then PCM frames, then finalize with httpd_resp_send_chunk(NULL,0).
 * Most WAV players / ffmpeg handle the placeholder length gracefully.
 */
static esp_err_t handle_voice_capture(httpd_req_t *req)
{
    /* Parse optional query params: max_ms, silence_ms */
    char query[128] = {0};
    httpd_req_get_url_query_str(req, query, sizeof(query));

    char val[16] = {0};
    int max_ms     = CAP_MAX_MS_DEFAULT;
    int silence_ms = CAP_SILENCE_MS_DEFAULT;
    if (httpd_query_key_value(query, "max_ms", val, sizeof(val)) == ESP_OK) {
        int v = atoi(val);
        if (v > 0 && v <= CAP_MAX_ALLOWED_MS) max_ms = v;
        else if (v > CAP_MAX_ALLOWED_MS) max_ms = CAP_MAX_ALLOWED_MS;
    }
    memset(val, 0, sizeof(val));
    if (httpd_query_key_value(query, "silence_ms", val, sizeof(val)) == ESP_OK) {
        int v = atoi(val);
        if (v > 0 && v <= 5000) silence_ms = v;
    }
    ESP_LOGI(TAG, "POST /voice/capture max_ms=%d silence_ms=%d (streaming)", max_ms, silence_ms);

    /* Start capture (enables I2S RX, disables TX). */
    if (audio_capture_begin(max_ms, silence_ms) != 0) {
        httpd_resp_send_err(req, HTTPD_500_INTERNAL_SERVER_ERROR, "capture init failed");
        return ESP_FAIL;
    }

    /* Send HTTP headers. */
    httpd_resp_set_type(req, "audio/wav");
    httpd_resp_set_hdr(req, "Access-Control-Allow-Origin", "*");

    /* WAV header with placeholder data_size (0xFFFFFFFF = unknown length). */
    const uint32_t cap_rate    = 16000;
    const uint16_t cap_ch      = 1;
    const uint16_t cap_bits    = 16;
    const uint32_t byte_rate   = cap_rate * cap_ch * (cap_bits / 8);
    const uint16_t block_align = (uint16_t)(cap_ch * (cap_bits / 8));
    const uint32_t data_size   = 0xFFFFFFFFu;  /* placeholder, updated below if possible */
    const uint32_t riff_size   = 0xFFFFFFFFu;
    uint16_t fmt_pcm  = 1;
    uint32_t fmt_size = 16;

    uint8_t hdr[44];
    memcpy(hdr +  0, "RIFF", 4);  memcpy(hdr +  4, &riff_size,   4);
    memcpy(hdr +  8, "WAVE", 4);  memcpy(hdr + 12, "fmt ", 4);
    memcpy(hdr + 16, &fmt_size,   4);  memcpy(hdr + 20, &fmt_pcm,     2);
    memcpy(hdr + 22, &cap_ch,     2);  memcpy(hdr + 24, &cap_rate,    4);
    memcpy(hdr + 28, &byte_rate,  4);  memcpy(hdr + 32, &block_align, 2);
    memcpy(hdr + 34, &cap_bits,   2);
    memcpy(hdr + 36, "data", 4);  memcpy(hdr + 40, &data_size,   4);

    esp_err_t ret = httpd_resp_send_chunk(req, (const char *)hdr, sizeof(hdr));
    if (ret != ESP_OK) { audio_capture_end(); return ESP_FAIL; }

    /* VAD state (mirrors audio_capture_wav logic). */
    const int64_t vad_onset_sq   = (int64_t)328 * 328;
    const int64_t vad_silence_sq  = (int64_t)100 * 100;
    const int     max_frames      = max_ms / 20;
    const int     silence_frames  = silence_ms / 20;
    bool          voice_started   = false;
    int           silent_count    = 0;
    int           total_frames    = 0;
    size_t        total_pcm_bytes = 0;

    /* Per-frame mono buffer: 320 × int16_t = 640 bytes on stack. */
    int16_t mono_frame[320];

    for (int f = 0; f < max_frames; f++) {
        int64_t rms_sq = 0;
        int n = audio_capture_read_frame(mono_frame, 320, &rms_sq);
        if (n < 0) { ESP_LOGW(TAG, "capture: read error at frame %d", f); break; }
        if (n == 0) continue;  /* timeout, retry */

        /* Send PCM chunk. */
        size_t frame_bytes = (size_t)n * sizeof(int16_t);
        ret = httpd_resp_send_chunk(req, (const char *)mono_frame, (ssize_t)frame_bytes);
        if (ret != ESP_OK) { ESP_LOGW(TAG, "capture: send_chunk failed"); break; }
        total_pcm_bytes += frame_bytes;
        total_frames = f + 1;

        /* VAD. */
        if (!voice_started) {
            if (rms_sq >= vad_onset_sq) {
                voice_started = true;
                silent_count  = 0;
                ESP_LOGI(TAG, "capture: voice onset frame %d rms²=%"PRId64, f, rms_sq);
            }
        } else {
            if (rms_sq < vad_silence_sq) {
                if (++silent_count >= silence_frames) {
                    ESP_LOGI(TAG, "capture: VAD end frame %d", f);
                    break;
                }
            } else {
                silent_count = 0;
            }
        }
    }

    audio_capture_end();

    /* Finalize chunked transfer. */
    httpd_resp_send_chunk(req, NULL, 0);

    float dur = (float)total_pcm_bytes / (float)(cap_rate * (cap_bits / 8) * cap_ch);
    ESP_LOGI(TAG, "capture done: %d frames %.2fs %zu PCM bytes sent", total_frames, dur, total_pcm_bytes);
    return ESP_OK;
}

/* ── GET /debug/regs  (ES8388 register dump for ADC diagnosis) ─────────────── */

static esp_err_t handle_debug_regs(httpd_req_t *req)
{
    /* Read key ES8388 registers to diagnose ADC path. */
    uint8_t regs[0x40] = {0};
    for (int i = 0; i < 0x40; i++) {
        es8388_read_reg((uint8_t)i, &regs[i]);
    }
    char buf[1024];
    /* All register names use Espressif official numbering (ADCCONTROL1=reg0x09, etc.) */
    int n = snprintf(buf, sizeof(buf),
        "{\"CTL1\":\"0x%02X\",\"CTL2\":\"0x%02X\",\"CHIPPOWER\":\"0x%02X\","
        "\"ADCPOWER\":\"0x%02X\",\"DACPOWER\":\"0x%02X\","
        "\"ADCCONTROL1\":\"0x%02X\",\"ADCCONTROL2\":\"0x%02X\",\"ADCCONTROL3\":\"0x%02X\","
        "\"ADCCONTROL4\":\"0x%02X\",\"ADCCONTROL5\":\"0x%02X\",\"ADCCONTROL6\":\"0x%02X\","
        "\"ADCCONTROL7\":\"0x%02X\",\"ADCCONTROL8\":\"0x%02X\",\"ADCCONTROL9\":\"0x%02X\","
        "\"DACCTL1\":\"0x%02X\",\"DACCTL2\":\"0x%02X\",\"DACCTL3\":\"0x%02X\","
        "\"DACCTL21\":\"0x%02X\",\"DACCTL24\":\"0x%02X\",\"DACCTL25\":\"0x%02X\","
        "\"MASTERMODE\":\"0x%02X\",\"CHIPCTL3\":\"0x%02X\",\"CHIPLP\":\"0x%02X\"}",
        regs[0x00], regs[0x01], regs[0x02],
        regs[0x03], regs[0x04],
        regs[0x09], regs[0x0A], regs[0x0B],
        regs[0x0C], regs[0x0D], regs[0x0E],
        regs[0x0F], regs[0x10], regs[0x11],
        regs[0x17], regs[0x18], regs[0x19],
        regs[0x2B], regs[0x2E], regs[0x2F],
        regs[0x08], regs[0x06], regs[0x05]);
    httpd_resp_set_type(req, "application/json");
    httpd_resp_set_hdr(req, "Access-Control-Allow-Origin", "*");
    return httpd_resp_send(req, buf, n);
}

/* ── POST /game/cmd  (WiFi-direct CMD endpoint) ───────────────────────────── */

#define MAX_CMD_BYTES 512

static esp_err_t handle_cmd_post(httpd_req_t *req)
{
    if (req->content_len <= 0 || req->content_len > MAX_CMD_BYTES)
        return send_json(req, "400 Bad Request", "{\"error\":\"body must be 1..512 bytes\"}");

    char body[MAX_CMD_BYTES + 1];
    int total = 0;
    while (total < (int)req->content_len) {
        int got = httpd_req_recv(req, body + total, req->content_len - total);
        if (got <= 0) {
            if (got == HTTPD_SOCK_ERR_TIMEOUT) continue;
            return send_json(req, "400 Bad Request", "{\"error\":\"recv failed\"}");
        }
        total += got;
    }
    body[total] = '\0';

    ESP_LOGI(TAG, "POST /game/cmd (%d B): %.*s", total, total < 80 ? total : 80, body);
    esp_err_t err = cmd_exec_handle(body, (size_t)total);
    if (err == ESP_ERR_INVALID_ARG)
        return send_json(req, "400 Bad Request", "{\"error\":\"malformed cmd json or missing op\"}");
    return send_json(req, "200 OK", "{\"ok\":true}");
}

/* ── GET /debug/ring  (trigger ring tone over HTTP) ──────────────────────── */

static esp_err_t handle_debug_ring(httpd_req_t *req)
{
    phone_ring_start();
    ESP_LOGI(TAG, "debug/ring: ring started");
    return send_json(req, "200 OK", "{\"ok\":true,\"ringing\":true}");
}

/* ── GET /debug/ringstop  (stop ring tone over HTTP) ─────────────────────── */

static esp_err_t handle_debug_ringstop(httpd_req_t *req)
{
    phone_ring_stop();
    ESP_LOGI(TAG, "debug/ringstop: ring stopped");
    return send_json(req, "200 OK", "{\"ok\":true,\"ringing\":false}");
}

/* ── GET /debug/slic  (read GPIO levels of all 4 SLIC pins) ──────────────── */

static esp_err_t handle_debug_slic(httpd_req_t *req)
{
    char buf[192];
    /* gpio_get_level reads the actual pad level even for output pins. */
    snprintf(buf, sizeof(buf),
             "{\"rm\":%d,\"fr\":%d,\"shk\":%d,\"pd\":%d,\"offhook\":%s,\"ringing\":%s}",
             gpio_get_level(PLIP_SLIC_RM), gpio_get_level(PLIP_SLIC_FR),
             gpio_get_level(PLIP_SLIC_SHK), gpio_get_level(PLIP_SLIC_PD),
             slic_is_offhook() ? "true" : "false",
             slic_is_ringing() ? "true" : "false");
    ESP_LOGI(TAG, "debug/slic: %s", buf);
    return send_json(req, "200 OK", buf);
}

/* ── GET /debug/dial?number=NNNN  (push digits into the dialer) ───────────── */

static esp_err_t handle_debug_dial(httpd_req_t *req)
{
    char query[64] = {0};
    httpd_req_get_url_query_str(req, query, sizeof(query));
    char number[16] = {0};
    if (httpd_query_key_value(query, "number", number, sizeof(number)) != ESP_OK) {
        return send_json(req, "400 Bad Request", "{\"error\":\"missing number param\"}");
    }

    int pushed = 0;
    for (int i = 0; number[i] != '\0' && i < 12; i++) {
        if (number[i] >= '0' && number[i] <= '9') {
            dialer_push_digit(number[i] - '0');
            pushed++;
        }
    }

    char resp[64];
    snprintf(resp, sizeof(resp), "{\"ok\":true,\"pushed\":%d,\"number\":\"%s\"}",
             pushed, dialer_current());
    ESP_LOGI(TAG, "debug/dial: pushed %d digits -> \"%s\"", pushed, dialer_current());
    return send_json(req, "200 OK", resp);
}

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

bool net_is_connected(void)
{
    return s_connected;
}

httpd_handle_t net_httpd_handle(void)
{
    return s_httpd;
}

esp_err_t net_init(void)
{
    /* WiFi credentials: NVS first, then Kconfig compile-time defaults. */
    char ssid[64] = CONFIG_PLIP_WIFI_SSID;
    char pwd[64]  = CONFIG_PLIP_WIFI_PASSWORD;

    char nvs_ssid[64] = {0}, nvs_pwd[64] = {0};
    if (load_wifi_creds(nvs_ssid, sizeof(nvs_ssid),
                        nvs_pwd,  sizeof(nvs_pwd)) == ESP_OK) {
        strncpy(ssid, nvs_ssid, sizeof(ssid) - 1);
        strncpy(pwd,  nvs_pwd,  sizeof(pwd)  - 1);
        ESP_LOGI(TAG, "WiFi creds from NVS: SSID=\"%s\"", ssid);
    } else {
        ESP_LOGI(TAG, "WiFi creds from Kconfig: SSID=\"%s\"", ssid);
    }

    /* Network stack init. */
    ESP_ERROR_CHECK(esp_netif_init());
    ESP_ERROR_CHECK(esp_event_loop_create_default());
    esp_netif_create_default_wifi_sta();

    wifi_init_config_t cfg = WIFI_INIT_CONFIG_DEFAULT();
    ESP_ERROR_CHECK(esp_wifi_init(&cfg));

    ESP_ERROR_CHECK(esp_event_handler_instance_register(
        WIFI_EVENT, ESP_EVENT_ANY_ID, wifi_event_handler, NULL, NULL));
    ESP_ERROR_CHECK(esp_event_handler_instance_register(
        IP_EVENT, IP_EVENT_STA_GOT_IP, wifi_event_handler, NULL, NULL));

    wifi_config_t wcfg = {
        .sta = {
            .threshold.authmode = (strlen(pwd) == 0)
                                      ? WIFI_AUTH_OPEN : WIFI_AUTH_WPA2_PSK,
            .channel = CONFIG_PLIP_WIFI_CHANNEL,
        },
    };
    strncpy((char *)wcfg.sta.ssid,     ssid, sizeof(wcfg.sta.ssid) - 1);
    strncpy((char *)wcfg.sta.password,  pwd,  sizeof(wcfg.sta.password) - 1);

    ESP_ERROR_CHECK(esp_wifi_set_mode(WIFI_MODE_STA));
    ESP_ERROR_CHECK(esp_wifi_set_config(WIFI_IF_STA, &wcfg));
    ESP_ERROR_CHECK(esp_wifi_start());

    ESP_LOGI(TAG, "WiFi STA started, connecting to '%s' (ch=%d)...",
             ssid, CONFIG_PLIP_WIFI_CHANNEL);

    /* Wait for IP with timeout. */
    const uint32_t deadline = xTaskGetTickCount() +
                              pdMS_TO_TICKS(WIFI_CONNECT_TIMEOUT_MS);
    while (!s_connected && xTaskGetTickCount() < deadline) {
        vTaskDelay(pdMS_TO_TICKS(WIFI_POLL_MS));
    }
    if (!s_connected) {
        ESP_LOGW(TAG, "WiFi connect timeout — continuing offline");
        return ESP_ERR_TIMEOUT;
    }

    /* Start HTTP server. */
    httpd_config_t hcfg = HTTPD_DEFAULT_CONFIG();
    hcfg.server_port      = 80;
    hcfg.max_uri_handlers = 17;
    hcfg.stack_size       = 8192;

    esp_err_t ret = httpd_start(&s_httpd, &hcfg);
    if (ret != ESP_OK) {
        ESP_LOGE(TAG, "httpd_start: %s", esp_err_to_name(ret));
        return ret;
    }

    static const httpd_uri_t uri_status = {
        .uri = "/status", .method = HTTP_GET,
        .handler = handle_status, .user_ctx = NULL,
    };
    static const httpd_uri_t uri_scenario = {
        .uri = "/game/scenario", .method = HTTP_POST,
        .handler = handle_scenario_post, .user_ctx = NULL,
    };
    static const httpd_uri_t uri_file = {
        .uri = "/game/file", .method = HTTP_POST,
        .handler = handle_file_post, .user_ctx = NULL,
    };
    static const httpd_uri_t uri_cmd = {
        .uri = "/game/cmd", .method = HTTP_POST,
        .handler = handle_cmd_post, .user_ctx = NULL,
    };
    static const httpd_uri_t uri_capture = {
        .uri = "/voice/capture", .method = HTTP_POST,
        .handler = handle_voice_capture, .user_ctx = NULL,
    };
    static const httpd_uri_t uri_debug_regs = {
        .uri = "/debug/regs", .method = HTTP_GET,
        .handler = handle_debug_regs, .user_ctx = NULL,
    };
    static const httpd_uri_t uri_debug_dial = {
        .uri = "/debug/dial", .method = HTTP_GET,
        .handler = handle_debug_dial, .user_ctx = NULL,
    };
    static const httpd_uri_t uri_debug_ring = {
        .uri = "/debug/ring", .method = HTTP_GET,
        .handler = handle_debug_ring, .user_ctx = NULL,
    };
    static const httpd_uri_t uri_debug_ringstop = {
        .uri = "/debug/ringstop", .method = HTTP_GET,
        .handler = handle_debug_ringstop, .user_ctx = NULL,
    };
    static const httpd_uri_t uri_debug_slic = {
        .uri = "/debug/slic", .method = HTTP_GET,
        .handler = handle_debug_slic, .user_ctx = NULL,
    };
    httpd_register_uri_handler(s_httpd, &uri_status);
    httpd_register_uri_handler(s_httpd, &uri_scenario);
    httpd_register_uri_handler(s_httpd, &uri_file);
    httpd_register_uri_handler(s_httpd, &uri_cmd);
    httpd_register_uri_handler(s_httpd, &uri_capture);
    httpd_register_uri_handler(s_httpd, &uri_debug_regs);
    httpd_register_uri_handler(s_httpd, &uri_debug_dial);
    httpd_register_uri_handler(s_httpd, &uri_debug_ring);
    httpd_register_uri_handler(s_httpd, &uri_debug_ringstop);
    httpd_register_uri_handler(s_httpd, &uri_debug_slic);

    ESP_LOGI(TAG, "httpd up on :80 (GET /status, GET /debug/regs, GET /debug/dial, GET /debug/ring, GET /debug/ringstop, GET /debug/slic, POST /game/scenario, POST /game/file, POST /game/cmd, POST /voice/capture)");
    return ESP_OK;
}
