// game_endpoint — see include/game_endpoint.h for design notes.
//
// Slice 12 of the IDF migration: live-tunable group profile so the
// game master can switch the hints engine policy from the dashboard
// (or any HTTP client) without reflashing NVS.

#include "game_endpoint.h"

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

#include "cJSON.h"
#include "esp_err.h"
#include "esp_http_server.h"
#include "esp_littlefs.h"
#include "esp_log.h"
#include "media_manager.h"
#include "sd_storage.h"
#include "esp_system.h"
#include "freertos/FreeRTOS.h"
#include "freertos/task.h"
#include "nvs.h"
#include "nvs_flash.h"

#include "hints_client.h"
#include "gamebook.h"
#include "npc_engine.h"
#include "scenario_mesh.h"
#include "puzzle_binding.h"
#include "local_puzzles.h"
#include "p7_coffre.h"
#include "p5_morse.h"
#include "p6_nfc.h"

static const char *TAG = "game_endpoint";

// ─── puzzle state + step tracking (Task C) ──────────────────────────────────
// Pointer supplied by game_endpoint_set_puzzle_state(); NULL until then.
static puzzle_state_t *s_pstate = NULL;
// Last step id armed via POST /game/step. Empty string = none.
static char s_current_step_id[64] = {0};
// Last armed puzzle type: "qr" | "sound" | "morse" | "nfc" | "none" | ""
static char s_current_armed[12] = {0};   // "qr"|"sound"|"morse"|"nfc"|"none"
// Display scene metadata of the current step (lenient parse; defaults safe).
static scene_binding_t s_current_scene = {0};

// ─── P5 / P6 solved-callback context ────────────────────────────────────────
// Mirrors the on_qr_solved / on_sound_solved pattern in local_puzzles.c:
// context is copied at arm time, callback is called on the puzzle task.

typedef struct {
    uint8_t id;
    uint8_t frag[PB_MAX_FRAG];
    uint8_t frag_len;
} ep_puzzle_ctx_t;

static ep_puzzle_ctx_t s_morse_ctx = {0};
static ep_puzzle_ctx_t s_nfc_ctx   = {0};

static void on_morse_solved(void)
{
    if (s_pstate) {
        puzzle_state_report(s_pstate, s_morse_ctx.id,
                            s_morse_ctx.frag, s_morse_ctx.frag_len);
    }
}

static void on_nfc_solved(void)
{
    if (s_pstate) {
        puzzle_state_report(s_pstate, s_nfc_ctx.id,
                            s_nfc_ctx.frag, s_nfc_ctx.frag_len);
    }
}

// Whitelist mirrored in the 4xx error message so the operator can
// recover without grepping the source. Keep aligned with
// hints_client_set_group_profile() validation.
#define GAME_ENDPOINT_PROFILE_HELP \
    "invalid group_profile, must be one of [TECH, NON_TECH, MIXED, BOTH]"

// ─── small JSON response helper (mirrors voice_hook_endpoint) ───────────────

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

static esp_err_t send_error(httpd_req_t *req, const char *status_line,
                            const char *message) {
    char buf[192];
    snprintf(buf, sizeof(buf), "{\"error\":\"%s\"}", message ? message : "");
    return send_json(req, status_line, buf);
}

// ─── NVS persistence helper ─────────────────────────────────────────────────

// Writes the (already-validated) profile into NVS namespace "zacus",
// key "group_profile". Logs and returns the underlying error on
// failure — the caller decides whether to surface it to the client.
static esp_err_t persist_group_profile(const char *profile) {
    nvs_handle_t h;
    esp_err_t err = nvs_open("zacus", NVS_READWRITE, &h);
    if (err != ESP_OK) {
        ESP_LOGW(TAG, "nvs_open(zacus, RW): %s", esp_err_to_name(err));
        return err;
    }

    err = nvs_set_str(h, "group_profile", profile);
    if (err != ESP_OK) {
        ESP_LOGW(TAG, "nvs_set_str(group_profile=\"%s\"): %s",
                 profile, esp_err_to_name(err));
        nvs_close(h);
        return err;
    }

    err = nvs_commit(h);
    if (err != ESP_OK) {
        ESP_LOGW(TAG, "nvs_commit(zacus): %s", esp_err_to_name(err));
    }
    nvs_close(h);
    return err;
}

// ─── GET /game/group_profile ────────────────────────────────────────────────

static esp_err_t handle_group_profile_get(httpd_req_t *req) {
    const char *current = hints_client_group_profile();
    char buf[96];
    snprintf(buf, sizeof(buf),
             "{\"group_profile\":\"%s\"}",
             current ? current : "MIXED");
    return send_json(req, "200 OK", buf);
}

// ─── POST /game/group_profile ───────────────────────────────────────────────

static esp_err_t handle_group_profile_post(httpd_req_t *req) {
    if (req->content_len <= 0 ||
        req->content_len > GAME_ENDPOINT_MAX_BODY_BYTES) {
        ESP_LOGW(TAG, "POST /game/group_profile: bad body length %d",
                 (int) req->content_len);
        return send_error(req, "413 Payload Too Large",
                          "body must be 1..256 bytes");
    }

    char body[GAME_ENDPOINT_MAX_BODY_BYTES + 1] = {0};
    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_error(req, "400 Bad Request", "recv failed");
        }
        total += got;
    }
    body[total] = '\0';

    cJSON *root = cJSON_Parse(body);
    if (!root) {
        ESP_LOGW(TAG, "POST /game/group_profile: malformed JSON: %s", body);
        return send_error(req, "400 Bad Request", "malformed json");
    }

    const cJSON *profile = cJSON_GetObjectItemCaseSensitive(root, "group_profile");
    if (!cJSON_IsString(profile) || profile->valuestring == NULL ||
        profile->valuestring[0] == '\0') {
        cJSON_Delete(root);
        return send_error(req, "400 Bad Request",
                          "missing 'group_profile'");
    }

    // Push to the hints client first — its built-in whitelist is the
    // source of truth for valid profiles. If it accepts the value we
    // then persist; if it rejects we never touch NVS so a bad POST
    // cannot brick the boot-time seed.
    esp_err_t set_err = hints_client_set_group_profile(profile->valuestring);
    if (set_err != ESP_OK) {
        ESP_LOGW(TAG, "hints_client_set_group_profile(\"%s\") rejected: %s",
                 profile->valuestring, esp_err_to_name(set_err));
        cJSON_Delete(root);
        return send_error(req, "400 Bad Request", GAME_ENDPOINT_PROFILE_HELP);
    }

    // Best-effort NVS persistence. If it fails the in-RAM hints client
    // is still updated, but we surface a 500 so the operator knows the
    // change won't survive reboot. Log line above already captured the
    // underlying NVS error.
    esp_err_t nvs_err = persist_group_profile(profile->valuestring);
    if (nvs_err != ESP_OK) {
        char buf[192];
        snprintf(buf, sizeof(buf),
                 "{\"status\":\"runtime_only\","
                 "\"group_profile\":\"%s\","
                 "\"warning\":\"nvs write failed: %s\"}",
                 profile->valuestring, esp_err_to_name(nvs_err));
        cJSON_Delete(root);
        return send_json(req, "500 Internal Server Error", buf);
    }

    ESP_LOGI(TAG, "group_profile updated -> %s (NVS persisted)",
             profile->valuestring);

    char buf[128];
    snprintf(buf, sizeof(buf),
             "{\"status\":\"ok\",\"group_profile\":\"%s\"}",
             profile->valuestring);
    cJSON_Delete(root);
    return send_json(req, "200 OK", buf);
}

