// Zacus master — ESP-IDF entry point (P1 slice 2).
//
// Responsibilities at this slice:
//   1. Initialize NVS (required by Wi-Fi).
//   2. Initialize esp_netif + default event loop.
//   3. Read Wi-Fi creds from NVS namespace "wifi" (keys "ssid" / "pwd").
//        - If creds present  : start STA, wait for IP_EVENT_STA_GOT_IP.
//        - If creds absent   : fall back to open AP "zacus-setup" so the
//                              operator can still reach the OTA endpoint
//                              (and provision creds in a later slice).
//   4. Once the network is up, call ota_server_init() so the inherited
//      HTTP server (port 80) starts answering /version, /status, /ota.
//   5. Mount the LittleFS "storage" partition on /littlefs and list it.
//   6. Log heap stats + idle loop with periodic heartbeat (60 s).
//
// Subsequent slices port the NPC engine, voice pipeline, media manager, etc.
// See docs/superpowers/specs/2026-05-03-voice-pipeline-esp-sr-design.md.

#include <stdio.h>
#include <string.h>
#include <dirent.h>
#include <sys/stat.h>

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

#include "esp_log.h"
#include "esp_system.h"
#include "esp_heap_caps.h"
#include "esp_err.h"
#include "esp_event.h"
#include "esp_netif.h"
#include "esp_wifi.h"
#include "esp_littlefs.h"
#include "nvs_flash.h"
#include "nvs.h"

#include "mdns.h"

#include "ota_server.h"
#include "media_manager.h"
#include "sd_storage.h"
#include "npc_engine.h"
#include "hints_client.h"
#include "voice_pipeline.h"
#include "voice_dispatcher.h"
#include "voice_hook_endpoint.h"
#include "game_endpoint.h"
#include "scenario_mesh.h"
#include "puzzle_state.h"
#include "local_puzzles.h"
#include "qr_puzzle.h"
#include "buttons_input.h"
#include "mic_broker.h"
#include "board_pins_mediakit.h"
#include "display_ui.h"
#include "p7_coffre.h"
#include "p5_morse.h"
#include "p6_nfc.h"

// Hints engine endpoint (slice 5). Hardcoded for now — slice 7 will move
// this to NVS so the field operator can repoint the firmware without a flash.
#define ZACUS_HINTS_BASE_URL  "http://192.168.0.150:8302"

// Slice 7: voice-bridge WebSocket on the MacStudio (Tailscale address).
// Hardcoded here for the same reason as ZACUS_HINTS_BASE_URL — moves to
// NVS in a follow-up slice. The bridge endpoint is documented in
// docs/superpowers/specs/2026-05-03-voice-pipeline-esp-sr-design.md.
#define ZACUS_VOICE_BRIDGE_WS_URL  "ws://100.116.92.12:8200/voice/ws"

static const char *TAG = "zacus_main";

// ─── Puzzle aggregation state ─────────────────────────────────────────────────
static puzzle_state_t s_pstate;

// Soft-AP fallback when no creds in NVS yet.
#define ZACUS_FALLBACK_AP_SSID  "zacus-setup"
#define ZACUS_FALLBACK_AP_CHAN  6
#define ZACUS_STA_MAX_RETRY     8

// ─── Wi-Fi state ─────────────────────────────────────────────────────────────
static EventGroupHandle_t s_wifi_event_group;
#define WIFI_CONNECTED_BIT BIT0
#define WIFI_FAIL_BIT      BIT1
static int s_sta_retry = 0;

// ─── ota_server externs (real implementations come with the puzzle/master
//     state in a later P1 slice; for now we provide trivial stubs so the
//     component links cleanly). ─────────────────────────────────────────────
int puzzle_get_battery_pct(void) {
    return 100;
}

int puzzle_get_espnow_peer_count(void) {
    return scenario_mesh_peer_count();
}

// Slice 6: wake-word callback. Runs on the voice_pipeline capture task,
// keep it short. The pipeline already auto-transitioned to LISTENING
// before invoking us; here we just log + ensure capture is running so
// downstream STT (slice 7) has audio to consume.
static void on_voice_wake(const char *wake_word, void *user_ctx) {
    (void) user_ctx;
    ESP_LOGI(TAG, "WAKE: \"%s\" detected, transitioning to LISTENING",
             wake_word ? wake_word : "(null)");
    // Capture is already running (esp-sr feeds it), but if a future
    // slice toggles it off between wakes, this keeps us robust.
    esp_err_t err = voice_pipeline_start_capture();
    if (err != ESP_OK && err != ESP_ERR_INVALID_STATE) {
        ESP_LOGW(TAG, "voice_pipeline_start_capture from wake cb: %s",
                 esp_err_to_name(err));
    }
}