// ─── LittleFS lazy mount (shared with media_manager — idempotent) ──────────

static bool s_storage_mounted = false;

static esp_err_t mount_storage_lazy(void) {
    if (s_storage_mounted) return ESP_OK;
    // Already mounted by main.c (or media_manager)? Checking first avoids
    // esp_littlefs's own "Partition already used" ERROR logs — the register
    // call below would still succeed-as-INVALID_STATE, but noisily.
    if (esp_littlefs_mounted(GAME_ENDPOINT_STORAGE_LABEL)) {
        s_storage_mounted = true;
        return ESP_OK;
    }
    esp_vfs_littlefs_conf_t conf = {
        .base_path = GAME_ENDPOINT_STORAGE_BASE,
        .partition_label = GAME_ENDPOINT_STORAGE_LABEL,
        .format_if_mount_failed = true,
        .dont_mount = false,
    };
    esp_err_t err = esp_vfs_littlefs_register(&conf);
    if (err == ESP_OK || err == ESP_ERR_INVALID_STATE) {
        // INVALID_STATE = already registered by another component → fine.
        s_storage_mounted = true;
        ESP_LOGI(TAG, "littlefs '%s' mounted at %s",
                 conf.partition_label, conf.base_path);
        return ESP_OK;
    }
    ESP_LOGE(TAG, "esp_vfs_littlefs_register(%s) failed: %s",
             conf.partition_label, esp_err_to_name(err));
    return err;
}

// ─── deferred reboot (lets the HTTP response flush first) ──────────────────

static void deferred_restart_task(void *arg) {
    (void) arg;
    vTaskDelay(pdMS_TO_TICKS(800));
    ESP_LOGW(TAG, "scenario hot-load: rebooting to apply new IR");
    esp_restart();
}

static void schedule_restart(void) {
    xTaskCreate(deferred_restart_task, "scenario_restart",
                4096, NULL, tskIDLE_PRIORITY + 1, NULL);
}

// ─── _scenario_apply — shared validate + atomic-write path ──────────────────
//
// The single internal entry point both the HTTP POST /game/scenario handler
// and the ESP-NOW receiver (scenario_mesh) funnel through, per the spec. It
// validates the IR (schema_version + non-empty steps), rotates the current
// scenario to .bak, and atomically writes the new blob to LittleFS. On
// success it schedules the hot-reload reboot and returns ESP_OK; the optional
// out-params report the parsed step count / entry id for the HTTP response.
//
// On failure it returns a specific esp_err_t and leaves the previous scenario
// in place (rolled back from .bak when a short write corrupted the new file).
// `err_msg_out` (if non-NULL) receives a static human-readable reason.
static esp_err_t scenario_apply_buffer(const char *body, size_t len,
                                       int *steps_count_out,
                                       char *entry_out, size_t entry_cap,
                                       const char **err_msg_out) {
    if (err_msg_out) *err_msg_out = NULL;
    if (steps_count_out) *steps_count_out = 0;
    if (entry_out && entry_cap) entry_out[0] = '\0';

    if (!body || len == 0 || len > GAME_ENDPOINT_MAX_SCENARIO_BYTES) {
        if (err_msg_out) *err_msg_out = "body must be 1..65536 bytes";
        return ESP_ERR_INVALID_SIZE;
    }
    if (mount_storage_lazy() != ESP_OK) {
        if (err_msg_out) *err_msg_out = "littlefs mount failed";
        return ESP_FAIL;
    }

    // Minimal validation: parse + schema_version + non-empty steps array.
    // The runtime3_common.py validator is the strict source of truth on the
    // gateway side; here we keep the firmware permissive but safe.
    cJSON *root = cJSON_Parse(body);
    if (!root) {
        ESP_LOGW(TAG, "scenario apply: malformed JSON (len=%d)", (int) len);
        if (err_msg_out) *err_msg_out = "malformed json";
        return ESP_ERR_INVALID_ARG;
    }
    const cJSON *schema = cJSON_GetObjectItemCaseSensitive(root, "schema_version");
    if (!cJSON_IsString(schema) ||
        strcmp(schema->valuestring, "zacus.runtime3.v1") != 0) {
        cJSON_Delete(root);
        if (err_msg_out) *err_msg_out = "schema_version must be zacus.runtime3.v1";
        return ESP_ERR_INVALID_ARG;
    }
    const cJSON *steps = cJSON_GetObjectItemCaseSensitive(root, "steps");
    if (!cJSON_IsArray(steps) || cJSON_GetArraySize(steps) == 0) {
        cJSON_Delete(root);
        if (err_msg_out) *err_msg_out = "steps must be a non-empty array";
        return ESP_ERR_INVALID_ARG;
    }
    const cJSON *scenario_obj = cJSON_GetObjectItemCaseSensitive(root, "scenario");
    const cJSON *entry        = scenario_obj
        ? cJSON_GetObjectItemCaseSensitive(scenario_obj, "entry_step_id")
        : NULL;
    if (entry_out && entry_cap && cJSON_IsString(entry) && entry->valuestring) {
        strncpy(entry_out, entry->valuestring, entry_cap - 1);
        entry_out[entry_cap - 1] = '\0';
    }
    int steps_count = cJSON_GetArraySize(steps);
    cJSON_Delete(root);
    if (steps_count_out) *steps_count_out = steps_count;

    // Rotate existing scenario -> .bak so a broken push can be rolled back
    // by a future scenario_engine_reload() failure path.
    struct stat st;
    if (stat(GAME_ENDPOINT_SCENARIO_PATH, &st) == 0) {
        // Best-effort: ignore rename failure (e.g. .bak already exists from
        // a previous push — overwrite via unlink+rename).
        unlink(GAME_ENDPOINT_SCENARIO_BAK);
        if (rename(GAME_ENDPOINT_SCENARIO_PATH,
                   GAME_ENDPOINT_SCENARIO_BAK) != 0) {
            ESP_LOGW(TAG, "rename current scenario -> .bak failed (errno=%d)",
                     errno);
        }
    }

    FILE *f = fopen(GAME_ENDPOINT_SCENARIO_PATH, "wb");
    if (!f) {
        ESP_LOGE(TAG, "fopen %s for write failed (errno=%d)",
                 GAME_ENDPOINT_SCENARIO_PATH, errno);
        if (err_msg_out) *err_msg_out = "scenario write open failed";
        return ESP_FAIL;
    }
    size_t written = fwrite(body, 1, len, f);
    fclose(f);

    if (written != len) {
        ESP_LOGE(TAG, "scenario write short: %zu/%zu bytes (rolling back)",
                 written, len);
        unlink(GAME_ENDPOINT_SCENARIO_PATH);
        if (stat(GAME_ENDPOINT_SCENARIO_BAK, &st) == 0) {
            rename(GAME_ENDPOINT_SCENARIO_BAK, GAME_ENDPOINT_SCENARIO_PATH);
        }
        if (err_msg_out) *err_msg_out = "scenario write short";
        return ESP_FAIL;
    }

    ESP_LOGI(TAG, "scenario hot-load OK: %zu bytes, %d steps, entry=%s",
             len, steps_count, (entry_out && entry_cap) ? entry_out : "");

    // New scenario supersedes any live puzzle — disarm and clear remembered step.
    local_puzzles_disarm();
    p5_morse_disarm();   // no-op when CONFIG_ZACUS_P5_MORSE_ENABLE=n
    p6_nfc_disarm();     // no-op when CONFIG_ZACUS_P6_NFC_ENABLE=n
    s_current_step_id[0] = '\0';
    memset(&s_current_scene, 0, sizeof(s_current_scene));
    // Re-arm the coffre for the new game session.
    // p7_coffre_lock() is a no-op stub when CONFIG_ZACUS_P7_COFFRE_ENABLE=n.
    p7_coffre_lock();

    // Hot-reload-via-reboot until scenario_engine_reload() lands (Phase 3).
    schedule_restart();
    return ESP_OK;
}