// Slice 7/8: STT callback. Runs on the WebSocket event-loop task —
// keep it short. The actual routing (keyword fast-path → hints engine,
// non-keyword → defer to LLM intent path) is owned by voice_dispatcher,
// which voice_pipeline_ws calls in parallel with this user callback.
// We keep the log here as a diagnostic breadcrumb for field debugging.
static void on_voice_stt(const char *text, bool final, void *user_ctx) {
    (void) user_ctx;
    ESP_LOGI(TAG, "STT(final=%d): %s", final ? 1 : 0,
             text ? text : "(null)");
}

// ─── Helpers ─────────────────────────────────────────────────────────────────

static void log_heap_stats(const char *phase) {
    ESP_LOGI(TAG, "[heap @ %s] free=%u internal=%u psram=%u",
             phase,
             (unsigned) esp_get_free_heap_size(),
             (unsigned) esp_get_free_internal_heap_size(),
             (unsigned) heap_caps_get_free_size(MALLOC_CAP_SPIRAM));
}

static esp_err_t mount_littlefs(void) {
    const esp_vfs_littlefs_conf_t conf = {
        .base_path        = "/littlefs",
        .partition_label  = "storage",
        .format_if_mount_failed = true,
        .dont_mount       = false,
    };
    esp_err_t err = esp_vfs_littlefs_register(&conf);
    if (err != ESP_OK) {
        ESP_LOGE(TAG, "LittleFS mount failed: %s", esp_err_to_name(err));
        return err;
    }

    size_t total = 0, used = 0;
    if (esp_littlefs_info(conf.partition_label, &total, &used) == ESP_OK) {
        ESP_LOGI(TAG, "LittleFS mounted at %s — %u / %u bytes used",
                 conf.base_path, (unsigned) used, (unsigned) total);
    }
    return ESP_OK;
}

static void list_littlefs_root(void) {
    DIR *dir = opendir("/littlefs");
    if (!dir) {
        ESP_LOGW(TAG, "opendir(/littlefs) failed");
        return;
    }
    struct dirent *ent;
    int count = 0;
    while ((ent = readdir(dir)) != NULL) {
        ESP_LOGI(TAG, "  /littlefs/%s (type=%d)", ent->d_name, ent->d_type);
        count++;
    }
    closedir(dir);
    ESP_LOGI(TAG, "LittleFS root contains %d entries", count);
}

// ─── ESP-NOW relay peer registry seed ────────────────────────────────────────
//
// The /game/scenario/relay endpoint resolves a peer alias (e.g. "box3",
// "plip", "p7_coffre") to a MAC via the scenario_mesh registry. Seed it from
// NVS namespace "peers": one entry per alias whose value is the 6-byte MAC
// blob (set with `idf.py nvs-partition-gen` or from the dashboard). This keeps
// MAC addresses out of the firmware image and lets the GM repoint a satellite
// board without a reflash.
//
// Provisioning example (nvs CSV):
//   key,type,encoding,value
//   peers,namespace,,
//   box3,data,base64,<base64 of the 6 MAC bytes>
static void seed_relay_peers_from_nvs(void) {
    nvs_iterator_t it = NULL;
    esp_err_t err = nvs_entry_find("nvs", "peers", NVS_TYPE_BLOB, &it);
    if (err != ESP_OK) {
        ESP_LOGI(TAG, "no NVS 'peers' namespace — relay registry empty "
                      "(set MACs to enable /game/scenario/relay)");
        return;
    }

    nvs_handle_t h;
    if (nvs_open("peers", NVS_READONLY, &h) != ESP_OK) {
        nvs_release_iterator(it);
        return;
    }

    int seeded = 0;
    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) {
            if (scenario_mesh_register_peer(info.key, mac) == ESP_OK) {
                seeded++;
            }
        } else {
            ESP_LOGW(TAG, "peers/%s: not a 6-byte MAC blob — skipped",
                     info.key);
        }
        err = nvs_entry_next(&it);
    }
    nvs_release_iterator(it);
    nvs_close(h);
    ESP_LOGI(TAG, "relay peer registry seeded: %d peer(s)", seeded);
}