// scenario_mesh apply callback: a received-over-ESP-NOW scenario takes the
// exact same path as an HTTP POST. The mesh frees `data` after this returns.
static esp_err_t scenario_mesh_apply_adapter(const char *data, size_t len) {
    int steps = 0;
    char entry[64] = {0};
    const char *emsg = NULL;
    esp_err_t err = scenario_apply_buffer(data, len, &steps,
                                          entry, sizeof(entry), &emsg);
    if (err != ESP_OK) {
        ESP_LOGW(TAG, "ESP-NOW scenario rejected: %s",
                 emsg ? emsg : esp_err_to_name(err));
    }
    return err;
}

// ─── POST /game/scenario — accept a Runtime 3 IR JSON ──────────────────────

static esp_err_t handle_scenario_post(httpd_req_t *req) {
    if (req->content_len <= 0 ||
        req->content_len > GAME_ENDPOINT_MAX_SCENARIO_BYTES) {
        ESP_LOGW(TAG, "POST /game/scenario: bad body length %d",
                 (int) req->content_len);
        return send_error(req, "413 Payload Too Large",
                          "body must be 1..65536 bytes");
    }

    char *body = (char *) malloc((size_t) req->content_len + 1);
    if (!body) {
        return send_error(req, "500 Internal Server Error",
                          "out of memory");
    }
    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;
            free(body);
            return send_error(req, "400 Bad Request", "recv failed");
        }
        total += got;
    }
    body[total] = '\0';

    int steps_count = 0;
    char entry_str[64] = {0};
    const char *emsg = NULL;
    esp_err_t aerr = scenario_apply_buffer(body, (size_t) total, &steps_count,
                                           entry_str, sizeof(entry_str), &emsg);
    free(body);

    if (aerr != ESP_OK) {
        const char *status = (aerr == ESP_ERR_INVALID_ARG ||
                              aerr == ESP_ERR_INVALID_SIZE)
            ? "400 Bad Request" : "500 Internal Server Error";
        return send_error(req, status, emsg ? emsg : esp_err_to_name(aerr));
    }

    char buf[256];
    snprintf(buf, sizeof(buf),
             "{\"status\":\"ok\",\"steps_count\":%d,"
             "\"entry_step_id\":\"%s\",\"bytes\":%d,"
             "\"reload\":\"reboot_pending\"}",
             steps_count, entry_str, total);
    // The HTTP response is queued by send_json; scenario_apply_buffer already
    // scheduled the deferred restart, which waits 800 ms before rebooting so
    // the TCP stack can flush this response first.
    return send_json(req, "200 OK", buf);
}

// ─── POST /game/scenario/relay (master only) ───────────────────────────────
//
// Body: { "peers": ["box3","plip",...], "ir": { <runtime3 IR> } }
//
// Resolves each alias to a MAC via the scenario_mesh peer registry, then
// chunks + sends the IR over ESP-NOW. A failure on one peer (unknown alias,
// ack timeout) does not abort the others — it lands in "skipped" with a
// reason. Responds 200 with { "relayed":[...], "skipped":[{name,reason}] }.
static esp_err_t handle_scenario_relay_post(httpd_req_t *req) {
    if (req->content_len <= 0 ||
        req->content_len > GAME_ENDPOINT_MAX_SCENARIO_BYTES) {
        ESP_LOGW(TAG, "POST /game/scenario/relay: bad body length %d",
                 (int) req->content_len);
        return send_error(req, "413 Payload Too Large",
                          "body must be 1..65536 bytes");
    }

    char *body = (char *) malloc((size_t) req->content_len + 1);
    if (!body) {
        return send_error(req, "500 Internal Server Error", "out of memory");
    }
    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;
            free(body);
            return send_error(req, "400 Bad Request", "recv failed");
        }
        total += got;
    }
    body[total] = '\0';

    cJSON *root = cJSON_Parse(body);
    free(body);
    if (!root) {
        return send_error(req, "400 Bad Request", "malformed json");
    }
    const cJSON *peers = cJSON_GetObjectItemCaseSensitive(root, "peers");
    const cJSON *ir    = cJSON_GetObjectItemCaseSensitive(root, "ir");
    if (!cJSON_IsArray(peers) || cJSON_GetArraySize(peers) == 0) {
        cJSON_Delete(root);
        return send_error(req, "400 Bad Request",
                          "'peers' must be a non-empty array");
    }
    if (!cJSON_IsObject(ir)) {
        cJSON_Delete(root);
        return send_error(req, "400 Bad Request", "'ir' must be an object");
    }

    // Serialize the IR object once (unformatted, compact) — this is the exact
    // byte stream the receiver reassembles and feeds to _scenario_apply().
    char *ir_str = cJSON_PrintUnformatted(ir);
    if (!ir_str) {
        cJSON_Delete(root);
        return send_error(req, "500 Internal Server Error",
                          "ir serialize failed");
    }
    size_t ir_len = strlen(ir_str);

    // Build the two result arrays into a response cJSON tree.
    cJSON *resp    = cJSON_CreateObject();
    cJSON *relayed = cJSON_AddArrayToObject(resp, "relayed");
    cJSON *skipped = cJSON_AddArrayToObject(resp, "skipped");

    int n = cJSON_GetArraySize(peers);
    for (int i = 0; i < n; i++) {
        const cJSON *p = cJSON_GetArrayItem(peers, i);
        if (!cJSON_IsString(p) || !p->valuestring) continue;
        const char *alias = p->valuestring;

        uint8_t mac[6];
        esp_err_t rerr = scenario_mesh_mac_for_alias(alias, mac);
        if (rerr != ESP_OK) {
            cJSON *s = cJSON_CreateObject();
            cJSON_AddStringToObject(s, "name", alias);
            cJSON_AddStringToObject(s, "reason", "unknown_peer");
            cJSON_AddItemToArray(skipped, s);
            ESP_LOGW(TAG, "relay: alias \"%s\" not in peer registry", alias);
            continue;
        }

        esp_err_t serr = scenario_mesh_send(mac, ir_str, ir_len);
        if (serr == ESP_OK) {
            cJSON_AddItemToArray(relayed, cJSON_CreateString(alias));
            ESP_LOGI(TAG, "relay: \"%s\" OK (%u bytes)",
                     alias, (unsigned) ir_len);
        } else {
            cJSON *s = cJSON_CreateObject();
            cJSON_AddStringToObject(s, "name", alias);
            cJSON_AddStringToObject(s, "reason",
                serr == ESP_ERR_TIMEOUT ? "timeout" : esp_err_to_name(serr));
            cJSON_AddItemToArray(skipped, s);
            ESP_LOGW(TAG, "relay: \"%s\" failed: %s",
                     alias, esp_err_to_name(serr));
        }
    }

    cJSON_free(ir_str);
    cJSON_Delete(root);

    char *resp_str = cJSON_PrintUnformatted(resp);
    cJSON_Delete(resp);
    if (!resp_str) {
        return send_error(req, "500 Internal Server Error",
                          "response serialize failed");
    }
    esp_err_t ret = send_json(req, "200 OK", resp_str);
    cJSON_free(resp_str);
    return ret;
}