// ─── Wi-Fi: NVS creds + event handler ────────────────────────────────────────

// Reads NVS namespace "wifi" keys "ssid" + "pwd". Returns ESP_OK if both
// keys are present and ssid is non-empty. Buffers are NUL-terminated.
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) {
        ESP_LOGI(TAG, "NVS namespace 'wifi' not found (%s)", esp_err_to_name(err));
        return err;
    }

    size_t len = ssid_len;
    err = nvs_get_str(h, "ssid", ssid, &len);
    if (err != ESP_OK || len <= 1) {
        ESP_LOGI(TAG, "NVS 'wifi/ssid' missing (%s)", esp_err_to_name(err));
        nvs_close(h);
        return ESP_ERR_NOT_FOUND;
    }

    len = pwd_len;
    err = nvs_get_str(h, "pwd", pwd, &len);
    if (err != ESP_OK) {
        // Empty password is acceptable (open network).
        pwd[0] = '\0';
    }
    nvs_close(h);
    return ESP_OK;
}

static void wifi_event_handler(void *arg, esp_event_base_t base,
                               int32_t id, void *data) {
    if (base == WIFI_EVENT && id == WIFI_EVENT_STA_START) {
        ESP_LOGI(TAG, "STA start — connecting…");
        esp_wifi_connect();
    } else if (base == WIFI_EVENT && id == WIFI_EVENT_STA_DISCONNECTED) {
        if (s_sta_retry < ZACUS_STA_MAX_RETRY) {
            s_sta_retry++;
            ESP_LOGW(TAG, "STA disconnected — retry %d/%d", s_sta_retry, ZACUS_STA_MAX_RETRY);
            esp_wifi_connect();
        } else {
            ESP_LOGE(TAG, "STA give up after %d retries", ZACUS_STA_MAX_RETRY);
            xEventGroupSetBits(s_wifi_event_group, WIFI_FAIL_BIT);
        }
    } else if (base == IP_EVENT && id == IP_EVENT_STA_GOT_IP) {
        ip_event_got_ip_t *event = (ip_event_got_ip_t *) data;
        ESP_LOGI(TAG, "STA got IP " IPSTR, IP2STR(&event->ip_info.ip));
        s_sta_retry = 0;
        xEventGroupSetBits(s_wifi_event_group, WIFI_CONNECTED_BIT);
    } else if (base == WIFI_EVENT && id == WIFI_EVENT_AP_START) {
        ESP_LOGI(TAG, "AP started — SSID=\"%s\" (open)", ZACUS_FALLBACK_AP_SSID);
    } else if (base == WIFI_EVENT && id == WIFI_EVENT_AP_STACONNECTED) {
        ESP_LOGI(TAG, "AP: client joined");
    }
}

// Returns true if STA connected, false if AP fallback (or STA gave up).
static bool wifi_bring_up(void) {
    s_wifi_event_group = xEventGroupCreate();

    ESP_ERROR_CHECK(esp_netif_init());
    ESP_ERROR_CHECK(esp_event_loop_create_default());

    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));

    char ssid[33] = {0};
    char pwd[65]  = {0};
    bool have_creds = (load_wifi_creds(ssid, sizeof(ssid), pwd, sizeof(pwd)) == ESP_OK);

    if (have_creds) {
        ESP_LOGI(TAG, "Wi-Fi: STA mode (ssid=\"%s\")", ssid);
        esp_netif_create_default_wifi_sta();

        wifi_config_t wc = {0};
        strncpy((char *) wc.sta.ssid,     ssid, sizeof(wc.sta.ssid) - 1);
        strncpy((char *) wc.sta.password, pwd,  sizeof(wc.sta.password) - 1);
        wc.sta.threshold.authmode = WIFI_AUTH_OPEN;  // accept any; we don't pin

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

        EventBits_t bits = xEventGroupWaitBits(
            s_wifi_event_group,
            WIFI_CONNECTED_BIT | WIFI_FAIL_BIT,
            pdFALSE, pdFALSE,
            pdMS_TO_TICKS(20000));

        if (bits & WIFI_CONNECTED_BIT) {
            return true;
        }
        ESP_LOGW(TAG, "STA failed within 20 s — fallback to AP");
        esp_wifi_stop();
    } else {
        ESP_LOGI(TAG, "Wi-Fi: no creds in NVS, starting AP fallback");
    }

    // AP fallback (open network — provisioning will be added later).
    esp_netif_create_default_wifi_ap();

    wifi_config_t ap_cfg = {0};
    strncpy((char *) ap_cfg.ap.ssid, ZACUS_FALLBACK_AP_SSID, sizeof(ap_cfg.ap.ssid) - 1);
    ap_cfg.ap.ssid_len       = strlen(ZACUS_FALLBACK_AP_SSID);
    ap_cfg.ap.channel        = ZACUS_FALLBACK_AP_CHAN;
    ap_cfg.ap.max_connection = 4;
    ap_cfg.ap.authmode       = WIFI_AUTH_OPEN;

    ESP_ERROR_CHECK(esp_wifi_set_mode(WIFI_MODE_AP));
    ESP_ERROR_CHECK(esp_wifi_set_config(WIFI_IF_AP, &ap_cfg));
    ESP_ERROR_CHECK(esp_wifi_start());
    return false;
}