// ─── GET/POST /game/peers ───────────────────────────────────────────────────
// Relay peer registry management over HTTP — replaces the desktop
// NvsConfigurator round-trip for ESP-NOW provisioning.
//   POST { "alias": "plip", "mac": "AA:BB:CC:DD:EE:FF" }
//     → persists to NVS namespace "peers" (key = alias, blob = 6-byte MAC,
//       the exact format main.c seeds from at boot) AND registers the peer
//       live in the scenario_mesh table (no reboot needed).
//   GET → { "peers": [ { "alias": "...", "mac": "..." }, ... ] }

static esp_err_t handle_peers_post(httpd_req_t *req) {
    if (req->content_len <= 0 || req->content_len > 256) {
        return send_error(req, "413 Payload Too Large",
                          "body must be 1..256 bytes");
    }
    char body[257];
    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_error(req, "400 Bad Request", "recv failed");
        }
        total += got;
    }
    body[total] = '\0';

    cJSON *root = cJSON_Parse(body);
    if (!root) {
        return send_error(req, "400 Bad Request", "invalid JSON");
    }
    const cJSON *alias_j = cJSON_GetObjectItem(root, "alias");
    const cJSON *mac_j   = cJSON_GetObjectItem(root, "mac");
    if (!cJSON_IsString(alias_j) || !cJSON_IsString(mac_j)) {
        cJSON_Delete(root);
        return send_error(req, "400 Bad Request",
                          "alias and mac (string) required");
    }
    const char *alias = alias_j->valuestring;
    size_t alias_len = strlen(alias);
    if (alias_len == 0 || alias_len > 15) {   // NVS key limit
        cJSON_Delete(root);
        return send_error(req, "400 Bad Request",
                          "alias must be 1..15 chars (NVS key)");
    }
    uint8_t mac[6];
    if (sscanf(mac_j->valuestring, "%hhx:%hhx:%hhx:%hhx:%hhx:%hhx",
               &mac[0], &mac[1], &mac[2], &mac[3], &mac[4], &mac[5]) != 6) {
        cJSON_Delete(root);
        return send_error(req, "400 Bad Request",
                          "mac must be AA:BB:CC:DD:EE:FF");
    }

    nvs_handle_t h;
    esp_err_t err = nvs_open("peers", NVS_READWRITE, &h);
    if (err == ESP_OK) {
        err = nvs_set_blob(h, alias, mac, sizeof(mac));
        if (err == ESP_OK) err = nvs_commit(h);
        nvs_close(h);
    }
    if (err != ESP_OK) {
        ESP_LOGE(TAG, "peers: NVS write \"%s\" failed: %s",
                 alias, esp_err_to_name(err));
        cJSON_Delete(root);
        return send_error(req, "500 Internal Server Error", "NVS write failed");
    }

    esp_err_t reg = scenario_mesh_register_peer(alias, mac);
    if (reg != ESP_OK) {
        ESP_LOGW(TAG, "peers: \"%s\" stored in NVS but live registration "
                      "failed: %s (effective after reboot)",
                 alias, esp_err_to_name(reg));
    }
    ESP_LOGI(TAG, "peers: \"%s\" -> %02X:%02X:%02X:%02X:%02X:%02X (%s)",
             alias, mac[0], mac[1], mac[2], mac[3], mac[4], mac[5],
             reg == ESP_OK ? "live" : "reboot needed");
    // Build the response BEFORE deleting root: `alias` points into the cJSON
    // tree (use-after-free observed on-device as a garbled alias echo).
    char resp[128];
    snprintf(resp, sizeof(resp),
             "{\"ok\":true,\"alias\":\"%s\",\"live\":%s}",
             alias, reg == ESP_OK ? "true" : "false");
    cJSON_Delete(root);
    return send_json(req, "200 OK", resp);
}

static esp_err_t handle_peers_get(httpd_req_t *req) {
    cJSON *resp = cJSON_CreateObject();
    cJSON *arr  = cJSON_AddArrayToObject(resp, "peers");

    nvs_handle_t h;
    if (nvs_open("peers", NVS_READONLY, &h) == ESP_OK) {
        nvs_iterator_t it = NULL;
        esp_err_t err = nvs_entry_find("nvs", "peers", NVS_TYPE_BLOB, &it);
        while (err == ESP_OK && it != NULL) {
            nvs_entry_info_t info;
            nvs_entry_info(it, &info);
            uint8_t mac[6];
            size_t len = sizeof(mac);
            if (nvs_get_blob(h, info.key, mac, &len) == ESP_OK && len == 6) {
                cJSON *p = cJSON_CreateObject();
                cJSON_AddStringToObject(p, "alias", info.key);
                char mac_str[18];
                snprintf(mac_str, sizeof(mac_str),
                         "%02X:%02X:%02X:%02X:%02X:%02X",
                         mac[0], mac[1], mac[2], mac[3], mac[4], mac[5]);
                cJSON_AddStringToObject(p, "mac", mac_str);
                cJSON_AddItemToArray(arr, p);
            }
            err = nvs_entry_next(&it);
        }
        nvs_release_iterator(it);
        nvs_close(h);
    }

    char *resp_str = cJSON_PrintUnformatted(resp);
    cJSON_Delete(resp);
    if (!resp_str) {
        return send_error(req, "500 Internal Server Error",
                          "response serialize failed");
    }
    esp_err_t ret = send_json(req, "200 OK", resp_str);
    cJSON_free(resp_str);
    return ret;
}

// ─── POST /game/espnow/cmd — debug/manual CMD injection ─────────────────────
//
// Body: {"peer":"alias","command":"ping"} or {"broadcast":true,"command":"x"}.
// Sends one CMD text frame (spec 2026-06-11) and reports the radio status.
// The scenario-driven executor will share this exact send path.

static const uint8_t kEspnowBroadcastMac[6] =
    {0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF};

// Receive side: log every CMD/EVT so two-board bring-up is observable on the
// serial console. The step executor will hook waits in here later.
static void master_text_cb(uint8_t kind, const uint8_t src[6],
                           const char *text) {
    ESP_LOGI(TAG, "espnow %s \"%s\" from %02x:%02x:%02x:%02x:%02x:%02x",
             kind == SCENARIO_MESH_TEXT_CMD ? "CMD" : "EVT", text,
             src[0], src[1], src[2], src[3], src[4], src[5]);
}

static esp_err_t handle_espnow_cmd_post(httpd_req_t *req) {
    char buf[256];
    int total = req->content_len;
    if (total <= 0 || total >= (int) sizeof(buf)) {
        return send_error(req, "400 Bad Request", "bad body length");
    }
    int got = httpd_req_recv(req, buf, total);
    if (got <= 0) {
        return send_error(req, "400 Bad Request", "body read failed");
    }
    buf[got] = '\0';

    cJSON *root = cJSON_Parse(buf);
    if (!root) {
        return send_error(req, "400 Bad Request", "invalid JSON");
    }
    const cJSON *cmd_j  = cJSON_GetObjectItemCaseSensitive(root, "command");
    const cJSON *peer_j = cJSON_GetObjectItemCaseSensitive(root, "peer");
    const cJSON *bc_j   = cJSON_GetObjectItemCaseSensitive(root, "broadcast");
    if (!cJSON_IsString(cmd_j) || !cmd_j->valuestring[0]) {
        cJSON_Delete(root);
        return send_error(req, "400 Bad Request", "'command' required");
    }

    uint8_t mac[6];
    if (cJSON_IsTrue(bc_j)) {
        memcpy(mac, kEspnowBroadcastMac, 6);
    } else if (cJSON_IsString(peer_j) && peer_j->valuestring[0]) {
        if (scenario_mesh_mac_for_alias(peer_j->valuestring, mac) != ESP_OK) {
            cJSON_Delete(root);
            return send_error(req, "404 Not Found", "unknown peer alias");
        }
    } else {
        cJSON_Delete(root);
        return send_error(req, "400 Bad Request",
                          "'peer' or 'broadcast':true required");
    }

    esp_err_t serr = scenario_mesh_send_text(mac, SCENARIO_MESH_TEXT_CMD,
                                             cmd_j->valuestring);
    char resp[96];
    snprintf(resp, sizeof(resp), "{\"ok\":%s,\"status\":\"%s\"}",
             serr == ESP_OK ? "true" : "false", esp_err_to_name(serr));
    cJSON_Delete(root);
    return send_json(req, serr == ESP_OK ? "200 OK" : "502 Bad Gateway", resp);
}

// ─── POST /game/step — arm a puzzle for the given step id ───────────────────
//
// Body: {"step_id":"STEP_X"}
// Reads /littlefs/scenario.json, calls puzzle_binding_from_ir, then
// disarms whatever is running and arms the new puzzle (if any).
// Concurrency: httpd task vs. puzzle solved-callbacks writing puzzle_state —
// the arm/disarm calls here are the only writers to local_puzzles state; puzzle
// tasks only call puzzle_state_report. No mutex needed for this slice.

// Core step-change logic, shared by the HTTP handler and internal callers
// (e.g. shell app launch on the local display). Error contract:
//   ESP_OK                 armed (armed_out = "qr"|"sound"|"morse"|"nfc"|"none")
//   ESP_ERR_INVALID_STATE  not ready (no puzzle_state wired)
//   ESP_ERR_NOT_SUPPORTED  no scenario stored / storage unavailable
//   ESP_ERR_NOT_FOUND      unknown step id
//   ESP_ERR_INVALID_ARG    invalid puzzle object in the IR
//   ESP_ERR_TIMEOUT        puzzle busy (QR teardown window, after one retry)
//   others                 internal failure
esp_err_t game_endpoint_apply_step(const char *step_id,
                                   char *armed_out, size_t armed_cap) {
    if (armed_out && armed_cap) armed_out[0] = '\0';
    if (!step_id || !step_id[0]) return ESP_ERR_INVALID_ARG;
    if (s_pstate == NULL) return ESP_ERR_INVALID_STATE;

    if (mount_storage_lazy() != ESP_OK) return ESP_ERR_NOT_SUPPORTED;
    FILE *f = fopen(GAME_ENDPOINT_SCENARIO_PATH, "rb");
    if (!f) return ESP_ERR_NOT_SUPPORTED;
    fseek(f, 0, SEEK_END);
    long fsize = ftell(f);
    rewind(f);
    if (fsize <= 0 || fsize > GAME_ENDPOINT_MAX_SCENARIO_BYTES) {
        fclose(f);
        return ESP_ERR_NOT_SUPPORTED;
    }
    char *ir_json = (char *) malloc((size_t) fsize + 1);
    if (!ir_json) { fclose(f); return ESP_ERR_NO_MEM; }
    size_t nread = fread(ir_json, 1, (size_t) fsize, f);
    fclose(f);
    ir_json[nread] = '\0';

    // Disarm current puzzle before re-arming.
    local_puzzles_disarm();
    // Disarm P5/P6 stubs (no-ops when their CONFIG flags are off).
    p5_morse_disarm();
    p6_nfc_disarm();

    // Parse binding for this step (+ display scene metadata, lenient).
    puzzle_binding_t binding;
    esp_err_t berr = puzzle_binding_from_ir(ir_json, step_id, &binding);
    if (berr == ESP_OK) {
        (void) scene_binding_from_ir(ir_json, step_id, &s_current_scene);
    }
    free(ir_json);
    if (berr != ESP_OK) return berr;   // NOT_FOUND / INVALID_ARG / other

    strncpy(s_current_step_id, step_id, sizeof(s_current_step_id) - 1);
    s_current_step_id[sizeof(s_current_step_id) - 1] = '\0';

    // Sync the NPC/hints engine (and, through it, the voice gateway) with the
    // scene this step enters. This is what makes the phone NPCs hint on — and
    // scripted calls auto-ring for — the current scene, regardless of whether
    // the step was driven by the local game loop or an external POST /game/step.
    // Best-effort: notify is async + server-side idempotent and never blocks
    // the step change. Steps with no scene_id (or an unknown one) are skipped.
    if (s_current_scene.scene_id[0] != '\0') {
        (void) npc_engine_set_scene_by_id(s_current_scene.scene_id, 0);
    }

    // P7 coffre: fire the actuator when the final win step is reached.
    // p7_coffre_unlock() is a no-op stub when CONFIG_ZACUS_P7_COFFRE_ENABLE=n.
    if (strcmp(step_id, "STEP_FINAL_WIN") == 0) {
        p7_coffre_unlock();
    }

    const char *armed = "none";
    if (binding.type == PB_QR) {
        const char *ptrs[PB_MAX_CODES];
        for (size_t i = 0; i < binding.code_count; i++) ptrs[i] = binding.codes[i];
        esp_err_t aerr = local_puzzles_arm_qr(binding.id, ptrs, binding.code_count,
                                              binding.fragment, binding.fragment_len);
        if (aerr == ESP_ERR_INVALID_STATE) {
            vTaskDelay(pdMS_TO_TICKS(250));   // async QR teardown — retry once
            aerr = local_puzzles_arm_qr(binding.id, ptrs, binding.code_count,
                                        binding.fragment, binding.fragment_len);
        }
        if (aerr == ESP_ERR_INVALID_STATE) return ESP_ERR_TIMEOUT;
        if (aerr != ESP_OK) return aerr;
        armed = "qr";
    } else if (binding.type == PB_SOUND) {
        esp_err_t aerr = local_puzzles_arm_sound(binding.id, binding.melody,
                                                 binding.note_count, binding.tolerance,
                                                 binding.fragment, binding.fragment_len);
        if (aerr == ESP_ERR_INVALID_STATE) {
            vTaskDelay(pdMS_TO_TICKS(250));
            aerr = local_puzzles_arm_sound(binding.id, binding.melody,
                                           binding.note_count, binding.tolerance,
                                           binding.fragment, binding.fragment_len);
        }
        if (aerr == ESP_ERR_INVALID_STATE) return ESP_ERR_TIMEOUT;
        if (aerr != ESP_OK) return aerr;
        armed = "sound";
    } else if (binding.type == PB_MORSE) {
        // P5 morse: copy context then arm.
        // p5_morse_arm() is a no-op stub when CONFIG_ZACUS_P5_MORSE_ENABLE=n.
        s_morse_ctx.id       = binding.id;
        s_morse_ctx.frag_len = binding.fragment_len;
        if (binding.fragment_len > 0) {
            memcpy(s_morse_ctx.frag, binding.fragment, binding.fragment_len);
        }
        p5_morse_arm(binding.morse_expected, on_morse_solved);
        armed = "morse";
    } else if (binding.type == PB_NFC) {
        // P6 nfc: copy context then arm.
        // p6_nfc_arm() is a no-op stub when CONFIG_ZACUS_P6_NFC_ENABLE=n.
        s_nfc_ctx.id       = binding.id;
        s_nfc_ctx.frag_len = binding.fragment_len;
        if (binding.fragment_len > 0) {
            memcpy(s_nfc_ctx.frag, binding.fragment, binding.fragment_len);
        }
        p6_nfc_arm(binding.nfc_uid, on_nfc_solved);
        armed = "nfc";
    }

    strncpy(s_current_armed, armed, sizeof(s_current_armed) - 1);
    s_current_armed[sizeof(s_current_armed) - 1] = '\0';
    if (armed_out && armed_cap) {
        strncpy(armed_out, armed, armed_cap - 1);
        armed_out[armed_cap - 1] = '\0';
    }
    return ESP_OK;
}