// ─── mDNS bring-up (slice 12) ────────────────────────────────────────────────
//
// Publishes `zacus-master.local` once Wi-Fi STA is up so PLIP and the
// dashboard can discover the master without a DHCP reservation. We
// also advertise a `_zacus._tcp` service on port 80 with TXT records
// pointing at the voice-hook URI — useful for `dns-sd -B _zacus._tcp`
// style introspection from the workshop laptop.
//
// In AP-fallback mode we deliberately skip mDNS: there is no upstream
// resolver to claim the hostname against, and several tooling stacks
// (avahi, bonjour) trip over a duplicate-name race when the AP later
// goes back to STA. The PLIP fallback in that scenario is the static
// AP IP (192.168.4.1).

static void start_mdns(void) {
    esp_err_t err = mdns_init();
    if (err != ESP_OK) {
        ESP_LOGW(TAG, "mdns_init failed: %s", esp_err_to_name(err));
        return;
    }

    err = mdns_hostname_set("zacus-master");
    if (err != ESP_OK) {
        ESP_LOGW(TAG, "mdns_hostname_set: %s", esp_err_to_name(err));
        // Continue — the daemon is up, just no hostname claim.
    }

    err = mdns_instance_name_set("Zacus Master ESP32-S3");
    if (err != ESP_OK) {
        ESP_LOGW(TAG, "mdns_instance_name_set: %s", esp_err_to_name(err));
    }

    // Advertise the master HTTP surface as a `_zacus._tcp` service on
    // port 80. The TXT records let PLIP firmware confirm it found the
    // right device + which voice-hook path to POST to (so a future
    // protocol bump can be discovered without reflashing PLIP).
    err = mdns_service_add(NULL, "_zacus", "_tcp", 80, NULL, 0);
    if (err != ESP_OK) {
        ESP_LOGW(TAG, "mdns_service_add(_zacus._tcp:80): %s",
                 esp_err_to_name(err));
        return;
    }

    (void) mdns_service_instance_name_set("_zacus", "_tcp",
                                          "Zacus Master Voice Hook");

    mdns_txt_item_t txt[] = {
        {"path",    "/voice/hook"},
        {"version", "1"},
    };
    err = mdns_service_txt_set("_zacus", "_tcp", txt,
                               sizeof(txt) / sizeof(txt[0]));
    if (err != ESP_OK) {
        ESP_LOGW(TAG, "mdns_service_txt_set: %s", esp_err_to_name(err));
    }

    ESP_LOGI(TAG, "mDNS up — hostname=zacus-master.local, "
                  "service=_zacus._tcp:80");
}

// ─── app_main ────────────────────────────────────────────────────────────────