static esp_err_t handle_step_post(httpd_req_t *req) {
    if (s_pstate == NULL) {
        return send_error(req, "503 Service Unavailable", "not_ready");
    }

    // Read body (≤512 bytes is sufficient for {"step_id":"..."}).
    char body[513] = {0};
    if (req->content_len <= 0 || req->content_len > 512) {
        return send_error(req, "400 Bad Request", "body must be 1..512 bytes");
    }
    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_error(req, "400 Bad Request", "recv failed");
        }
        total += got;
    }
    body[total] = '\0';

    cJSON *root = cJSON_Parse(body);
    if (!root) {
        return send_error(req, "400 Bad Request", "malformed json");
    }
    const cJSON *step_id_j = cJSON_GetObjectItemCaseSensitive(root, "step_id");
    if (!cJSON_IsString(step_id_j) || !step_id_j->valuestring ||
        step_id_j->valuestring[0] == '\0') {
        cJSON_Delete(root);
        return send_error(req, "400 Bad Request", "missing or empty step_id");
    }
    char step_id[64];
    strncpy(step_id, step_id_j->valuestring, sizeof(step_id) - 1);
    step_id[sizeof(step_id) - 1] = '\0';
    cJSON_Delete(root);

    // Delegate to the shared core; map its error contract onto the SAME
    // HTTP statuses/messages as before (REST contract unchanged).
    char armed[8];
    esp_err_t aerr = game_endpoint_apply_step(step_id, armed, sizeof(armed));
    switch (aerr) {
    case ESP_OK: {
        char buf[128];
        snprintf(buf, sizeof(buf),
                 "{\"step_id\":\"%s\",\"armed\":\"%s\"}", step_id, armed);
        return send_json(req, "200 OK", buf);
    }
    case ESP_ERR_NOT_SUPPORTED:
        return send_error(req, "409 Conflict", "no_scenario");
    case ESP_ERR_NOT_FOUND:
        return send_error(req, "404 Not Found", "unknown_step");
    case ESP_ERR_INVALID_ARG:
        return send_error(req, "422 Unprocessable Entity", "invalid_puzzle");
    case ESP_ERR_TIMEOUT:
        return send_error(req, "503 Service Unavailable", "puzzle_busy");
    case ESP_ERR_INVALID_STATE:
        return send_error(req, "503 Service Unavailable", "not_ready");
    default:
        return send_error(req, "500 Internal Server Error",
                          esp_err_to_name(aerr));
    }
}

// ─── POST /game/media/play — trigger a media_manager WAV cue ─────────────────
// Body: { "path": "apps/<id>/cue.wav" | "/littlefs/music/foo.wav" }. Streams
// the file to the MAX98357A via media_manager (P4 real playback). Lets the
// gateway / atelier fire audio cues without an NPC decision.
static esp_err_t handle_media_play_post(httpd_req_t *req) {
    char body[513] = {0};
    if (req->content_len <= 0 || req->content_len > 512) {
        return send_error(req, "400 Bad Request", "body must be 1..512 bytes");
    }
    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_error(req, "400 Bad Request", "recv failed");
        }
        total += got;
    }
    body[total] = '\0';

    cJSON *root = cJSON_Parse(body);
    if (!root) {
        return send_error(req, "400 Bad Request", "malformed json");
    }
    const cJSON *path_j = cJSON_GetObjectItemCaseSensitive(root, "path");
    if (!cJSON_IsString(path_j) || !path_j->valuestring || path_j->valuestring[0] == '\0') {
        cJSON_Delete(root);
        return send_error(req, "400 Bad Request", "missing or empty path");
    }
    char path[160];
    strncpy(path, path_j->valuestring, sizeof(path) - 1);
    path[sizeof(path) - 1] = '\0';
    cJSON_Delete(root);

    esp_err_t err = media_manager_play(path);
    if (err == ESP_OK) {
        char buf[200];
        snprintf(buf, sizeof(buf), "{\"status\":\"playing\",\"path\":\"%s\"}", path);
        return send_json(req, "200 OK", buf);
    }
    if (err == ESP_ERR_NOT_FOUND) {
        return send_error(req, "404 Not Found", "file_not_found");
    }
    if (err == ESP_ERR_INVALID_ARG) {
        return send_error(req, "400 Bad Request", "invalid_path");
    }
    return send_error(req, "500 Internal Server Error", esp_err_to_name(err));
}

// ─── POST /game/gamebook[?action=stop] ───────────────────────────────────────
//
// Start the "livre dont vous êtes le héros" mode (loads /sdcard/gamebook/
// gamebook.json, plays the start passage, takes over the 5-way pad). With
// ?action=stop it leaves gamebook mode. No body required.
static esp_err_t handle_gamebook_post(httpd_req_t *req) {
    char query[32] = {0}, action[16] = {0};
    if (httpd_req_get_url_query_str(req, query, sizeof(query)) == ESP_OK) {
        httpd_query_key_value(query, "action", action, sizeof(action));
    }
    if (strcmp(action, "stop") == 0) {
        gamebook_stop();
        return send_json(req, "200 OK", "{\"gamebook\":\"stopped\"}");
    }
    esp_err_t err = gamebook_start();
    if (err != ESP_OK) {
        return send_error(req, "503 Service Unavailable",
                          err == ESP_ERR_NOT_FOUND ? "gamebook_not_on_sd"
                                                   : "gamebook_load_failed");
    }
    return send_json(req, "200 OK", "{\"gamebook\":\"started\"}");
}

// ─── GET /game/puzzle_state ──────────────────────────────────────────────────
//
// Returns {"step_id":"STEP_X"|null, "solved":[1,3], "code":"125"}.
// NOTE: reads puzzle_state fields without a mutex — acceptable for this game
// (GM dashboard polling). solved[] flags are written monotonically by puzzle
// tasks; a concurrent read may see solved=true with fragment mid-write.

static esp_err_t handle_puzzle_state_get(httpd_req_t *req) {
    if (s_pstate == NULL) {
        return send_error(req, "503 Service Unavailable", "not_ready");
    }

    // Build "solved" array of ids where solved[id] == true (ids 1..PUZZLE_MAX_ID).
    char solved_buf[64] = "[";
    int  solved_len = 1;
    bool first = true;
    for (int id = 1; id <= PUZZLE_MAX_ID; id++) {
        if (s_pstate->solved[id]) {
            int n = snprintf(solved_buf + solved_len,
                             sizeof(solved_buf) - (size_t) solved_len - 1,
                             "%s%d", first ? "" : ",", id);
            if (n > 0) solved_len += n;
            first = false;
        }
    }
    if (solved_len < (int) sizeof(solved_buf) - 1) {
        solved_buf[solved_len++] = ']';
        solved_buf[solved_len]   = '\0';
    }

    char code[PUZZLE_MAX_ID * PUZZLE_MAX_FRAG + 1] = {0};
    puzzle_state_code(s_pstate, code, sizeof(code));

    // Build response.  step_id is null when no step has been armed yet.
    char resp[256];
    if (s_current_step_id[0] != '\0') {
        snprintf(resp, sizeof(resp),
                 "{\"step_id\":\"%s\",\"solved\":%s,\"code\":\"%s\"}",
                 s_current_step_id, solved_buf, code);
    } else {
        snprintf(resp, sizeof(resp),
                 "{\"step_id\":null,\"solved\":%s,\"code\":\"%s\"}",
                 solved_buf, code);
    }
    return send_json(req, "200 OK", resp);
}

// ─── public setter (Task C) ──────────────────────────────────────────────────

void game_endpoint_set_puzzle_state(puzzle_state_t *state) {
    s_pstate = state;
    ESP_LOGI(TAG, "puzzle_state registered (GET /game/puzzle_state + POST /game/step active)");
}

// ─── public getter for display_ui (Phase 1) ──────────────────────────────────
//
// Copies the statics written by the step handler.  Reading without a mutex is
// acceptable here — the game master arms one step at a time and the display is
// informational; a torn read produces at worst a one-cycle stale value.

void game_endpoint_get_puzzle_status(char *step_id, size_t step_cap,
                                     char *armed,   size_t armed_cap) {
    if (step_id && step_cap > 0) {
        strncpy(step_id, s_current_step_id, step_cap - 1);
        step_id[step_cap - 1] = '\0';
    }
    if (armed && armed_cap > 0) {
        strncpy(armed, s_current_armed, armed_cap - 1);
        armed[armed_cap - 1] = '\0';
    }
}

// ─── POST /game/file?path=apps/<id>/<file> — provision shell apps ────────────
// Raw body → file under /littlefs/apps/ ONLY (whitelist + no traversal).
// Lets the GM push icon.png / step.txt for the display shell's dynamic tiles
// without reflashing a littlefs image.
#define GAME_ENDPOINT_MAX_FILE_BYTES (256 * 1024)

static esp_err_t handle_file_post(httpd_req_t *req) {
    char query[160], path_param[96];
    if (httpd_req_get_url_query_str(req, query, sizeof(query)) != ESP_OK ||
        httpd_query_key_value(query, "path", path_param,
                              sizeof(path_param)) != ESP_OK) {
        return send_error(req, "400 Bad Request", "missing path param");
    }
    // Route: apps/<…> → /littlefs/apps/<…> (display shell tiles, ≤256 KiB);
    //        sd/<…>   → /sdcard/<…>        (P4 large assets: audio, images).
    const bool to_sd   = (strncmp(path_param, "sd/", 3) == 0);
    const bool to_apps = (strncmp(path_param, "apps/", 5) == 0);
    if ((!to_sd && !to_apps) || strstr(path_param, "..") ||
        path_param[strlen(path_param) - 1] == '/') {
        return send_error(req, "403 Forbidden", "path must be under apps/ or sd/");
    }
    const long max_bytes = to_sd ? (8L * 1024 * 1024) : GAME_ENDPOINT_MAX_FILE_BYTES;
    if (req->content_len <= 0 || (long) req->content_len > max_bytes) {
        return send_error(req, "400 Bad Request", "file too large");
    }

    char full[192];
    size_t root_len;
    if (to_sd) {
        if (!sd_storage_ready()) {
            return send_error(req, "503 Service Unavailable", "sd_not_mounted");
        }
        snprintf(full, sizeof(full), "/sdcard/%s", path_param + 3);  // strip "sd/"
        root_len = strlen("/sdcard/");
    } else {
        if (mount_storage_lazy() != ESP_OK) {
            return send_error(req, "503 Service Unavailable", "storage_unavailable");
        }
        snprintf(full, sizeof(full), "/littlefs/%s", path_param);
        root_len = strlen("/littlefs/");
    }
    // mkdir -p for intermediate directories.
    for (char *p = full + root_len; *p; p++) {
        if (*p == '/') {
            *p = '\0';
            mkdir(full, 0775);   // EEXIST is fine
            *p = '/';
        }
    }

    FILE *f = fopen(full, "wb");
    if (!f) {
        return send_error(req, "500 Internal Server Error", "open failed");
    }
    char buf[1024];
    int remaining = (int) req->content_len;
    while (remaining > 0) {
        const int want = remaining < (int) sizeof(buf) ? remaining
                                                       : (int) sizeof(buf);
        int got = httpd_req_recv(req, buf, want);
        if (got <= 0) {
            if (got == HTTPD_SOCK_ERR_TIMEOUT) continue;
            fclose(f);
            unlink(full);
            return send_error(req, "400 Bad Request", "recv failed");
        }
        if (fwrite(buf, 1, (size_t) got, f) != (size_t) got) {
            fclose(f);
            unlink(full);
            return send_error(req, "500 Internal Server Error", "write failed");
        }
        remaining -= got;
    }
    fclose(f);

    char resp[192];
    snprintf(resp, sizeof(resp),
             "{\"status\":\"ok\",\"path\":\"%s\",\"bytes\":%d}",
             path_param, (int) req->content_len);
    ESP_LOGI(TAG, "file stored: %s (%d B)", full, (int) req->content_len);
    return send_json(req, "200 OK", resp);
}

// ─── DELETE /game/file?path=sd/<…>|apps/<…> — remove a staged file ───────────
// Same path routing/whitelist as the POST handler. Lets the host clean stale
// assets (e.g. orphan gamebook WAVs) off the SD without reflashing.
static esp_err_t handle_file_delete(httpd_req_t *req) {
    char query[160], path_param[96];
    if (httpd_req_get_url_query_str(req, query, sizeof(query)) != ESP_OK ||
        httpd_query_key_value(query, "path", path_param,
                              sizeof(path_param)) != ESP_OK) {
        return send_error(req, "400 Bad Request", "missing path param");
    }
    const bool to_sd   = (strncmp(path_param, "sd/", 3) == 0);
    const bool to_apps = (strncmp(path_param, "apps/", 5) == 0);
    if ((!to_sd && !to_apps) || strstr(path_param, "..") ||
        path_param[strlen(path_param) - 1] == '/') {
        return send_error(req, "403 Forbidden", "path must be under apps/ or sd/");
    }
    char full[192];
    if (to_sd) {
        if (!sd_storage_ready()) {
            return send_error(req, "503 Service Unavailable", "sd_not_mounted");
        }
        snprintf(full, sizeof(full), "/sdcard/%s", path_param + 3);
    } else {
        if (mount_storage_lazy() != ESP_OK) {
            return send_error(req, "503 Service Unavailable", "storage_unavailable");
        }
        snprintf(full, sizeof(full), "/littlefs/%s", path_param);
    }
    if (unlink(full) != 0) {
        return send_error(req, "404 Not Found", "remove failed");
    }
    ESP_LOGI(TAG, "file removed: %s", full);
    char resp[192];
    snprintf(resp, sizeof(resp), "{\"status\":\"ok\",\"removed\":\"%s\"}", path_param);
    return send_json(req, "200 OK", resp);
}

void game_endpoint_get_scene(scene_binding_t *out) {
    if (!out) return;
    // Lock-free snapshot, same class as get_puzzle_status: written on the
    // httpd task per step change, polled by main's status loop. A torn read
    // of display text during the copy is cosmetic and self-heals next poll.
    memcpy(out, &s_current_scene, sizeof(*out));
}