void app_main(void) {
    ESP_LOGI(TAG, "Zacus master booting (ESP-IDF scaffold, P1 slice 2)");

    log_heap_stats("boot");

    esp_err_t nvs_err = nvs_flash_init();
    if (nvs_err == ESP_ERR_NVS_NO_FREE_PAGES || nvs_err == ESP_ERR_NVS_NEW_VERSION_FOUND) {
        ESP_ERROR_CHECK(nvs_flash_erase());
        nvs_err = nvs_flash_init();
    }
    ESP_ERROR_CHECK(nvs_err);
    ESP_LOGI(TAG, "NVS initialized");

    // Phase 1 display: bring up the ST7796 TFT early so the splash is visible
    // while the rest of the subsystems initialise.  Non-fatal on failure.
    esp_err_t disp_err = display_ui_init();
    if (disp_err != ESP_OK) {
        ESP_LOGW(TAG, "display_ui_init: %s (continuing without display)",
                 esp_err_to_name(disp_err));
    } else {
        // Phase 3: mirror QR camera frames to the scene-view viewfinder.
        qr_puzzle_set_preview_cb(display_ui_camera_frame);
        // 5-way buttons (view toggle + brightness). Non-fatal.
        esp_err_t btn_err = buttons_input_init();
        if (btn_err != ESP_OK) {
            ESP_LOGW(TAG, "buttons_input_init: %s", esp_err_to_name(btn_err));
        }
    }

    bool sta_ok = wifi_bring_up();
    ESP_LOGI(TAG, "Wi-Fi up (mode=%s)", sta_ok ? "STA" : "AP-fallback");

    // Slice 12: publish zacus-master.local once we're on a real LAN.
    // Skip in AP-fallback to avoid hostname-claim races when STA later
    // recovers (and because there is no upstream resolver anyway).
    if (sta_ok) {
        start_mdns();
    } else {
        ESP_LOGW(TAG, "mDNS not started in AP mode "
                      "(PLIP must use the AP IP fallback)");
    }

    esp_err_t ota_err = ota_server_init();
    if (ota_err != ESP_OK) {
        ESP_LOGE(TAG, "ota_server_init failed: %s", esp_err_to_name(ota_err));
    } else {
        ESP_LOGI(TAG, "OTA server listening on :%d", OTA_SERVER_PORT);

        // Slice 10: piggyback the PLIP /voice/hook endpoint on the same
        // esp_http_server instance. Independent of voice_pipeline_init
        // success — the handler tolerates a degraded pipeline (the
        // voice_pipeline_* APIs return ESP_ERR_INVALID_STATE which we
        // log and report as a 200 with whatever state we have).
        httpd_handle_t httpd = ota_server_get_handle();
        esp_err_t hook_err = voice_hook_endpoint_init(httpd);
        if (hook_err != ESP_OK) {
            ESP_LOGW(TAG, "voice_hook_endpoint_init: %s",
                     esp_err_to_name(hook_err));
        }

        // Slice 12: REST surface for runtime game tuning. Today this
        // exposes /game/group_profile (GET + POST) so the dashboard /
        // GM can swap the hints policy without reflashing NVS. The
        // POST handler validates via hints_client_set_group_profile()
        // and persists to NVS namespace "zacus" / key "group_profile"
        // (the same slot main.c reads at boot).
        esp_err_t game_err = game_endpoint_init(httpd);
        if (game_err != ESP_OK) {
            ESP_LOGW(TAG, "game_endpoint_init: %s",
                     esp_err_to_name(game_err));
        } else {
            // game_endpoint_init brought up scenario_mesh (ESP-NOW). Seed the
            // relay peer registry from NVS so /game/scenario/relay can resolve
            // aliases to MACs without a reflash.
            seed_relay_peers_from_nvs();
            // Task C: give the endpoint access to the master puzzle state so
            // POST /game/step and GET /game/puzzle_state become live.
            // Called here so s_pstate is valid (puzzle_state_init runs below).
            // game_endpoint_set_puzzle_state tolerates being called before
            // puzzle_state_init — it only stores the pointer; actual reads
            // happen on-demand in the httpd task after boot completes.
            game_endpoint_set_puzzle_state(&s_pstate);
        }
    }

    // P4: best-effort microSD mount (SDMMC 1-bit on /sdcard). A missing card
    // is non-fatal — assets fall back to LittleFS.
    if (sd_storage_mount() == ESP_OK) {
        ESP_LOGI(TAG, "microSD ready (%lu MiB) at %s",
                 (unsigned long) sd_storage_capacity_mb(), SD_STORAGE_MOUNT_POINT);
    }

    if (mount_littlefs() == ESP_OK) {
        list_littlefs_root();

        // Slice 3: bring up the ported media_manager. Catalog dirs live on
        // LittleFS so this must run *after* the mount succeeds.
        media_manager_config_t media_cfg;
        media_manager_default_config(&media_cfg);
        esp_err_t media_err = media_manager_init(&media_cfg);
        if (media_err != ESP_OK) {
            ESP_LOGE(TAG, "media_manager_init failed: %s",
                     esp_err_to_name(media_err));
        } else {
            // Smoke test: try to play /littlefs/intro.mp3. The file is
            // unlikely to exist this early — that's fine, the manager
            // returns ESP_ERR_NOT_FOUND and logs a warning, no crash.
            esp_err_t play_err = media_manager_play("/littlefs/intro.mp3");
            ESP_LOGI(TAG, "media smoke play -> %s",
                     esp_err_to_name(play_err));

            // Slice 4: bring up the ported npc_engine. Cue table is empty
            // at this stage — wiring the scenario IR-driven cue catalog is
            // a follow-up slice. The engine still boots, accepts ticks
            // (no-op when auto_evaluate is false), and is ready to receive
            // trigger_cue calls from REST/diagnostic surfaces.
            const npc_engine_config_t npc_cfg = {
                .cues                = NULL,
                .cue_count           = 0,
                .auto_evaluate       = false,
                .auto_play_decisions = false,
            };
            esp_err_t npc_err = npc_engine_init(&npc_cfg);
            if (npc_err != ESP_OK) {
                ESP_LOGE(TAG, "npc_engine_init failed: %s",
                         esp_err_to_name(npc_err));
            }

            // Slice 5: bring up the hints HTTP client (so npc_engine can
            // route hint requests through the real backend) and the voice
            // pipeline (I2S capture stub + state machine, no auto-start).
            esp_err_t hints_err = hints_client_init(ZACUS_HINTS_BASE_URL);
            if (hints_err != ESP_OK) {
                ESP_LOGW(TAG, "hints_client_init failed: %s — npc will use stub",
                         esp_err_to_name(hints_err));
            } else {
                // Slice 11 (P5): load the group profile from NVS so the
                // hints engine can tune answers per audience (TECH /
                // NON_TECH / MIXED / BOTH). Default to MIXED when the
                // key is absent or holds an unknown value.
                // TODO(slice-11): endpoint /game/group_profile to update
                // NVS at runtime — for now the value is flashed by the
                // dashboard or `idf.py nvs-partition-gen` outputs.
                nvs_handle_t gh;
                esp_err_t open_err = nvs_open("zacus", NVS_READONLY, &gh);
                char profile[HINTS_CLIENT_GROUP_PROFILE_MAX] = "MIXED";
                if (open_err == ESP_OK) {
                    size_t plen = sizeof(profile);
                    esp_err_t kerr = nvs_get_str(gh, "group_profile",
                                                 profile, &plen);
                    if (kerr != ESP_OK) {
                        ESP_LOGI(TAG, "NVS zacus/group_profile missing (%s) "
                                      "— defaulting to MIXED",
                                 esp_err_to_name(kerr));
                        strncpy(profile, "MIXED", sizeof(profile) - 1);
                        profile[sizeof(profile) - 1] = '\0';
                    }
                    nvs_close(gh);
                } else {
                    ESP_LOGI(TAG, "NVS namespace 'zacus' not found (%s) "
                                  "— defaulting group_profile=MIXED",
                             esp_err_to_name(open_err));
                }
                esp_err_t set_err = hints_client_set_group_profile(profile);
                if (set_err != ESP_OK) {
                    // Validation rejected the NVS value — force MIXED
                    // so the engine still has a usable hint policy.
                    ESP_LOGW(TAG, "group_profile \"%s\" rejected — falling "
                                  "back to MIXED", profile);
                    (void) hints_client_set_group_profile("MIXED");
                }
            }

            // Slice 8: voice → npc_engine routing layer. Must come
            // after npc_engine_init / hints_client_init so the hint
            // fast-path lands on the real backend (fallback: local stub).
            esp_err_t disp_err = voice_dispatcher_init();
            if (disp_err != ESP_OK) {
                ESP_LOGW(TAG, "voice_dispatcher_init failed: %s",
                         esp_err_to_name(disp_err));
            }

            // Task 7: init puzzle aggregation + local puzzle wiring.
            puzzle_state_init(&s_pstate);
            local_puzzles_init(&s_pstate);
            ESP_LOGI(TAG, "puzzle_state + local_puzzles initialised");

            // P7 coffre actuator: configure GPIO/LEDC at boot so the
            // first call to p7_coffre_unlock() (on STEP_FINAL_WIN) has
            // zero warm-up latency.  A no-op stub when
            // CONFIG_ZACUS_P7_COFFRE_ENABLE=n (default).
            esp_err_t coffre_err = p7_coffre_init();
            if (coffre_err != ESP_OK) {
                ESP_LOGW(TAG, "p7_coffre_init: %s", esp_err_to_name(coffre_err));
            }

            // P5 Morse puzzle: configure telegraph-key GPIO and start the
            // decoding task. No-op stub when CONFIG_ZACUS_P5_MORSE_ENABLE=n.
            esp_err_t morse_err = p5_morse_init();
            if (morse_err != ESP_OK) {
                ESP_LOGW(TAG, "p5_morse_init: %s", esp_err_to_name(morse_err));
            }

            // P6 NFC puzzle: initialise bus and NFC polling task.
            // No-op stub when CONFIG_ZACUS_P6_NFC_ENABLE=n.
            esp_err_t nfc_err = p6_nfc_init();
            if (nfc_err != ESP_OK) {
                ESP_LOGW(TAG, "p6_nfc_init: %s", esp_err_to_name(nfc_err));
            }

            // Task 7: mic_broker takes ownership of the Media Kit I2S IN
            // pins (3/14/46 per board_pins_mediakit.h) BEFORE
            // voice_pipeline_init — the pipeline's own init call then
            // no-ops with ESP_ERR_INVALID_STATE, which it tolerates.
            // Note (a): a HARD failure here (anything other than
            // ESP_ERR_INVALID_STATE) will surface again inside
            // voice_pipeline_init — same root cause, two log warnings.
            // Note (b): the readout path is also deferred — nothing
            // consumes puzzle_state_code yet; that lands with the
            // scenario hook slice.
            esp_err_t mic_err = mic_broker_init(MIC_PIN_BCLK, MIC_PIN_WS,
                                                MIC_PIN_DIN, 16000);
            if (mic_err != ESP_OK && mic_err != ESP_ERR_INVALID_STATE) {
                ESP_LOGW(TAG, "mic_broker_init: %s", esp_err_to_name(mic_err));
            } else {
                ESP_LOGI(TAG, "mic_broker initialised (bclk=%d ws=%d din=%d 16kHz)",
                         MIC_PIN_BCLK, MIC_PIN_WS, MIC_PIN_DIN);
            }

            voice_pipeline_config_t voice_cfg;
            voice_pipeline_default_config(&voice_cfg);
            // Slice 6: bring up esp-sr AFE + WakeNet (placeholder
            // wn9_hiesp). Auto-start capture so the wake detector is
            // hot from boot — saying "Hi ESP" fires the callback below.
            voice_cfg.enable_wake_word   = true;
            voice_cfg.auto_start_capture = true;
            // Slice 7: stream post-AFE PCM to the MacStudio voice-bridge
            // over WebSocket once the wake word fires. The bridge runs
            // STT (whisper) and may auto-route to the LLM intent layer.
            voice_cfg.voice_bridge_ws_url = ZACUS_VOICE_BRIDGE_WS_URL;
            // Slice 9 + 10: enable the I2S TX leg so TTS payloads coming
            // back from the bridge land on the MAX98357A DAC. Wake-word
            // stays enabled — PLIP hook is the primary path for voice
            // sessions, but "hi esp" remains a backup if the phone is
            // unplugged or its hook switch fails.
            voice_cfg.enable_tts_playback = true;
            // Task 7 / Task 0 pin alignment: override voice_pipeline
            // defaults (mic 14/15/22, spk 11/12/13) which collide with
            // the FNK0102H camera pins. Use Media Kit values from
            // board_pins_mediakit.h (mic: 3/14/46, spk: 42/41/1).
            voice_cfg.i2s_bclk_pin     = MIC_PIN_BCLK;   // 3
            voice_cfg.i2s_ws_pin       = MIC_PIN_WS;     // 14
            voice_cfg.i2s_din_pin      = MIC_PIN_DIN;    // 46
            voice_cfg.i2s_out_bclk_pin = SPK_PIN_BCLK;   // 42
            voice_cfg.i2s_out_lrc_pin  = SPK_PIN_LRC;    // 41
            voice_cfg.i2s_out_din_pin  = SPK_PIN_DIN;    // 1
            voice_pipeline_set_wake_callback(on_voice_wake, NULL);
            voice_pipeline_set_stt_callback(on_voice_stt, NULL);
            esp_err_t voice_err = voice_pipeline_init(&voice_cfg);
            if (voice_err != ESP_OK) {
                ESP_LOGW(TAG, "voice_pipeline_init failed: %s",
                         esp_err_to_name(voice_err));
            } else if (voice_pipeline_wake_word_active()) {
                ESP_LOGI(TAG, "voice: wake-word detector active "
                              "(placeholder \"hi esp\")");
            } else {
                ESP_LOGW(TAG, "voice: wake-word inactive — running "
                              "in slice-5 stub mode");
            }

            // Arming is driven by POST /game/step (game_endpoint);
            // readout via GET /game/puzzle_state.
        }
    }

    log_heap_stats("post-init");

    // Mark this firmware valid only after subsystems came up cleanly.
    ota_server_mark_valid();

    ESP_LOGI(TAG, "entering idle loop (heartbeat every 60 s)");
    uint32_t tick        = 0;
    uint32_t disp_sub    = 0;   // sub-counter: display refresh every 2 s
    for (;;) {
        // Sleep in 2 s increments so the display stays responsive.
        // Every 30 sub-ticks (~60 s) we do the full heartbeat log.
        // 500 ms so scene changes (POST /game/step) reach the display fast.
        vTaskDelay(pdMS_TO_TICKS(500));
        disp_sub++;

        // ── Display status refresh (every 500 ms) ───────────────────────
        if (disp_err == ESP_OK) {
            display_status_t ds;
            memset(&ds, 0, sizeof(ds));

            // IP address (STA only; empty when in AP mode)
            esp_netif_t *sta_if = esp_netif_get_handle_from_ifkey("WIFI_STA_DEF");
            if (sta_if) {
                esp_netif_ip_info_t ip_info;
                if (esp_netif_get_ip_info(sta_if, &ip_info) == ESP_OK &&
                    ip_info.ip.addr != 0) {
                    snprintf(ds.ip, sizeof(ds.ip), IPSTR,
                             IP2STR(&ip_info.ip));
                }
            }

            // Wake-word detector state
            ds.wake_active = voice_pipeline_wake_word_active();

            // Step + armed type from game_endpoint
            game_endpoint_get_puzzle_status(ds.step_id, sizeof(ds.step_id),
                                            ds.armed,   sizeof(ds.armed));

            // Solved count + assembled code from puzzle_state
            ds.solved_count = 0;
            for (int i = 1; i <= PUZZLE_MAX_ID; i++) {
                if (s_pstate.solved[i]) {
                    ds.solved_count++;
                }
            }
            puzzle_state_code(&s_pstate, ds.code, sizeof(ds.code));

            // Scene metadata (title/subtitle/symbol/effect) of the step.
            scene_binding_t sb;
            game_endpoint_get_scene(&sb);
            if (sb.present) {
                memcpy(ds.scene_title,    sb.title,    sizeof(ds.scene_title));
                memcpy(ds.scene_subtitle, sb.subtitle, sizeof(ds.scene_subtitle));
                memcpy(ds.scene_symbol,   sb.symbol,   sizeof(ds.scene_symbol));
            }
            ds.scene_effect = (uint8_t) sb.effect;

            display_ui_set_status(&ds);

            // Shell app launch (dynamic tiles from /littlefs/apps/<id>/):
            // the app's step.txt names the scenario step to arm.
            char app_id[32];
            if (display_ui_take_pending_launch(app_id, sizeof(app_id))) {
                char path[80], step[64] = {0};
                snprintf(path, sizeof(path), "/littlefs/apps/%s/step.txt",
                         app_id);
                FILE *af = fopen(path, "r");
                if (af) {
                    if (fgets(step, sizeof(step), af)) {
                        step[strcspn(step, "\r\n")] = '\0';
                    }
                    fclose(af);
                }
                if (step[0]) {
                    char armed[8];
                    esp_err_t lerr = game_endpoint_apply_step(step, armed,
                                                              sizeof(armed));
                    ESP_LOGI(TAG, "app '%s' -> step '%s': %s (armed=%s)",
                             app_id, step, esp_err_to_name(lerr),
                             (lerr == ESP_OK) ? armed : "-");
                } else {
                    ESP_LOGW(TAG, "app '%s': no step.txt — nothing to do",
                             app_id);
                }
            }
        }

        // ── 60 s heartbeat (every 120 × 500 ms sub-ticks) ───────────────
        if (disp_sub >= 120) {
            disp_sub = 0;
            tick++;
            const uint32_t uptime_ms = (uint32_t) esp_log_timestamp();
            ESP_LOGI(TAG, "heartbeat #%u — uptime=%llu s",
                     (unsigned) tick,
                     (unsigned long long) (uptime_ms / 1000));
            // Drive the slice-3/4 subsystems from the heartbeat.
            media_manager_update(uptime_ms);
            npc_engine_update(uptime_ms);
        }
    }
}