// ─── public init ────────────────────────────────────────────────────────────

esp_err_t game_endpoint_init(httpd_handle_t server) {
    if (server == NULL) {
        ESP_LOGE(TAG, "game_endpoint_init: NULL httpd handle "
                      "(did ota_server_init() succeed?)");
        return ESP_ERR_INVALID_ARG;
    }

    static const httpd_uri_t uri_get = {
        .uri      = "/game/group_profile",
        .method   = HTTP_GET,
        .handler  = handle_group_profile_get,
        .user_ctx = NULL,
    };
    static const httpd_uri_t uri_post = {
        .uri      = "/game/group_profile",
        .method   = HTTP_POST,
        .handler  = handle_group_profile_post,
        .user_ctx = NULL,
    };
    static const httpd_uri_t uri_scenario_post = {
        .uri      = "/game/scenario",
        .method   = HTTP_POST,
        .handler  = handle_scenario_post,
        .user_ctx = NULL,
    };
    static const httpd_uri_t uri_scenario_relay = {
        .uri      = "/game/scenario/relay",
        .method   = HTTP_POST,
        .handler  = handle_scenario_relay_post,
        .user_ctx = NULL,
    };
    static const httpd_uri_t uri_peers_get = {
        .uri      = "/game/peers",
        .method   = HTTP_GET,
        .handler  = handle_peers_get,
        .user_ctx = NULL,
    };
    static const httpd_uri_t uri_peers_post = {
        .uri      = "/game/peers",
        .method   = HTTP_POST,
        .handler  = handle_peers_post,
        .user_ctx = NULL,
    };
    static const httpd_uri_t uri_step_post = {
        .uri      = "/game/step",
        .method   = HTTP_POST,
        .handler  = handle_step_post,
        .user_ctx = NULL,
    };
    static const httpd_uri_t uri_puzzle_state_get = {
        .uri      = "/game/puzzle_state",
        .method   = HTTP_GET,
        .handler  = handle_puzzle_state_get,
        .user_ctx = NULL,
    };
    static const httpd_uri_t uri_file_post = {
        .uri      = "/game/file",
        .method   = HTTP_POST,
        .handler  = handle_file_post,
        .user_ctx = NULL,
    };
    static const httpd_uri_t uri_file_delete = {
        .uri      = "/game/file",
        .method   = HTTP_DELETE,
        .handler  = handle_file_delete,
        .user_ctx = NULL,
    };
    static const httpd_uri_t uri_espnow_cmd_post = {
        .uri      = "/game/espnow/cmd",
        .method   = HTTP_POST,
        .handler  = handle_espnow_cmd_post,
        .user_ctx = NULL,
    };
    static const httpd_uri_t uri_media_play_post = {
        .uri      = "/game/media/play",
        .method   = HTTP_POST,
        .handler  = handle_media_play_post,
        .user_ctx = NULL,
    };
    static const httpd_uri_t uri_gamebook_post = {
        .uri      = "/game/gamebook",
        .method   = HTTP_POST,
        .handler  = handle_gamebook_post,
        .user_ctx = NULL,
    };

    // Bring up the ESP-NOW mesh transport. The master is primarily a sender
    // (the relay handler) but we also register the apply adapter so a peer
    // could push a scenario back over ESP-NOW symmetrically. Non-fatal: if the
    // Wi-Fi stack isn't ready the relay endpoint will just report send errors.
    esp_err_t mesh_err = scenario_mesh_init(scenario_mesh_apply_adapter);
    if (mesh_err != ESP_OK) {
        ESP_LOGW(TAG, "scenario_mesh_init failed: %s — /game/scenario/relay "
                      "will be unavailable", esp_err_to_name(mesh_err));
    }

    esp_err_t err = httpd_register_uri_handler(server, &uri_get);
    if (err != ESP_OK) {
        ESP_LOGE(TAG, "register GET /game/group_profile: %s",
                 esp_err_to_name(err));
        return err;
    }
    err = httpd_register_uri_handler(server, &uri_post);
    if (err != ESP_OK) {
        ESP_LOGE(TAG, "register POST /game/group_profile: %s",
                 esp_err_to_name(err));
        return err;
    }
    err = httpd_register_uri_handler(server, &uri_scenario_post);
    if (err != ESP_OK) {
        ESP_LOGE(TAG, "register POST /game/scenario: %s",
                 esp_err_to_name(err));
        return err;
    }
    // Relay is best-effort: only register it when the mesh came up, but a
    // registration failure here is non-fatal to the rest of the surface.
    if (mesh_err == ESP_OK) {
        err = httpd_register_uri_handler(server, &uri_scenario_relay);
        if (err != ESP_OK) {
            ESP_LOGW(TAG, "register POST /game/scenario/relay: %s",
                     esp_err_to_name(err));
        }
        // CMD/EVT text channel: log inbound traffic + manual CMD injection.
        esp_err_t terr = scenario_mesh_set_text_cb(master_text_cb);
        if (terr != ESP_OK) {
            ESP_LOGW(TAG, "scenario_mesh_set_text_cb: %s",
                     esp_err_to_name(terr));
        }
        err = httpd_register_uri_handler(server, &uri_espnow_cmd_post);
        if (err != ESP_OK) {
            ESP_LOGW(TAG, "register POST /game/espnow/cmd: %s",
                     esp_err_to_name(err));
        }
    }
    // Peer registry management — non-fatal if registration fails.
    err = httpd_register_uri_handler(server, &uri_peers_get);
    if (err != ESP_OK) {
        ESP_LOGW(TAG, "register GET /game/peers: %s", esp_err_to_name(err));
    }
    err = httpd_register_uri_handler(server, &uri_peers_post);
    if (err != ESP_OK) {
        ESP_LOGW(TAG, "register POST /game/peers: %s", esp_err_to_name(err));
    }

    // Task C: puzzle arming + state readout — non-fatal.
    err = httpd_register_uri_handler(server, &uri_step_post);
    if (err != ESP_OK) {
        ESP_LOGW(TAG, "register POST /game/step: %s", esp_err_to_name(err));
    }
    err = httpd_register_uri_handler(server, &uri_file_delete);
    if (err != ESP_OK) {
        ESP_LOGW(TAG, "register DELETE /game/file: %s", esp_err_to_name(err));
    }
    err = httpd_register_uri_handler(server, &uri_file_post);
    if (err != ESP_OK) {
        ESP_LOGW(TAG, "register /game/file: %s", esp_err_to_name(err));
    }
    err = httpd_register_uri_handler(server, &uri_puzzle_state_get);
    if (err != ESP_OK) {
        ESP_LOGW(TAG, "register GET /game/puzzle_state: %s", esp_err_to_name(err));
    }
    err = httpd_register_uri_handler(server, &uri_media_play_post);
    if (err != ESP_OK) {
        ESP_LOGW(TAG, "register POST /game/media/play: %s", esp_err_to_name(err));
    }
    err = httpd_register_uri_handler(server, &uri_gamebook_post);
    if (err != ESP_OK) {
        ESP_LOGW(TAG, "register POST /game/gamebook: %s", esp_err_to_name(err));
    }

    ESP_LOGI(TAG, "game endpoint registered "
                  "(GET+POST /game/group_profile, POST /game/scenario%s, "
                  "GET+POST /game/peers, POST /game/step, GET /game/puzzle_state)",
             mesh_err == ESP_OK ? ", POST /game/scenario/relay" : "");
    return ESP_OK;
}
