#include "board.h"
#include "approval_control.h"
#include "speech_control.h"
#include "mbedtls/base64.h"
#include "button_gesture.h"
#include "agent_target.h"
#include "agent_display.h"
#include "page_redraw_policy.h"
#include "cJSON.h"
#include "esp_random.h"
#include <deque>

#include <algorithm>
#include <array>
#include <cctype>
#include <cstdio>
#include <cstring>
#include <string>
#include <vector>

#include "audio_backend.h"
#include "M5GFX.h"
#include "transcript_font.h"
#include "M5IOE1.h"
#include "M5PM1.h"
#include "driver/gpio.h"
#include "driver/i2c_master.h"
#include "esp_heap_caps.h"
#include "esp_log.h"
#include "esp_psram.h"
#include "esp_timer.h"
#include "freertos/FreeRTOS.h"
#include "freertos/semphr.h"
#include "freertos/task.h"
#include "lgfx/v1/panel/Panel_AMOLED.hpp"

namespace {

constexpr char TAG[] = "BOARD";
const TranscriptFont s_transcript_font;

constexpr gpio_num_t I2C_SDA_PIN = GPIO_NUM_47;
constexpr gpio_num_t I2C_SCL_PIN = GPIO_NUM_48;
constexpr i2c_port_num_t I2C_PORT = I2C_NUM_0;

constexpr uint8_t PMIC_ADDRESS = 0x6e;
constexpr uint8_t IOE_PRIMARY_ADDRESS = 0x4f;
constexpr uint8_t IOE_SECONDARY_ADDRESS = 0x6f;
constexpr uint8_t RTC_ADDRESS = 0x32;
constexpr uint8_t IMU_ADDRESS = 0x68;
constexpr uint8_t TOUCH_ADDRESS = 0x15;

constexpr uint8_t MOTOR_PIN = M5IOE1_PIN_9;
constexpr uint8_t L3B_ENABLE_PIN = M5IOE1_PIN_8;
constexpr uint8_t SPEAKER_ENABLE_PIN = M5IOE1_PIN_10;
constexpr uint8_t TOUCH_RESET_PIN = M5IOE1_PIN_4;
constexpr uint8_t OLED_RESET_PIN = M5IOE1_PIN_5;
constexpr uint8_t MUX_CONTROL_PIN = M5IOE1_PIN_1;
constexpr uint8_t AUDIO_ENABLE_PIN = M5IOE1_PIN_3;
constexpr uint8_t MOTOR_PWM_CHANNEL = 0;
constexpr gpio_num_t SPEAKER_PA_PIN = GPIO_NUM_14;

constexpr uint8_t GENERIC_VIBRATION_DUTY = 75;
constexpr uint8_t NOTIFY_VIBRATION_DUTY = 90;
// The M5IOE1 PWM/I2C/logging call chain overflowed pi_notify's 2 KiB
// stack on physical DONE delivery. ESP-IDF task stack sizes are bytes.
constexpr uint32_t VIBRATION_TASK_STACK_BYTES = 4096;
struct VibrationStep {
    uint16_t on_ms;
    uint16_t off_ms;
};
constexpr std::array<VibrationStep, 2> DONE_VIBRATION_PATTERN = {{{250, 100}, {180, 0}}};
constexpr std::array<VibrationStep, 3> FAILED_VIBRATION_PATTERN = {
    {{240, 120}, {240, 120}, {240, 0}}};

constexpr std::array<VibrationStep, 2> APPROVAL_VIBRATION_PATTERN = {{{200, 100}, {200, 0}}};

constexpr size_t PI_LOCAL_TAIL_BYTES = 6 * 1024;
constexpr size_t PI_RENDER_SOURCE_BYTES = 1400;
constexpr uint32_t PI_REDRAW_MIN_INTERVAL_MS = 250;

constexpr gpio_num_t BUTTON_A_PIN = GPIO_NUM_2;
constexpr gpio_num_t BUTTON_B_PIN = GPIO_NUM_1;

constexpr gpio_num_t DISPLAY_SCLK_PIN = GPIO_NUM_40;
constexpr gpio_num_t DISPLAY_IO0_PIN = GPIO_NUM_41;
constexpr gpio_num_t DISPLAY_IO1_PIN = GPIO_NUM_42;
constexpr gpio_num_t DISPLAY_IO2_PIN = GPIO_NUM_46;
constexpr gpio_num_t DISPLAY_IO3_PIN = GPIO_NUM_45;
constexpr gpio_num_t DISPLAY_CS_PIN = GPIO_NUM_39;
constexpr gpio_num_t DISPLAY_TE_PIN = GPIO_NUM_38;

class StopWatchDisplay final : public M5GFX {
public:
    bool init_impl(bool use_reset, bool use_clear) override
    {
        auto bus_config = bus_.config();
        bus_config.freq_write = 80000000;
        bus_config.freq_read = 10000000;
        bus_config.pin_sclk = DISPLAY_SCLK_PIN;
        bus_config.pin_io0 = DISPLAY_IO0_PIN;
        bus_config.pin_io1 = DISPLAY_IO1_PIN;
        bus_config.pin_io2 = DISPLAY_IO2_PIN;
        bus_config.pin_io3 = DISPLAY_IO3_PIN;
        bus_config.spi_host = SPI2_HOST;
        bus_config.spi_mode = 0;
        bus_config.spi_3wire = true;
        bus_config.dma_channel = SPI_DMA_CH_AUTO;
        bus_.config(bus_config);
        panel_.setBus(&bus_);

        auto panel_config = panel_.config();
        panel_config.pin_rst = GPIO_NUM_NC;
        panel_config.pin_cs = DISPLAY_CS_PIN;
        panel_config.panel_width = 468;
        panel_config.panel_height = 466;
        panel_config.offset_x = 6;
        panel_config.offset_y = 0;
        panel_config.readable = false;
        panel_config.bus_shared = false;
        panel_.config(panel_config);
        setPanel(&panel_);

        lgfx::pinMode(DISPLAY_TE_PIN, lgfx::pin_mode_t::input_pullup);
        if (!LGFX_Device::init_impl(use_reset, use_clear)) {
            return false;
        }
        if (!panel_.initPanelFb()) {
            return false;
        }

        auto *framebuffer = panel_.getPanelFb();
        if (framebuffer == nullptr) {
            return false;
        }
        framebuffer->setBus(&bus_);
        framebuffer->setAutoDisplay(true);
        setPanel(framebuffer);
        panel_.setBrightness(128);
        return true;
    }

    void setPanelBrightness(uint8_t brightness)
    {
        panel_.setBrightness(brightness);
    }

private:
    lgfx::Bus_SPI bus_;
    lgfx::Panel_CO5300 panel_;
};


SemaphoreHandle_t s_display_lock;
char s_version[24] = "unknown";
char s_device_id[24] = "pending";
char s_wifi_detail[24] = "disconnected";
char s_last_error[96] = "not initialized";
bool s_wifi_connected;
bool s_cloud_connected;
bool s_display_ready;
bool s_vibrator_ready;
bool s_audio_ready;
i2c_master_bus_handle_t s_i2c_bus;
M5PM1 s_pmic;
M5IOE1 s_ioe;
StopWatchDisplay s_display;
ButtonGesture s_button_a;
ButtonGesture s_button_b;
ApprovalControl s_approval;
ApprovalAction s_approval_action;
std::string s_notified_approval;

using Page = DisplayPage;

struct ServerState {
    bool valid = false;
    char hostname[64] = "LIGHTHOUSE";
    float cpu_percent = 0;
    float load_1m = 0;
    float load_5m = 0;
    uint64_t memory_used = 0;
    uint64_t memory_total = 0;
    float memory_percent = 0;
    float swap_percent = 0;
    uint64_t disk_used = 0;
    uint64_t disk_total = 0;
    float disk_percent = 0;
    uint64_t network_rx_bytes = 0;
    uint64_t network_tx_bytes = 0;
    float network_rx_bps = 0;
    float network_tx_bps = 0;
    uint64_t uptime = 0;
};

struct PiState {
    bool selected = false;
    char agent[16] = "pi";
    char model[256] = "";
    char effort[64] = "";
    char session_id[161] = "";
    char name[64] = "";
    char cwd[128] = "";
    char state[24] = "OFFLINE";
    char activity_type[16] = "";
    // Gateway caps latest_activity at 480 UTF-8 bytes; keep the whole value so
    // strlcpy never cuts a multibyte character used by the tool view.
    char latest_activity[512] = "";
    bool has_final_text = false;
    char final_text[AGENT_FINAL_MAX_BYTES + 1] = "";
    uint64_t elapsed_seconds = 0;
    uint64_t received_at_ms = 0;
};

ServerState s_server;
PiState s_pi;
Page s_page = Page::OVERVIEW;
bool s_message_visible;
uint64_t s_message_until_ms;
uint64_t s_last_pi_redraw_ms;
uint64_t s_last_pi_elapsed_second;
bool s_pi_redraw_pending;
char *s_pi_tail;
size_t s_pi_tail_length;

void set_last_error(const char *stage, int code)
{
    std::snprintf(s_last_error, sizeof(s_last_error), "%s failed (code=%d)", stage, code);
}

bool address_present(uint8_t address)
{
    return s_i2c_bus != nullptr && i2c_master_probe(s_i2c_bus, address, 20) == ESP_OK;
}

esp_err_t initialize_i2c()
{
    i2c_master_bus_config_t config = {};
    config.i2c_port = I2C_PORT;
    config.sda_io_num = I2C_SDA_PIN;
    config.scl_io_num = I2C_SCL_PIN;
    config.clk_source = I2C_CLK_SRC_DEFAULT;
    config.glitch_ignore_cnt = 7;
    config.flags.enable_internal_pullup = true;

    const esp_err_t result = i2c_new_master_bus(&config, &s_i2c_bus);
    if (result != ESP_OK) {
        ESP_LOGE(TAG, "[DIAG] I2C init GPIO47(SDA)/GPIO48(SCL): FAIL code=%s (%d)",
                 esp_err_to_name(result), result);
        set_last_error("I2C init", result);
        return result;
    }

    char detected[384] = {};
    size_t used = 0;
    unsigned count = 0;
    for (uint8_t address = 0x08; address <= 0x77; ++address) {
        const esp_err_t probe = i2c_master_probe(s_i2c_bus, address, 20);
        if (probe == ESP_OK) {
            const int written = std::snprintf(detected + used, sizeof(detected) - used,
                                              "%s0x%02x", count == 0 ? "" : " ", address);
            if (written > 0 && static_cast<size_t>(written) < sizeof(detected) - used) {
                used += static_cast<size_t>(written);
            }
            ++count;
        }
    }
    ESP_LOGI(TAG, "[DIAG] I2C scan GPIO47(SDA)/GPIO48(SCL): count=%u addresses=%s",
             count, count == 0 ? "none" : detected);
    ESP_LOGI(TAG,
             "[DIAG] I2C roles: touch(0x15)=%s rtc(0x32)=%s imu(0x68)=%s pmic(0x6e)=%s ioe(0x4f/0x6f)=%s/%s",
             address_present(TOUCH_ADDRESS) ? "ACK" : "MISS",
             address_present(RTC_ADDRESS) ? "ACK" : "MISS",
             address_present(IMU_ADDRESS) ? "ACK" : "MISS",
             address_present(PMIC_ADDRESS) ? "ACK" : "MISS",
             address_present(IOE_PRIMARY_ADDRESS) ? "ACK" : "MISS",
             address_present(IOE_SECONDARY_ADDRESS) ? "ACK" : "MISS");
    return ESP_OK;
}

esp_err_t initialize_pmic()
{
    const m5pm1_err_t begin_result = s_pmic.begin(s_i2c_bus, PMIC_ADDRESS, M5PM1_I2C_FREQ_100K);
    if (begin_result != M5PM1_OK) {
        ESP_LOGE(TAG, "[DIAG] M5PM1 init addr=0x%02x: FAIL code=%d", PMIC_ADDRESS,
                 static_cast<int>(begin_result));
        set_last_error("M5PM1 init", static_cast<int>(begin_result));
        return ESP_ERR_NOT_FOUND;
    }

    uint8_t device_id = 0;
    uint8_t device_model = 0;
    const m5pm1_err_t id_result = s_pmic.getDeviceId(&device_id);
    const m5pm1_err_t model_result = s_pmic.getDeviceModel(&device_model);
    if (id_result != M5PM1_OK || model_result != M5PM1_OK ||
        device_id != 0x50 || device_model != 0x20) {
        ESP_LOGE(TAG,
                 "[DIAG] M5PM1 identity: FAIL id=0x%02x model=0x%02x id_code=%d model_code=%d expected=0x50/0x20",
                 device_id, device_model, static_cast<int>(id_result),
                 static_cast<int>(model_result));
        set_last_error("M5PM1 identity", ESP_ERR_INVALID_RESPONSE);
        return ESP_ERR_INVALID_RESPONSE;
    }

    const m5pm1_err_t sleep_1 = s_pmic.setI2cSleepTime(0);
    const m5pm1_err_t sleep_2 = s_pmic.setI2cSleepTime(0);
    const m5pm1_err_t watchdog = s_pmic.wdtSet(0);
    constexpr uint8_t required_power = M5PM1_PWR_CFG_CHG_EN | M5PM1_PWR_CFG_DCDC_EN |
                                       M5PM1_PWR_CFG_LDO_EN | M5PM1_PWR_CFG_LED_CTRL;
    const m5pm1_err_t power = s_pmic.setPowerConfig(required_power, required_power);
    const m5pm1_err_t hold = s_pmic.ldoSetPowerHold(true);
    if (sleep_1 != M5PM1_OK || sleep_2 != M5PM1_OK || watchdog != M5PM1_OK ||
        power != M5PM1_OK || hold != M5PM1_OK) {
        ESP_LOGE(TAG,
                 "[DIAG] M5PM1 core power setup: FAIL sleep=%d/%d wdt=%d power=%d hold=%d",
                 static_cast<int>(sleep_1), static_cast<int>(sleep_2),
                 static_cast<int>(watchdog), static_cast<int>(power), static_cast<int>(hold));
        set_last_error("M5PM1 core power setup", ESP_FAIL);
        return ESP_FAIL;
    }

    const m5pm1_err_t button = s_pmic.btnSetConfig(M5PM1_BTN_TYPE_CLICK, M5PM1_BTN_CLICK_DELAY_1000MS);
    const m5pm1_err_t reset = s_pmic.setSingleResetDisable(true);
    if (button != M5PM1_OK || reset != M5PM1_OK) {
        ESP_LOGW(TAG, "[DIAG] M5PM1 optional button config: FAIL button=%d reset=%d",
                 static_cast<int>(button), static_cast<int>(reset));
    }
    ESP_LOGI(TAG,
             "[DIAG] M5PM1 init addr=0x%02x: OK code=%d id=0x%02x model=0x%02x core_power=OK",
             PMIC_ADDRESS, static_cast<int>(begin_result), device_id, device_model);
    return ESP_OK;
}

bool configure_ioe_pin(uint8_t pin, uint8_t mode, uint8_t value, const char *name)
{
    m5ioe1_err_t mode_result = M5IOE1_OK;
    m5ioe1_err_t write_result = M5IOE1_OK;
    s_ioe.pinModeWithRes(pin, mode, &mode_result);
    s_ioe.digitalWriteWithRes(pin, value, &write_result);
    if (mode_result != M5IOE1_OK || write_result != M5IOE1_OK) {
        ESP_LOGE(TAG, "[DIAG] M5IOE1 pin %s(%u): FAIL mode=%d write=%d", name, pin,
                 static_cast<int>(mode_result), static_cast<int>(write_result));
        return false;
    }
    return true;
}

esp_err_t initialize_ioe()
{
    uint8_t selected_address = IOE_PRIMARY_ADDRESS;
    m5ioe1_err_t begin_result = s_ioe.begin(s_i2c_bus, selected_address, M5IOE1_I2C_FREQ_400K,
                                             M5IOE1_INT_MODE_DISABLED);
    const m5ioe1_err_t primary_result = begin_result;
    if (begin_result != M5IOE1_OK) {
        selected_address = IOE_SECONDARY_ADDRESS;
        begin_result = s_ioe.begin(s_i2c_bus, selected_address, M5IOE1_I2C_FREQ_400K,
                                   M5IOE1_INT_MODE_DISABLED);
    }
    if (begin_result != M5IOE1_OK) {
        ESP_LOGE(TAG, "[DIAG] M5IOE1 init: FAIL addr=0x%02x code=%d addr=0x%02x code=%d",
                 IOE_PRIMARY_ADDRESS, static_cast<int>(primary_result), IOE_SECONDARY_ADDRESS,
                 static_cast<int>(begin_result));
        set_last_error("M5IOE1 init", static_cast<int>(begin_result));
        return ESP_ERR_NOT_FOUND;
    }

    uint16_t ioe_uid = 0;
    const m5ioe1_err_t uid_result = s_ioe.getUID(&ioe_uid);
    if (uid_result != M5IOE1_OK) {
        ESP_LOGE(TAG, "[DIAG] M5IOE1 identity addr=0x%02x: FAIL code=%d", selected_address,
                 static_cast<int>(uid_result));
        set_last_error("M5IOE1 identity", static_cast<int>(uid_result));
        return ESP_ERR_INVALID_RESPONSE;
    }

    const m5ioe1_err_t sleep_1 = s_ioe.setI2cSleepTime(0);
    const m5ioe1_err_t sleep_2 = s_ioe.setI2cSleepTime(0);
    const bool l3b_ok = configure_ioe_pin(L3B_ENABLE_PIN, OUTPUT, HIGH, "L3B_EN");
    const bool oled_ok = configure_ioe_pin(OLED_RESET_PIN, OUTPUT, HIGH, "OLED_RST");
    if (sleep_1 != M5IOE1_OK || sleep_2 != M5IOE1_OK || !l3b_ok || !oled_ok) {
        ESP_LOGE(TAG, "[DIAG] M5IOE1 display power setup: FAIL sleep=%d/%d l3b=%s oled=%s",
                 static_cast<int>(sleep_1), static_cast<int>(sleep_2),
                 l3b_ok ? "OK" : "FAIL", oled_ok ? "OK" : "FAIL");
        set_last_error("M5IOE1 display power setup", ESP_FAIL);
        return ESP_FAIL;
    }

    m5ioe1_err_t l3b_read_result = M5IOE1_OK;
    int l3b_level = -1;
    for (int attempt = 1; attempt <= 3; ++attempt) {
        vTaskDelay(pdMS_TO_TICKS(80));
        l3b_level = s_ioe.digitalReadWithRes(L3B_ENABLE_PIN, &l3b_read_result);
        if (l3b_read_result == M5IOE1_OK && l3b_level == HIGH) {
            break;
        }
        m5ioe1_err_t retry_write = M5IOE1_OK;
        s_ioe.digitalWriteWithRes(L3B_ENABLE_PIN, HIGH, &retry_write);
        ESP_LOGW(TAG, "[DIAG] L3B_EN readback retry=%d level=%d read_code=%d write_code=%d",
                 attempt, l3b_level, static_cast<int>(l3b_read_result),
                 static_cast<int>(retry_write));
    }
    if (l3b_read_result != M5IOE1_OK || l3b_level != HIGH) {
        ESP_LOGE(TAG, "[DIAG] L3B_EN power rail: FAIL level=%d code=%d", l3b_level,
                 static_cast<int>(l3b_read_result));
        set_last_error("L3B_EN power rail", static_cast<int>(l3b_read_result));
        return ESP_FAIL;
    }
    ESP_LOGI(TAG, "[DIAG] L3B_EN power rail: OK level=%d code=%d", l3b_level,
             static_cast<int>(l3b_read_result));

    m5ioe1_err_t reset_low = M5IOE1_OK;
    m5ioe1_err_t reset_high = M5IOE1_OK;
    s_ioe.digitalWriteWithRes(OLED_RESET_PIN, LOW, &reset_low);
    vTaskDelay(pdMS_TO_TICKS(10));
    s_ioe.digitalWriteWithRes(OLED_RESET_PIN, HIGH, &reset_high);
    if (reset_low != M5IOE1_OK || reset_high != M5IOE1_OK) {
        ESP_LOGE(TAG, "[DIAG] OLED reset pulse: FAIL low=%d high=%d",
                 static_cast<int>(reset_low), static_cast<int>(reset_high));
        set_last_error("OLED reset pulse", ESP_FAIL);
        return ESP_FAIL;
    }

    const bool touch_reset_ok = configure_ioe_pin(TOUCH_RESET_PIN, OUTPUT, HIGH, "TP_RST");
    const bool speaker_ok = configure_ioe_pin(SPEAKER_ENABLE_PIN, OUTPUT, LOW, "SPK_PA");
    const bool mux_ok = configure_ioe_pin(MUX_CONTROL_PIN, OUTPUT, LOW, "MUX_CTR");
    const bool audio_ok = configure_ioe_pin(AUDIO_ENABLE_PIN, OUTPUT, HIGH, "AUDIO_EN");
    if (!touch_reset_ok || !speaker_ok || !mux_ok || !audio_ok) {
        ESP_LOGW(TAG, "[DIAG] M5IOE1 optional peripherals: touch_reset=%s speaker=%s mux=%s audio=%s",
                 touch_reset_ok ? "OK" : "FAIL", speaker_ok ? "OK" : "FAIL",
                 mux_ok ? "OK" : "FAIL", audio_ok ? "OK" : "FAIL");
    }
    const esp_err_t speaker_direction = gpio_set_direction(SPEAKER_PA_PIN, GPIO_MODE_OUTPUT);
    const esp_err_t speaker_level = gpio_set_level(SPEAKER_PA_PIN, LOW);
    if (speaker_direction != ESP_OK || speaker_level != ESP_OK) {
        ESP_LOGW(TAG, "[DIAG] speaker PA GPIO14 setup: FAIL direction=%d level=%d (nonfatal)",
                 speaker_direction, speaker_level);
    }

    m5ioe1_err_t motor_mode = M5IOE1_OK;
    s_ioe.pinModeWithRes(MOTOR_PIN, OUTPUT, &motor_mode);
    const m5ioe1_err_t motor_frequency = s_ioe.setPwmFrequency(5000);
    const m5ioe1_err_t motor_duty = s_ioe.setPwmDuty(MOTOR_PWM_CHANNEL, 0, false, true);
    s_vibrator_ready = motor_mode == M5IOE1_OK && motor_frequency == M5IOE1_OK &&
                       motor_duty == M5IOE1_OK;
    if (!s_vibrator_ready) {
        ESP_LOGW(TAG, "[DIAG] vibration init: FAIL pin=%d frequency=%d duty=%d (nonfatal)",
                 static_cast<int>(motor_mode), static_cast<int>(motor_frequency),
                 static_cast<int>(motor_duty));
    } else {
        ESP_LOGI(TAG, "[DIAG] vibration init: OK addr=0x%02x", selected_address);
    }

    ESP_LOGI(TAG, "[DIAG] M5IOE1 init addr=0x%02x: OK code=%d uid=0x%04x display_power=OK",
             selected_address, static_cast<int>(begin_result), ioe_uid);
    return ESP_OK;
}

esp_err_t initialize_display()
{
    const bool initialized = s_display.init();
    const int width = s_display.width();
    const int height = s_display.height();
    if (!initialized || width != 468 || height != 466) {
        ESP_LOGE(TAG, "[DIAG] CO5300 display init: FAIL init=%s size=%dx%d expected=468x466",
                 initialized ? "true" : "false", width, height);
        set_last_error("CO5300 display init", ESP_FAIL);
        return ESP_FAIL;
    }
    s_display.setRotation(0);
    s_display.setPanelBrightness(128);
    s_display_ready = true;
    ESP_LOGI(TAG, "[DIAG] CO5300 display init: OK qspi=80MHz size=%dx%d framebuffer=PSRAM", width,
             height);
    return ESP_OK;
}

void diagnose_touch_and_optional_i2c()
{
    m5ioe1_err_t reset_low = M5IOE1_OK;
    m5ioe1_err_t reset_high = M5IOE1_OK;
    s_ioe.digitalWriteWithRes(TOUCH_RESET_PIN, LOW, &reset_low);
    vTaskDelay(pdMS_TO_TICKS(10));
    s_ioe.digitalWriteWithRes(TOUCH_RESET_PIN, HIGH, &reset_high);
    vTaskDelay(pdMS_TO_TICKS(50));
    const bool reset_ok = reset_low == M5IOE1_OK && reset_high == M5IOE1_OK;

    ESP_LOGI(TAG, "[DIAG] touch CST820 addr=0x%02x: %s reset=%s (nonfatal)", TOUCH_ADDRESS,
             address_present(TOUCH_ADDRESS) ? "ACK" : "MISS", reset_ok ? "OK" : "FAIL");
    ESP_LOGI(TAG, "[DIAG] optional I2C: RTC(0x%02x)=%s IMU(0x%02x)=%s (nonfatal)",
             RTC_ADDRESS, address_present(RTC_ADDRESS) ? "ACK" : "MISS", IMU_ADDRESS,
             address_present(IMU_ADDRESS) ? "ACK" : "MISS");
}

void initialize_buttons()
{
    gpio_reset_pin(BUTTON_A_PIN);
    gpio_set_direction(BUTTON_A_PIN, GPIO_MODE_INPUT);
    gpio_set_pull_mode(BUTTON_A_PIN, GPIO_PULLUP_ONLY);
    gpio_reset_pin(BUTTON_B_PIN);
    gpio_set_direction(BUTTON_B_PIN, GPIO_MODE_INPUT);
    gpio_set_pull_mode(BUTTON_B_PIN, GPIO_PULLUP_ONLY);
    ESP_LOGI(TAG, "[DIAG] buttons init: OK A=GPIO2 B=GPIO1");
}

void initialize_audio_nonfatal()
{
    const esp_err_t codec_result = audio_backend_init(s_i2c_bus);
    if (codec_result != ESP_OK) {
        ESP_LOGW(TAG, "[AUDIO] init failed at ES8311/I2S: %s (%d) (nonfatal)",
                 esp_err_to_name(codec_result), codec_result);
        return;
    }

    m5ioe1_err_t ioe_result = M5IOE1_OK;
    s_ioe.digitalWriteWithRes(SPEAKER_ENABLE_PIN, HIGH, &ioe_result);
    const esp_err_t gpio_result = gpio_set_level(SPEAKER_PA_PIN, HIGH);
    if (ioe_result != M5IOE1_OK || gpio_result != ESP_OK) {
        s_ioe.digitalWrite(SPEAKER_ENABLE_PIN, LOW);
        gpio_set_level(SPEAKER_PA_PIN, LOW);
        ESP_LOGW(TAG, "[AUDIO] init failed at AW8737A enable: ioe=%d gpio=%d (nonfatal)",
                 static_cast<int>(ioe_result), gpio_result);
        return;
    }
    vTaskDelay(pdMS_TO_TICKS(10));
    s_audio_ready = audio_backend_ready();
    ESP_LOGI(TAG,
             "[AUDIO] init OK ES8311=ready I2S0=44.1kHz/16-bit MCLK=18 BCLK=17 LRCK=15 DOUT=21 AW8737A=enabled");
}

void lock_display()
{
    if (s_display_lock != nullptr) {
        xSemaphoreTakeRecursive(s_display_lock, portMAX_DELAY);
    }
}

void unlock_display()
{
    if (s_display_lock != nullptr) {
        xSemaphoreGiveRecursive(s_display_lock);
    }
}

uint16_t percent_color(float value)
{
    if (value >= 90) {
        return TFT_RED;
    }
    if (value >= 75) {
        return TFT_ORANGE;
    }
    return TFT_GREEN;
}

void format_bytes(uint64_t bytes, char *buffer, size_t length)
{
    constexpr double GIB = 1024.0 * 1024.0 * 1024.0;
    constexpr double MIB = 1024.0 * 1024.0;
    if (bytes >= static_cast<uint64_t>(GIB)) {
        std::snprintf(buffer, length, "%.1f GiB", bytes / GIB);
    } else {
        std::snprintf(buffer, length, "%.1f MiB", bytes / MIB);
    }
}

void format_rate(float bytes_per_second, char *buffer, size_t length)
{
    if (bytes_per_second >= 1000.0F * 1000.0F) {
        std::snprintf(buffer, length, "%.1f MB/s", bytes_per_second / 1000000.0F);
    } else if (bytes_per_second >= 1000.0F) {
        std::snprintf(buffer, length, "%.0f KB/s", bytes_per_second / 1000.0F);
    } else {
        std::snprintf(buffer, length, "%.0f B/s", bytes_per_second);
    }
}

size_t utf8_boundary(const char *value, size_t length, size_t offset)
{
    offset = std::min(offset, length);
    while (offset < length &&
           (static_cast<unsigned char>(value[offset]) & 0xC0U) == 0x80U) {
        ++offset;
    }
    return offset;
}

void set_pi_tail(const char *value)
{
    if (s_pi_tail == nullptr) {
        return;
    }
    value = value == nullptr ? "" : value;
    const size_t length = std::strlen(value);
    size_t start = length > PI_LOCAL_TAIL_BYTES ? length - PI_LOCAL_TAIL_BYTES : 0;
    start = utf8_boundary(value, length, start);
    s_pi_tail_length = std::min(length - start, PI_LOCAL_TAIL_BYTES);
    std::memcpy(s_pi_tail, value + start, s_pi_tail_length);
    s_pi_tail[s_pi_tail_length] = '\0';
}

void append_pi_tail(const char *activity_type, const char *activity)
{
    if (s_pi_tail == nullptr || activity == nullptr || activity[0] == '\0') {
        return;
    }
    std::string entry;
    if (activity_type != nullptr && std::strcmp(activity_type, "tool") == 0) {
        entry = "\n[TOOL] ";
        entry += activity;
        entry += '\n';
    } else {
        entry = activity;
    }
    if (entry.size() >= PI_LOCAL_TAIL_BYTES) {
        set_pi_tail(entry.c_str());
        return;
    }
    if (s_pi_tail_length + entry.size() > PI_LOCAL_TAIL_BYTES) {
        size_t drop = s_pi_tail_length + entry.size() - PI_LOCAL_TAIL_BYTES;
        drop = utf8_boundary(s_pi_tail, s_pi_tail_length, drop);
        std::memmove(s_pi_tail, s_pi_tail + drop, s_pi_tail_length - drop);
        s_pi_tail_length -= drop;
    }
    std::memcpy(s_pi_tail + s_pi_tail_length, entry.data(), entry.size());
    s_pi_tail_length += entry.size();
    s_pi_tail[s_pi_tail_length] = '\0';
}

size_t utf8_character_size(const char *value, size_t remaining)
{
    if (remaining == 0) {
        return 0;
    }
    const unsigned char first = static_cast<unsigned char>(value[0]);
    size_t size = 1;
    if ((first & 0xE0U) == 0xC0U) size = 2;
    else if ((first & 0xF0U) == 0xE0U) size = 3;
    else if ((first & 0xF8U) == 0xF0U) size = 4;
    return std::min(size, remaining);
}

std::vector<std::string> wrap_activity_text(const char *value, int maximum_width, bool tail = true)
{
    std::vector<std::string> lines;
    if (value == nullptr || value[0] == '\0') {
        return lines;
    }
    const size_t length = std::strlen(value);
    size_t offset = tail && length > PI_RENDER_SOURCE_BYTES ? length - PI_RENDER_SOURCE_BYTES : 0;
    offset = utf8_boundary(value, length, offset);
    std::string current;
    while (offset < length) {
        if (value[offset] == '\n') {
            lines.push_back(current);
            current.clear();
            ++offset;
            continue;
        }
        const size_t character_size = utf8_character_size(value + offset, length - offset);
        std::string character(value + offset, character_size);
        std::string candidate = current + character;
        if (!current.empty() && s_display.textWidth(candidate.c_str()) > maximum_width) {
            lines.push_back(current);
            current = character == " " ? "" : character;
        } else if (!current.empty() || character != " ") {
            current = std::move(candidate);
        }
        offset += character_size;
    }
    if (!current.empty()) {
        lines.push_back(current);
    }
    return lines;
}

void draw_header(const char *title)
{
    s_display.setTextDatum(textdatum_t::top_center);
    s_display.setTextColor(0xBDF7, TFT_BLACK);
    s_display.setTextSize(2);
    s_display.drawString(title, s_display.width() / 2, 38);
}

void draw_footer()
{
    const bool online = s_wifi_connected && s_cloud_connected;
    const uint16_t color = online ? TFT_GREEN : TFT_RED;
    s_display.fillCircle(155, 414, 7, color);
    s_display.setTextDatum(textdatum_t::middle_left);
    s_display.setTextColor(color, TFT_BLACK);
    s_display.setTextSize(1);
    s_display.drawString(online ? "ONLINE" : "OFFLINE", 172, 414);

    char indicator[12];
    std::snprintf(indicator, sizeof(indicator), "%u/%u", static_cast<unsigned>(s_page) + 1,
                  static_cast<unsigned>(Page::COUNT));
    s_display.setTextDatum(textdatum_t::middle_right);
    s_display.setTextColor(0x7BEF, TFT_BLACK);
    s_display.drawString(indicator, 360, 414);
}

void draw_percent_row(const char *label, float value, int y)
{
    char text[20];
    std::snprintf(text, sizeof(text), "%.0f%%", value);
    s_display.setTextDatum(textdatum_t::middle_left);
    s_display.setTextColor(TFT_WHITE, TFT_BLACK);
    s_display.setTextSize(2);
    s_display.drawString(label, 105, y);
    s_display.setTextDatum(textdatum_t::middle_right);
    s_display.setTextColor(percent_color(value), TFT_BLACK);
    s_display.drawString(text, 365, y);
}

void draw_progress(float value, int y)
{
    const int x = 85;
    const int width = 298;
    const int filled = static_cast<int>(std::clamp(value, 0.0F, 100.0F) * width / 100.0F);
    s_display.drawRoundRect(x, y, width, 20, 8, 0x4208);
    if (filled > 0) {
        s_display.fillRoundRect(x, y, filled, 20, 8, percent_color(value));
    }
}

void draw_overview()
{
    draw_header(s_server.hostname);
    if (!s_server.valid) {
        s_display.setTextDatum(textdatum_t::middle_center);
        s_display.setTextColor(0xBDF7, TFT_BLACK);
        s_display.setTextSize(2);
        s_display.drawString("WAITING FOR DATA", s_display.width() / 2, 220);
        return;
    }
    draw_percent_row("CPU", s_server.cpu_percent, 128);
    draw_percent_row("MEM", s_server.memory_percent, 187);
    draw_percent_row("DISK", s_server.disk_percent, 246);

    char down[24];
    char up[24];
    char line[64];
    format_rate(s_server.network_rx_bps, down, sizeof(down));
    format_rate(s_server.network_tx_bps, up, sizeof(up));
    s_display.setTextDatum(textdatum_t::top_left);
    s_display.setTextColor(0x7BEF, TFT_BLACK);
    s_display.setTextSize(1);
    s_display.drawString("NET", 105, 298);
    s_display.setTextColor(TFT_WHITE, TFT_BLACK);
    std::snprintf(line, sizeof(line), "RX  %s", down);
    s_display.drawString(line, 105, 326);
    std::snprintf(line, sizeof(line), "TX  %s", up);
    s_display.drawString(line, 105, 354);
}

void draw_cpu()
{
    draw_header("CPU");
    draw_percent_row("USAGE", s_server.cpu_percent, 145);
    draw_progress(s_server.cpu_percent, 184);
    char text[48];
    s_display.setTextDatum(textdatum_t::top_center);
    s_display.setTextColor(TFT_WHITE, TFT_BLACK);
    s_display.setTextSize(1);
    std::snprintf(text, sizeof(text), "Load 1m   %.2f", s_server.load_1m);
    s_display.drawString(text, s_display.width() / 2, 245);
    std::snprintf(text, sizeof(text), "Load 5m   %.2f", s_server.load_5m);
    s_display.drawString(text, s_display.width() / 2, 286);
    std::snprintf(text, sizeof(text), "Uptime   %llud %02lluh",
                  static_cast<unsigned long long>(s_server.uptime / 86400),
                  static_cast<unsigned long long>((s_server.uptime / 3600) % 24));
    s_display.setTextColor(0x7BEF, TFT_BLACK);
    s_display.drawString(text, s_display.width() / 2, 340);
}

void draw_memory()
{
    draw_header("MEMORY");
    draw_percent_row("USED", s_server.memory_percent, 145);
    draw_progress(s_server.memory_percent, 184);
    char used[24];
    char total[24];
    char text[64];
    format_bytes(s_server.memory_used, used, sizeof(used));
    format_bytes(s_server.memory_total, total, sizeof(total));
    s_display.setTextDatum(textdatum_t::top_center);
    s_display.setTextColor(TFT_WHITE, TFT_BLACK);
    s_display.setTextSize(1);
    std::snprintf(text, sizeof(text), "%s / %s", used, total);
    s_display.drawString(text, s_display.width() / 2, 248);
    std::snprintf(text, sizeof(text), "Swap   %.0f%%", s_server.swap_percent);
    s_display.setTextColor(percent_color(s_server.swap_percent), TFT_BLACK);
    s_display.drawString(text, s_display.width() / 2, 308);
}

void draw_disk()
{
    draw_header("DISK /");
    draw_percent_row("USED", s_server.disk_percent, 145);
    draw_progress(s_server.disk_percent, 184);
    char used[24];
    char total[24];
    char text[64];
    format_bytes(s_server.disk_used, used, sizeof(used));
    format_bytes(s_server.disk_total, total, sizeof(total));
    s_display.setTextDatum(textdatum_t::top_center);
    s_display.setTextColor(TFT_WHITE, TFT_BLACK);
    s_display.setTextSize(1);
    std::snprintf(text, sizeof(text), "%s / %s", used, total);
    s_display.drawString(text, s_display.width() / 2, 252);
    if (s_server.disk_percent >= 90) {
        s_display.setTextColor(TFT_RED, TFT_BLACK);
        s_display.setTextSize(2);
        s_display.drawString("SPACE LOW", s_display.width() / 2, 315);
    }
}

void draw_network()
{
    draw_header("NETWORK");
    char rx_rate[24];
    char tx_rate[24];
    char rx_total[24];
    char tx_total[24];
    char text[64];
    format_rate(s_server.network_rx_bps, rx_rate, sizeof(rx_rate));
    format_rate(s_server.network_tx_bps, tx_rate, sizeof(tx_rate));
    format_bytes(s_server.network_rx_bytes, rx_total, sizeof(rx_total));
    format_bytes(s_server.network_tx_bytes, tx_total, sizeof(tx_total));
    s_display.setTextDatum(textdatum_t::top_center);
    s_display.setTextColor(TFT_GREEN, TFT_BLACK);
    s_display.setTextSize(2);
    std::snprintf(text, sizeof(text), "RX  %s", rx_rate);
    s_display.drawString(text, s_display.width() / 2, 130);
    s_display.setTextColor(0xBDF7, TFT_BLACK);
    s_display.setTextSize(1);
    std::snprintf(text, sizeof(text), "Total %s", rx_total);
    s_display.drawString(text, s_display.width() / 2, 190);
    s_display.setTextColor(TFT_CYAN, TFT_BLACK);
    s_display.setTextSize(2);
    std::snprintf(text, sizeof(text), "TX  %s", tx_rate);
    s_display.drawString(text, s_display.width() / 2, 252);
    s_display.setTextColor(0xBDF7, TFT_BLACK);
    s_display.setTextSize(1);
    std::snprintf(text, sizeof(text), "Total %s", tx_total);
    s_display.drawString(text, s_display.width() / 2, 312);
}


enum class InputModal { NONE, VOICE, SETTINGS, ERROR };
InputModal s_input_modal = InputModal::NONE;
uint64_t s_ui_epoch = 1, s_voice_serial = 0, s_voice_started = 0, s_feedback_until = 0;
uint64_t s_modal_redraw = 0;
std::string s_voice_id, s_voice_phase, s_transcript, s_modal_error, s_settings_id;
std::string s_settings_phase, s_settings_target;
SpeechMenu s_speech_menu;
SpeechStream s_speech;
uint32_t s_speech_generation = 0;
std::string s_speech_text, s_read_screen, s_speech_request;

struct QuickModel { std::string id, name, default_effort; std::vector<std::string> efforts; };
std::vector<QuickModel> s_models;
size_t s_model_index = 0, s_effort_index = 0;
std::deque<std::string> s_control_queue;
board_button_t s_page_click = BOARD_BUTTON_NONE;

const char *page_name(Page page) {
    switch (page) {
    case Page::OVERVIEW: return "OVERVIEW";
    case Page::CPU: return "CPU";
    case Page::MEMORY: return "MEMORY";
    case Page::DISK: return "DISK";
    case Page::NETWORK: return "NETWORK";
    case Page::PI: return "PI";
    default: return "UNKNOWN";
    }
}
const char *modal_name(InputModal modal) {
    switch (modal) {
    case InputModal::NONE: return "NONE";
    case InputModal::VOICE: return "VOICE";
    case InputModal::SETTINGS: return "SETTINGS";
    case InputModal::ERROR: return "ERROR";
    }
    return "UNKNOWN";
}
const char *gesture_name(Gesture gesture) {
    switch (gesture) {
    case Gesture::CLICK: return "CLICK";
    case Gesture::LONG: return "LONG";
    case Gesture::LONG_RELEASE: return "LONG_RELEASE";
    default: return "NONE";
    }
}
const char *settings_page_name(SpeechMenu::Page page) {
    switch (page) {
    case SpeechMenu::Page::ROOT: return "ROOT";
    case SpeechMenu::Page::SPEECH: return "SPEECH";
    case SpeechMenu::Page::MODE: return "MODE";
    }
    return "UNKNOWN";
}
const char *settings_action_name(SpeechMenuAction action) {
    switch (action) {
    case SpeechMenuAction::AGENT_SETTINGS: return "AGENT_SETTINGS";
    case SpeechMenuAction::LOAD_SPEECH: return "LOAD_SPEECH";
    case SpeechMenuAction::SAVE_MODE: return "SAVE_MODE";
    case SpeechMenuAction::READ_SCREEN: return "READ_SCREEN";
    default: return "NONE";
    }
}
void log_modal_transition(InputModal before, const char *reason) {
    if (before != s_input_modal)
        ESP_LOGI(TAG, "[UI] modal %s -> %s reason=%s ui_epoch=%llu",
                 modal_name(before), modal_name(s_input_modal), reason,
                 static_cast<unsigned long long>(s_ui_epoch));
}
void log_stable_edge(bool a, bool before, const ButtonGesture &button) {
    if (before != button.down())
        ESP_LOGI(TAG, "[INPUT] %s %s event_epoch=%llu current_epoch=%llu ui_epoch=%llu page=%s modal=%s settings_phase=%s",
                 a ? "A" : "B", button.down() ? "DOWN" : "UP",
                 static_cast<unsigned long long>(button.epoch),
                 static_cast<unsigned long long>(s_ui_epoch+s_approval.generation),
                 static_cast<unsigned long long>(s_ui_epoch), page_name(s_page),
                 modal_name(s_input_modal), s_settings_phase.c_str());
}
void draw_status(const char *reason="ui_event");
bool page_overlay_visible() {
    return s_message_visible || s_approval.modal() || s_input_modal!=InputModal::NONE || !s_speech.id.empty();
}

template<size_t N> bool stored_text_equal(const char (&stored)[N], const char *incoming) {
    return std::strncmp(stored,incoming?incoming:"",N-1)==0;
}

bool rendered_tail_equal(const char *before, const char *after) {
    before=before?before:"";after=after?after:"";
    const size_t before_length=std::strlen(before), after_length=std::strlen(after);
    const size_t before_start=utf8_boundary(before,before_length,before_length>PI_RENDER_SOURCE_BYTES?before_length-PI_RENDER_SOURCE_BYTES:0);
    const size_t after_start=utf8_boundary(after,after_length,after_length>PI_RENDER_SOURCE_BYTES?after_length-PI_RENDER_SOURCE_BYTES:0);
    return std::strcmp(before+before_start,after+after_start)==0;
}

bool pi_snapshot_changes_view(const board_pi_snapshot_t &next) {
    const auto *session=next.session_id?next.session_id:"unknown";
    const bool old_tool=std::strcmp(s_pi.activity_type,"tool")==0;
    const bool new_tool=next.activity_type && std::strcmp(next.activity_type,"tool")==0;
    return !stored_text_equal(s_pi.agent,next.agent?next.agent:"pi") ||
           !stored_text_equal(s_pi.model,next.model) || !stored_text_equal(s_pi.effort,next.effort) ||
           !stored_text_equal(s_pi.name,next.name?next.name:session) ||
           !stored_text_equal(s_pi.state,next.state?next.state:"OFFLINE") ||
           s_pi.has_final_text!=(next.final_text!=nullptr) || !stored_text_equal(s_pi.final_text,next.final_text) ||
           s_pi.elapsed_seconds!=next.elapsed_seconds || old_tool!=new_tool ||
           (new_tool?!stored_text_equal(s_pi.latest_activity,next.latest_activity):
                     !rendered_tail_equal(s_pi_tail,next.recent_text));
}
std::string js(cJSON *root, const char *key) {
    const auto *item = cJSON_GetObjectItemCaseSensitive(root, key);
    return cJSON_IsString(item) ? item->valuestring : "";
}
std::string new_identity(uint64_t *serial = nullptr) {
    const uint64_t n = (static_cast<uint64_t>(esp_random()) << 32) | esp_random();
    if (serial) *serial = n;
    char id[17]; snprintf(id, sizeof(id), "%016llx", static_cast<unsigned long long>(n)); return id;
}
std::string selected_target() { return s_pi.selected ? std::string("agent:") + s_pi.agent + ":" + s_pi.session_id : "server"; }
void queue_control(cJSON *root) {
    const auto type=js(root,"type");
    ESP_LOGI(TAG,"[CONTROL] queue type=%s depth=%u",type.c_str(),static_cast<unsigned>(s_control_queue.size()));
    char *wire = cJSON_PrintUnformatted(root);
    if (wire && s_control_queue.size() < 8) s_control_queue.emplace_back(wire);
    else { const auto before=s_input_modal; audio_backend_record_stop(true); s_input_modal=InputModal::ERROR; s_modal_error="Control queue full"; ++s_ui_epoch; log_modal_transition(before,"control-queue-full"); }
    cJSON_free(wire); cJSON_Delete(root);
}
#ifdef CONFIG_DEVICE_LAB_SPEECH_TRACE
static void speech_rx_summary(const char *reason);
#endif
void stop_speech(bool notify=true) {
#ifdef CONFIG_DEVICE_LAB_SPEECH_TRACE
    if (!s_speech.id.empty()) speech_rx_summary("stop");
#endif
    if (!s_speech.id.empty())
        ESP_LOGI(TAG,"[SPEECH] stop id=%s chunks=%u ended=%d notify=%d",s_speech.id.c_str(),
                 s_speech.sequence,s_speech.ended,notify);
    audio_backend_speech_stop();
    if (notify) {
        auto *root=cJSON_CreateObject();cJSON_AddStringToObject(root,"type","speech_stop");
        // Missing ID cancels even an in-flight synthesis that has not started playing.
        queue_control(root);
    }
    s_speech.stop();s_speech_text.clear();
}
void speech_command(const char *type) {
    auto *root=cJSON_CreateObject(); cJSON_AddStringToObject(root,"type",type);
    s_speech_request=new_identity();
    cJSON_AddStringToObject(root,"request_id",s_speech_request.c_str());
    cJSON_AddStringToObject(root,"target",selected_target().c_str());
    if (std::strcmp(type,"speech_settings_set")==0) cJSON_AddStringToObject(root,"mode",SpeechMenu::mode_name(s_speech_menu.mode));
    if (std::strcmp(type,"speech_request")==0) {
        cJSON_AddStringToObject(root,"source","screen");cJSON_AddStringToObject(root,"text",s_read_screen.c_str());
    }
    queue_control(root);
}
std::string read_screen_text() {
    char text[512];
    if (s_page==Page::PI) {
        if (!s_pi.selected) return "No agent session selected.";
        std::string result=std::string(s_pi.agent)+". "+s_pi.state+". "+s_pi.name+". ";
        const auto view=agent_display_text(s_pi.state,s_pi.has_final_text?s_pi.final_text:nullptr,
            s_pi.activity_type,s_pi.latest_activity,s_pi_tail);
        const auto *previous_font=s_display.getFont();
        s_display.setFont(&s_transcript_font);
        const auto lines=wrap_activity_text(view.text,304,!view.terminal);
        s_display.setFont(previous_font);
        const size_t count=view.tool?6:8;
        const size_t first=!view.terminal && lines.size()>count?lines.size()-count:0;
        for (size_t i=first;i<std::min(first+count,lines.size());++i) result+=lines[i]+" ";
        if (result.size()>1200) {size_t n=1200;while (n && (static_cast<unsigned char>(result[n])&0xc0)==0x80)--n;result.resize(n);}
        return result;
    }
    switch (s_page) {
    case Page::CPU: snprintf(text,sizeof(text),"CPU %.0f percent. Load %.2f, %.2f.",s_server.cpu_percent,s_server.load_1m,s_server.load_5m);break;
    case Page::MEMORY: snprintf(text,sizeof(text),"Memory %.0f percent. Swap %.0f percent.",s_server.memory_percent,s_server.swap_percent);break;
    case Page::DISK: snprintf(text,sizeof(text),"Disk %.0f percent.",s_server.disk_percent);break;
    case Page::NETWORK: snprintf(text,sizeof(text),"Network receive %.0f bytes per second. Send %.0f bytes per second.",s_server.network_rx_bps,s_server.network_tx_bps);break;
    default: snprintf(text,sizeof(text),"CPU %.0f percent. Memory %.0f percent. Disk %.0f percent.",s_server.cpu_percent,s_server.memory_percent,s_server.disk_percent);break;
    }
    return text;
}
void voice_command(const char *type, const char *decision = nullptr) {
    if (s_voice_id.empty()) return;
    auto *root=cJSON_CreateObject(); cJSON_AddStringToObject(root,"type",type);
    cJSON_AddStringToObject(root,"voice_id",s_voice_id.c_str());
    if (decision) cJSON_AddStringToObject(root,"decision",decision);
    queue_control(root);
}
void settings_command(const char *type) {
    auto *root=cJSON_CreateObject(); cJSON_AddStringToObject(root,"type",type);
    cJSON_AddStringToObject(root,"request_id",s_settings_id.c_str());
    cJSON_AddStringToObject(root,"target",s_settings_target.c_str());
    if (std::strcmp(type,"settings_apply")==0 && !s_models.empty()) {
        const auto &m=s_models[s_model_index]; auto *value=cJSON_AddObjectToObject(root,"settings");
        cJSON_AddStringToObject(value,"model",m.id.c_str());
        if (!m.efforts.empty()) cJSON_AddStringToObject(value,"effort",m.efforts[s_effort_index].c_str());
    }
    queue_control(root);
}
void cancel_input(bool notify, const char *reason) {
    const auto before=s_input_modal;
    if (before!=InputModal::NONE) {
        ESP_LOGI(TAG, "[UI] cancel modal=%s reason=%s notify=%d voice_id=%s",
                 modal_name(before), reason, notify, s_voice_id.c_str());
        if (before==InputModal::VOICE) ESP_LOGI(TAG, "[VOICE] cancelled reason=%s", reason);
    }
    audio_backend_record_stop(true);
    if (notify && s_input_modal==InputModal::VOICE) voice_command("voice_cancel");
    if (notify && s_input_modal==InputModal::SETTINGS) settings_command("settings_cancel");
    s_input_modal=InputModal::NONE; s_transcript.clear(); s_voice_id.clear(); s_settings_id.clear();
    s_feedback_until=0; ++s_ui_epoch;
    log_modal_transition(before,reason);
}
void handle_gesture(bool a, Gesture g, uint64_t epoch, uint64_t now) {
    if (g==Gesture::NONE) return;
    ESP_LOGI(TAG, "[INPUT] %s %s event_epoch=%llu current_epoch=%llu ui_epoch=%llu page=%s modal=%s settings_phase=%s approval=%d",
             a?"A":"B", gesture_name(g), static_cast<unsigned long long>(epoch),
             static_cast<unsigned long long>(s_ui_epoch+s_approval.generation),
             static_cast<unsigned long long>(s_ui_epoch), page_name(s_page),
             modal_name(s_input_modal), s_settings_phase.c_str(), s_approval.modal());
    const auto before=s_input_modal;
    if (s_approval.modal()) {
        if (g==Gesture::CLICK && epoch==s_ui_epoch+s_approval.generation) {
            auto action=s_approval.release(a,s_approval.generation,now);
            if (!action.id.empty()) s_approval_action=action;
        }
        draw_status(); return;
    }
    if (g==Gesture::LONG_RELEASE) {
        if (a && s_input_modal==InputModal::VOICE && s_voice_phase=="RECORDING") {
            ESP_LOGI(TAG,"[VOICE] long-release voice_id=%s -> TRANSCRIBING",s_voice_id.c_str());
            audio_backend_record_stop(false); s_voice_phase="TRANSCRIBING"; draw_status();
        }
        return;
    }
    if (epoch!=s_ui_epoch+s_approval.generation) { ESP_LOGI(TAG,"[INPUT] ignored reason=stale-epoch"); return; }
    if (s_input_modal==InputModal::VOICE) {
        if (g==Gesture::CLICK && s_voice_phase=="AWAITING_CONFIRMATION") {
            voice_command("voice_decision",a?"send":"cancel");
            if (a) { s_voice_phase="SENDING"; ++s_ui_epoch; } else cancel_input(false,"user-decision-cancel");
        } else if (!a && g==Gesture::CLICK) cancel_input(true,"user-cancel");
    } else if (s_input_modal==InputModal::ERROR) {
        if (!a) cancel_input(true,"error-close");
    } else if (s_input_modal==InputModal::SETTINGS) {
        if (!a && g==Gesture::LONG) cancel_input(true,"settings-close");
        else if (g==Gesture::CLICK && s_settings_phase=="MENU") {
            const auto old_page=s_speech_menu.page;
            const auto old_index=s_speech_menu.index, old_mode=s_speech_menu.mode;
            const auto action=s_speech_menu.click(a);
            auto label=s_speech_menu.label();
            std::replace(label.begin(),label.end(),'\n',' ');
            ESP_LOGI(TAG,"[SETTINGS] %s %s page=%s -> %s index=%u -> %u action=%s label=%s",
                     a?"A":"B",a?"NEXT":"SELECT",settings_page_name(old_page),
                     settings_page_name(s_speech_menu.page),old_index,s_speech_menu.index,
                     settings_action_name(action),label.c_str());
            if (old_mode!=s_speech_menu.mode)
                ESP_LOGI(TAG,"[SETTINGS] mode %s -> %s",SpeechMenu::mode_name(old_mode),SpeechMenu::mode_name(s_speech_menu.mode));
            if (action==SpeechMenuAction::AGENT_SETTINGS) {
                if (!s_pi.selected) {s_settings_phase="NO AGENT SELECTED";}
                else {s_settings_phase="LOADING"; settings_command("settings_get");}
            } else if (action==SpeechMenuAction::LOAD_SPEECH) speech_command("speech_settings_get");
            else if (action==SpeechMenuAction::SAVE_MODE) {speech_command("speech_settings_set");s_settings_phase="SAVING SPEECH";}
            else if (action==SpeechMenuAction::READ_SCREEN) {speech_command("speech_request");cancel_input(false,"read-screen");}
            ++s_ui_epoch;
        }
        else if (g==Gesture::CLICK && !s_models.empty() && (s_settings_phase=="MODEL" || s_settings_phase=="EFFORT")) {
            ESP_LOGI(TAG,"[SETTINGS] %s %s phase=%s model_index=%u effort_index=%u",
                     a?"A":"B",a?"NEXT":"SELECT",s_settings_phase.c_str(),
                     static_cast<unsigned>(s_model_index),static_cast<unsigned>(s_effort_index));
            if (a) {
                if (s_settings_phase=="MODEL") s_model_index=(s_model_index+1)%s_models.size();
                else s_effort_index=(s_effort_index+1)%s_models[s_model_index].efforts.size();
            } else if (s_settings_phase=="MODEL" && !s_models[s_model_index].efforts.empty()) {
                s_settings_phase="EFFORT"; s_effort_index=0;
                const auto &m=s_models[s_model_index];
                for (size_t i=0;i<m.efforts.size();++i) if (m.efforts[i]==m.default_effort) s_effort_index=i;
                ++s_ui_epoch;
            } else { settings_command("settings_apply"); s_settings_phase="APPLYING"; ++s_ui_epoch; }
        }
    } else if (g==Gesture::LONG) {
        stop_speech();
        if ((a && !s_pi.selected) || !s_cloud_connected) {
            s_input_modal=InputModal::ERROR; s_modal_error=s_pi.selected?"OFFLINE":"NO AGENT SELECTED"; ++s_ui_epoch;
        } else if (a) {
            s_voice_id=new_identity(&s_voice_serial); s_voice_started=now;
            ESP_LOGI(TAG,"[VOICE] start voice_id=%s",s_voice_id.c_str());
            if (audio_backend_record_start(s_voice_serial)) { s_input_modal=InputModal::VOICE; s_voice_phase="RECORDING"; }
            else {s_input_modal=InputModal::ERROR;s_modal_error="Microphone unavailable";}
            ++s_ui_epoch;
        } else {
            s_settings_id=new_identity();s_settings_target=selected_target();s_settings_phase="MENU";s_models.clear();
            s_read_screen=read_screen_text();s_speech_menu.open();
            s_input_modal=InputModal::SETTINGS; ++s_ui_epoch;
        }
    } else if (g==Gesture::CLICK) {
        s_page_click=a?BOARD_BUTTON_A:BOARD_BUTTON_B;
        ESP_LOGI(TAG,"[INPUT] %s CLICK queued for page transition",a?"A":"B");
    }
    if (before==InputModal::NONE) log_modal_transition(before,g==Gesture::LONG?(a?"long-A":"long-B"):"click");
    draw_status();
}
void draw_input_modal() {
    draw_header(s_input_modal==InputModal::SETTINGS?"SETTINGS":s_input_modal==InputModal::ERROR?"ERROR":"VOICE");
    s_display.setTextDatum(textdatum_t::middle_center);s_display.setTextSize(1);s_display.setTextColor(TFT_WHITE,TFT_BLACK);
    s_display.drawString(std::strcmp(s_pi.agent,"codex")==0?"CODEX":"PI AGENT",234,104);
    s_display.drawString(s_pi.name,234,136);
    std::string body, footer="B CLOSE";
    if (s_input_modal==InputModal::ERROR) body=s_modal_error;
    else if (s_input_modal==InputModal::SETTINGS) {
        body=s_settings_phase=="MENU"?s_speech_menu.label():s_settings_phase;
        if (!s_models.empty()) {
            const auto &m=s_models[s_model_index];
            body+="\n\n"+(s_settings_phase=="EFFORT"?m.efforts[s_effort_index]:m.id);
            if (s_models.size()==1 && s_settings_phase=="MODEL") body+="\nNO ALTERNATIVES";
        }
        footer="A NEXT   B SELECT";
        if (s_settings_phase=="EFFORT" || (!s_models.empty() && s_models[s_model_index].efforts.empty())) footer="A NEXT   B APPLY";
        if (s_settings_phase=="MODEL UPDATED" || s_settings_phase=="UPDATE FAILED" || s_settings_phase=="APPLYING" || s_settings_phase=="LOADING") footer="Hold B to close";
    } else if (s_voice_phase=="RECORDING") {
        char duration[32];snprintf(duration,sizeof(duration),"00:%02llu",static_cast<unsigned long long>((esp_timer_get_time()/1000-s_voice_started)/1000));
        body=std::string("LISTENING...\n\n")+duration;footer="Release A to finish";
    } else if (s_voice_phase=="AWAITING_CONFIRMATION") { body=s_transcript;footer="A SEND   B CANCEL"; }
    else {body=s_voice_phase;footer="B CLOSE";}
    const auto *previous_font=s_display.getFont();
    if (s_input_modal==InputModal::VOICE && s_voice_phase=="AWAITING_CONFIRMATION")
        s_display.setFont(&s_transcript_font);
    auto lines=wrap_activity_text(body.c_str(),304);
    // No touch required: long transcripts page automatically, preserving all text.
    size_t start=0;
    if (lines.size()>7) start=((esp_timer_get_time()/1000000/4)%((lines.size()+6)/7))*7;
    s_display.setTextDatum(textdatum_t::top_left);
    for (size_t i=start;i<std::min(start+7,lines.size());++i) s_display.drawString(lines[i].c_str(),82,184+(i-start)*24);
    s_display.setFont(previous_font);
    s_display.setTextDatum(textdatum_t::middle_center);s_display.setTextColor(TFT_CYAN,TFT_BLACK);
    s_display.drawString(footer.c_str(),234,386);
}

void draw_pi()
{
    draw_header(std::strcmp(s_pi.agent,"codex")==0?"CODEX":"PI AGENT");
    if (!s_pi.selected) {
        s_display.setTextDatum(textdatum_t::middle_center);
        s_display.setTextColor(0x7BEF, TFT_BLACK);
        s_display.setTextSize(2);
        s_display.drawString("NO SESSION", s_display.width() / 2, 215);
        s_display.setTextSize(1);
        s_display.drawString("Select one in the admin page", s_display.width() / 2, 272);
        return;
    }

    uint16_t state_color = TFT_WHITE;
    if (std::strcmp(s_pi.state, "RUNNING") == 0) {
        state_color = TFT_CYAN;
    } else if (std::strcmp(s_pi.state, "DONE") == 0) {
        state_color = TFT_GREEN;
    } else if (std::strcmp(s_pi.state, "FAILED") == 0) {
        state_color = TFT_RED;
    } else if (std::strcmp(s_pi.state, "OFFLINE") == 0) {
        state_color = 0x7BEF;
    }
    uint64_t elapsed = s_pi.elapsed_seconds;
    if (std::strcmp(s_pi.state, "RUNNING") == 0) {
        elapsed += (esp_timer_get_time() / 1000 - s_pi.received_at_ms) / 1000;
    }
    char duration[24];
    if (elapsed >= 3600) {
        std::snprintf(duration, sizeof(duration), "%02llu:%02llu:%02llu",
                      static_cast<unsigned long long>(elapsed / 3600),
                      static_cast<unsigned long long>((elapsed / 60) % 60),
                      static_cast<unsigned long long>(elapsed % 60));
    } else {
        std::snprintf(duration, sizeof(duration), "%02llu:%02llu",
                      static_cast<unsigned long long>(elapsed / 60),
                      static_cast<unsigned long long>(elapsed % 60));
    }

    s_display.setTextDatum(textdatum_t::middle_left);
    s_display.setTextColor(state_color, TFT_BLACK);
    s_display.setTextSize(2);
    s_display.drawString(s_pi.state, 82, 112);
    s_display.setTextDatum(textdatum_t::middle_right);
    s_display.setTextColor(0xBDF7, TFT_BLACK);
    s_display.drawString(duration, 386, 112);

    s_display.setTextDatum(textdatum_t::top_center);
    s_display.setTextColor(TFT_WHITE, TFT_BLACK);
    s_display.setTextSize(1);
    s_display.drawString(s_pi.name, s_display.width() / 2, 142);
    s_display.setTextColor(0x7BEF, TFT_BLACK);
    s_display.drawString((std::string(s_pi.model)+" "+s_pi.effort).c_str(),234,158);
    s_display.drawFastHLine(82, 180, 304, 0x3186);

    const auto view=agent_display_text(s_pi.state,s_pi.has_final_text?s_pi.final_text:nullptr,
        s_pi.activity_type,s_pi.latest_activity,s_pi_tail);
    const bool tool_activity = view.tool;
    const char *activity = view.text;
    int activity_y = 184;
    if (tool_activity) {
        s_display.setTextDatum(textdatum_t::top_left);
        s_display.setTextColor(TFT_ORANGE, TFT_BLACK);
        s_display.drawString("TOOL", 82, activity_y);
        activity_y += 30;
    }
    s_display.setTextDatum(textdatum_t::top_left);
    s_display.setTextColor(TFT_WHITE, TFT_BLACK);
    const auto *previous_font=s_display.getFont();
    s_display.setFont(&s_transcript_font);
    const std::vector<std::string> lines = wrap_activity_text(activity, 304, !view.terminal);
    const size_t maximum_lines = tool_activity ? 6 : 8;
    const size_t first_line = !view.terminal && lines.size() > maximum_lines ? lines.size() - maximum_lines : 0;
    for (size_t index = first_line; index < std::min(first_line+maximum_lines,lines.size()); ++index) {
        s_display.drawString(lines[index].c_str(), 82, activity_y);
        activity_y += 24;
    }
    if (lines.empty()) {
        s_display.setTextColor(0x7BEF, TFT_BLACK);
        s_display.drawString("Waiting for agent activity...", 82, activity_y);
    }
    s_display.setFont(previous_font);
}

void draw_approval()
{
    draw_header("APPROVAL");
    s_display.setTextDatum(textdatum_t::middle_center);
    s_display.setTextSize(2);
    s_display.setTextColor(TFT_ORANGE, TFT_BLACK);
    if (s_approval.result_until) {
        s_display.drawString(s_approval.result.c_str(), 234, 225);
        return;
    }
    const auto *view = s_approval.current();
    if (!view) return;
    std::string agent = view->agent;
    for (char &c : agent) c = std::toupper(static_cast<unsigned char>(c));
    s_display.drawString(agent.c_str(), 234, 104);
    s_display.setTextSize(1);
    s_display.setTextColor(TFT_WHITE, TFT_BLACK);
    auto titles = wrap_activity_text(view->title.c_str(), 304);
    for (size_t i = 0; i < std::min<size_t>(titles.size(), 2); ++i)
        s_display.drawString(titles[i].c_str(), 234, 146 + i * 24);
    auto lines = wrap_activity_text(view->detail.c_str(), 304);
    if (lines.size() > 6) { lines.resize(6); lines.back() = "..."; }
    s_display.setTextDatum(textdatum_t::top_left);
    for (size_t i = 0; i < lines.size(); ++i)
        s_display.drawString(lines[i].c_str(), 82, 198 + i * 24);
    s_display.setTextDatum(textdatum_t::middle_center);
    s_display.setTextColor(TFT_GREEN, TFT_BLACK);
    s_display.drawString(s_approval.submitted ? "SENDING..." : "A  APPROVE", 234, 374);
    if (!s_approval.submitted) {
        s_display.setTextColor(TFT_RED, TFT_BLACK);
        s_display.drawString("B  REJECT", 234, 404);
    }
}

void draw_status(const char *reason)
{
    if (!s_display_ready) {
        return;
    }
    lock_display();
    ESP_LOGI(TAG,"[DRAW] reason=%s page=%s modal=%s speech=%d",reason,
             page_name(s_page),modal_name(s_input_modal),!s_speech.id.empty());
    char state[320];
    snprintf(state,sizeof(state),"page=%s modal=%s settings_phase=%s menu=%s index=%u mode=%u voice_phase=%s ui_epoch=%llu approval=%d",
             page_name(s_page),modal_name(s_input_modal),s_settings_phase.c_str(),
             settings_page_name(s_speech_menu.page),s_speech_menu.index,s_speech_menu.mode,
             s_voice_phase.rfind("VOICE ERROR",0)==0?"VOICE_ERROR":s_voice_phase.c_str(),
             static_cast<unsigned long long>(s_ui_epoch),s_approval.modal());
    static std::string last_state;
    const bool changed=last_state!=state;
    if (changed) { last_state=state; ESP_LOGI(TAG,"[UI] draw begin %s",state); }
    struct DrawDiagnostic {
        bool changed; int64_t started;
        ~DrawDiagnostic() {
            // The outer transaction keeps every primitive in the framebuffer.
            // endWrite submits the completed frame once, before releasing its lock.
            s_display.endWrite();
            if (changed) ESP_LOGI(TAG,"[UI] draw complete elapsed_ms=%lld",static_cast<long long>((esp_timer_get_time()-started)/1000));
            unlock_display();
        }
    } diagnostic{changed,esp_timer_get_time()};
    s_display.startWrite();
    s_display.fillScreen(TFT_BLACK);
    if (s_approval.modal()) {
        draw_approval();
        return;
    }
    if (s_input_modal != InputModal::NONE) { draw_input_modal(); return; }
    if (!s_speech.id.empty()) {
        draw_header("SPEAKING");s_display.setTextSize(1);s_display.setTextColor(TFT_WHITE,TFT_BLACK);
        s_display.setTextDatum(textdatum_t::top_center);s_display.drawString(s_pi.selected?s_pi.agent:"SCREEN",234,125);
        const auto lines=wrap_activity_text(s_speech_text.c_str(),304);
        s_display.setTextDatum(textdatum_t::top_left);
        for (size_t i=0;i<std::min<size_t>(7,lines.size());++i) s_display.drawString(lines[i].c_str(),82,184+i*24);
        return;
    }
    switch (s_page) {
    case Page::OVERVIEW:
        draw_overview();
        break;
    case Page::CPU:
        draw_cpu();
        break;
    case Page::MEMORY:
        draw_memory();
        break;
    case Page::DISK:
        draw_disk();
        break;
    case Page::NETWORK:
        draw_network();
        break;
    case Page::PI:
        draw_pi();
        s_pi_redraw_pending=false;
        s_last_pi_redraw_ms=esp_timer_get_time()/1000;
        break;
    default:
        break;
    }
    draw_footer();
}

void vibrate_task(void *arg)
{
    const uint32_t duration = reinterpret_cast<uintptr_t>(arg);
    const m5ioe1_err_t start = s_ioe.setPwmDuty(
        MOTOR_PWM_CHANNEL, GENERIC_VIBRATION_DUTY, false, true);
    vTaskDelay(pdMS_TO_TICKS(duration));
    const m5ioe1_err_t stop = s_ioe.setPwmDuty(MOTOR_PWM_CHANNEL, 0, false, true);
    if (start != M5IOE1_OK || stop != M5IOE1_OK) {
        ESP_LOGW(TAG, "vibration runtime failure: start=%d stop=%d", static_cast<int>(start),
                 static_cast<int>(stop));
    }
    ESP_LOGI(TAG, "[NOTIFY] vibration complete stack_free_min_bytes=%u",
             static_cast<unsigned>(uxTaskGetStackHighWaterMark(nullptr)));
    vTaskDelete(nullptr);
}

void pi_notify_task(void *arg)
{
    const auto kind = reinterpret_cast<uintptr_t>(arg);
    const bool failed = kind == 1;
    const VibrationStep *pattern = kind == 2 ? APPROVAL_VIBRATION_PATTERN.data() : failed
        ? FAILED_VIBRATION_PATTERN.data()
        : DONE_VIBRATION_PATTERN.data();
    const size_t pattern_size = kind == 2 ? APPROVAL_VIBRATION_PATTERN.size() : failed
        ? FAILED_VIBRATION_PATTERN.size()
        : DONE_VIBRATION_PATTERN.size();
    for (size_t pulse = 0; pulse < pattern_size; ++pulse) {
        const m5ioe1_err_t start = s_ioe.setPwmDuty(
            MOTOR_PWM_CHANNEL, NOTIFY_VIBRATION_DUTY, false, true);
        vTaskDelay(pdMS_TO_TICKS(pattern[pulse].on_ms));
        const m5ioe1_err_t stop = s_ioe.setPwmDuty(
            MOTOR_PWM_CHANNEL, 0, false, true);
        if (start != M5IOE1_OK || stop != M5IOE1_OK) {
            ESP_LOGW(TAG, "notification vibration failure: start=%d stop=%d",
                     static_cast<int>(start), static_cast<int>(stop));
        }
        if (pattern[pulse].off_ms > 0) {
            vTaskDelay(pdMS_TO_TICKS(pattern[pulse].off_ms));
        }
    }
    ESP_LOGI(TAG, "[NOTIFY] pattern complete kind=%u stack_free_min_bytes=%u",
             static_cast<unsigned>(kind),
             static_cast<unsigned>(uxTaskGetStackHighWaterMark(nullptr)));
    vTaskDelete(nullptr);
}

}  // namespace

esp_err_t board_init(const char *firmware_version)
{
    strlcpy(s_version, firmware_version, sizeof(s_version));
    s_display_lock = xSemaphoreCreateRecursiveMutex();
    if (s_display_lock == nullptr) {
        set_last_error("display mutex", ESP_ERR_NO_MEM);
        return ESP_ERR_NO_MEM;
    }

#if defined(CONFIG_ESP32S3_SPIRAM_SUPPORT) && defined(CONFIG_SPIRAM_MODE_OCT)
    constexpr bool psram_build_config = true;
#else
    constexpr bool psram_build_config = false;
#endif
    const bool psram_runtime = esp_psram_is_initialized();
    const size_t psram_size = psram_runtime ? esp_psram_get_size() : 0;
    ESP_LOGI(TAG, "[DIAG] PSRAM compile_opi=%s runtime=%s size=%u bytes",
             psram_build_config ? "yes" : "no", psram_runtime ? "OK" : "FAIL",
             static_cast<unsigned>(psram_size));
    if (!psram_build_config || !psram_runtime || psram_size == 0) {
        set_last_error("OPI PSRAM", ESP_ERR_NOT_SUPPORTED);
        ESP_LOGE(TAG, "[DIAG] OPI PSRAM is required for the StopWatch framebuffer");
        return ESP_ERR_NOT_SUPPORTED;
    }
    s_pi_tail = static_cast<char *>(heap_caps_calloc(
        PI_LOCAL_TAIL_BYTES + 1, sizeof(char), MALLOC_CAP_SPIRAM | MALLOC_CAP_8BIT));
    if (s_pi_tail == nullptr) {
        set_last_error("Pi activity PSRAM", ESP_ERR_NO_MEM);
        return ESP_ERR_NO_MEM;
    }
    ESP_LOGI(TAG, "[DIAG] Pi activity tail: OK PSRAM=%u bytes",
             static_cast<unsigned>(PI_LOCAL_TAIL_BYTES));

    esp_err_t result = initialize_i2c();
    if (result != ESP_OK) {
        return result;
    }
    result = initialize_pmic();
    if (result != ESP_OK) {
        return result;
    }
    result = initialize_ioe();
    if (result != ESP_OK) {
        return result;
    }
    vTaskDelay(pdMS_TO_TICKS(50));
    result = initialize_display();
    if (result != ESP_OK) {
        return result;
    }
    diagnose_touch_and_optional_i2c();
    initialize_buttons();
    initialize_audio_nonfatal();

    strlcpy(s_last_error, "none", sizeof(s_last_error));
    draw_status();
    return ESP_OK;
}

const char *board_slug(void) { return "m5stack-stopwatch"; }
const char *board_display_name(void) { return "M5Stack StopWatch"; }
const char *board_last_error(void) { return s_last_error; }

void board_set_device_id(const char *device_id)
{
    strlcpy(s_device_id, device_id, sizeof(s_device_id));
    draw_status();
}

void board_set_wifi_status(bool connected, const char *detail)
{
    s_wifi_connected = connected;
    strlcpy(s_wifi_detail, detail == nullptr ? (connected ? "connected" : "disconnected") : detail,
            sizeof(s_wifi_detail));
    draw_status();
}

void board_set_cloud_status(bool connected)
{
    struct Guard { Guard() { lock_display(); } ~Guard() { unlock_display(); } } guard;
    s_cloud_connected = connected;
    if (!connected) {
        stop_speech(false);
        s_message_visible = false;
        cancel_input(false,"cloud-disconnect");
        s_control_queue.clear();
        s_approval.disconnect();
        s_approval_action = {};
        s_notified_approval.clear();
    }
    draw_status();
}

void board_set_server_snapshot(const board_server_snapshot_t *snapshot)
{
    struct Guard { Guard(){lock_display();} ~Guard(){unlock_display();} } guard;
    if (snapshot == nullptr) {
        return;
    }
    strlcpy(s_server.hostname,
            snapshot->hostname == nullptr || snapshot->hostname[0] == '\0'
                ? "LIGHTHOUSE"
                : snapshot->hostname,
            sizeof(s_server.hostname));
    for (char *cursor = s_server.hostname; *cursor != '\0'; ++cursor) {
        *cursor = static_cast<char>(std::toupper(static_cast<unsigned char>(*cursor)));
    }
    s_server.cpu_percent = snapshot->cpu_percent;
    s_server.load_1m = snapshot->load_1m;
    s_server.load_5m = snapshot->load_5m;
    s_server.memory_used = snapshot->memory_used;
    s_server.memory_total = snapshot->memory_total;
    s_server.memory_percent = snapshot->memory_percent;
    s_server.swap_percent = snapshot->swap_percent;
    s_server.disk_used = snapshot->disk_used;
    s_server.disk_total = snapshot->disk_total;
    s_server.disk_percent = snapshot->disk_percent;
    s_server.network_rx_bytes = snapshot->network_rx_bytes;
    s_server.network_tx_bytes = snapshot->network_tx_bytes;
    s_server.network_rx_bps = snapshot->network_rx_bps;
    s_server.network_tx_bps = snapshot->network_tx_bps;
    s_server.uptime = snapshot->uptime;
    s_server.valid = true;
    if (server_snapshot_needs_redraw(s_page,page_overlay_visible())) {
        draw_status("server_snapshot");
    }
}

void board_select_server_target(void)
{
    struct Guard { Guard() { lock_display(); } ~Guard() { unlock_display(); } } guard;
    if (s_pi.selected) stop_speech();
    cancel_input(true,"server-target");
    s_pi.selected = false;
    s_message_visible = false;
    s_page = Page::OVERVIEW;
    draw_status();
}

void board_set_pi_snapshot(const board_pi_snapshot_t *snapshot)
{
    struct Guard { Guard() { lock_display(); } ~Guard() { unlock_display(); } } guard;
    if (snapshot == nullptr) {
        return;
    }
    const bool changed_target = agent_target_changed(s_pi.selected,s_pi.agent,s_pi.session_id,snapshot->agent,snapshot->session_id);
    const bool changed_view=pi_snapshot_changes_view(*snapshot);
    ESP_LOGI(TAG,"[AGENT] snapshot agent=%s session=%s changed_target=%s changed_view=%d state=%s page=%s modal=%s final_bytes=%u",
             snapshot->agent?snapshot->agent:"pi",snapshot->session_id?snapshot->session_id:"unknown",
             changed_target?"true":"false",changed_view,snapshot->state?snapshot->state:"OFFLINE",
             page_name(s_page),modal_name(s_input_modal),
             static_cast<unsigned>(snapshot->final_text?std::strlen(snapshot->final_text):0));
    if (changed_target) {
        ESP_LOGI(TAG,"[AGENT] target old_agent=%s new_agent=%s old_session=%s new_session=%s selected=%d",
                 s_pi.agent,snapshot->agent?snapshot->agent:"pi",s_pi.session_id,
                 snapshot->session_id?snapshot->session_id:"unknown",s_pi.selected);
        stop_speech();cancel_input(true,"target-change");
    }
    strlcpy(s_pi.agent,snapshot->agent?snapshot->agent:"pi",sizeof(s_pi.agent));
    strlcpy(s_pi.model,snapshot->model?snapshot->model:"",sizeof(s_pi.model));
    strlcpy(s_pi.effort,snapshot->effort?snapshot->effort:"",sizeof(s_pi.effort));
    strlcpy(s_pi.session_id, snapshot->session_id == nullptr ? "unknown" : snapshot->session_id,
            sizeof(s_pi.session_id));
    strlcpy(s_pi.name, snapshot->name == nullptr ? s_pi.session_id : snapshot->name,
            sizeof(s_pi.name));
    strlcpy(s_pi.cwd, snapshot->cwd == nullptr ? "" : snapshot->cwd, sizeof(s_pi.cwd));
    strlcpy(s_pi.state, snapshot->state == nullptr ? "OFFLINE" : snapshot->state,
            sizeof(s_pi.state));
    strlcpy(s_pi.activity_type,
            snapshot->activity_type == nullptr ? "" : snapshot->activity_type,
            sizeof(s_pi.activity_type));
    strlcpy(s_pi.latest_activity,
            snapshot->latest_activity == nullptr ? "" : snapshot->latest_activity,
            sizeof(s_pi.latest_activity));
    set_pi_tail(snapshot->recent_text);
    s_pi.has_final_text=snapshot->final_text!=nullptr;
    copy_agent_final(s_pi.final_text,snapshot->final_text);
    s_pi.elapsed_seconds = snapshot->elapsed_seconds;
    s_pi.received_at_ms = esp_timer_get_time() / 1000;
    s_pi.selected = true;
    if (changed_target) {
        s_message_visible = false;
        ESP_LOGI(TAG,"[UI] page %s -> PI reason=target-change",page_name(s_page));
        s_page = Page::PI;
        draw_status("agent_target_change");
    } else {
        // Keep activity/timer work already pending. board_update coalesces all
        // visible Agent changes; hidden backing state is still updated above.
        s_pi_redraw_pending=s_pi_redraw_pending || changed_view;
    }
}

void board_append_pi_activity(const char *activity_type, const char *activity)
{
    if (!s_pi.selected || activity == nullptr || activity[0] == '\0') {
        return;
    }
    strlcpy(s_pi.activity_type, activity_type == nullptr ? "text" : activity_type,
            sizeof(s_pi.activity_type));
    strlcpy(s_pi.latest_activity, activity, sizeof(s_pi.latest_activity));
    append_pi_tail(s_pi.activity_type, activity);
    s_pi_redraw_pending = true;
}

void board_notify_pi(bool failed)
{
    if (s_vibrator_ready) {
        if (xTaskCreate(pi_notify_task, "pi_notify", VIBRATION_TASK_STACK_BYTES,
                        reinterpret_cast<void *>(failed ? 1U : 0U), 3, nullptr) != pdPASS) {
            ESP_LOGW(TAG, "notification vibration task allocation failed");
        }
    }
    if (s_audio_ready &&
        !audio_backend_notify(failed ? audio_notification_t::FAILED : audio_notification_t::DONE)) {
        ESP_LOGW(TAG, "[AUDIO] notification queue full");
    }
}

void board_next_page(void)
{
    struct Guard { Guard() { lock_display(); } ~Guard() { unlock_display(); } } guard;
    if (s_approval.modal()) return;
    s_message_visible = false;
    const auto next = (static_cast<uint8_t>(s_page) + 1) % static_cast<uint8_t>(Page::COUNT);
    ESP_LOGI(TAG,"[UI] page %s -> %s reason=A-click",page_name(s_page),page_name(static_cast<Page>(next)));
    s_page = static_cast<Page>(next);
    draw_status();
}

void board_previous_page(void)
{
    struct Guard { Guard() { lock_display(); } ~Guard() { unlock_display(); } } guard;
    if (s_approval.modal()) return;
    s_message_visible = false;
    const auto count = static_cast<uint8_t>(Page::COUNT);
    const auto previous = (static_cast<uint8_t>(s_page) + count - 1) % count;
    ESP_LOGI(TAG,"[UI] page %s -> %s reason=B-click",page_name(s_page),page_name(static_cast<Page>(previous)));
    s_page = static_cast<Page>(previous);
    draw_status();
}

void board_show_message(const char *title, const char *body)
{
    struct Guard { Guard() { lock_display(); } ~Guard() { unlock_display(); } } guard;
    if (s_approval.modal()) return;
    if (!s_display_ready) {
        return;
    }
    lock_display();
    s_message_visible = true;
    s_message_until_ms = esp_timer_get_time() / 1000 + 5000;
    s_display.startWrite();
    s_display.fillScreen(TFT_BLACK);
    s_display.setTextDatum(textdatum_t::top_center);
    s_display.setTextColor(0xBDF7, TFT_BLACK);
    s_display.setTextSize(2);
    s_display.drawString(title, s_display.width() / 2, 110);
    s_display.setTextColor(TFT_WHITE, TFT_BLACK);
    s_display.setTextSize(1);
    s_display.setTextWrap(true, true);
    s_display.drawCenterString(body, s_display.width() / 2, 190);
    s_display.setTextColor(0x7BEF, TFT_BLACK);
    s_display.drawString("Press a button to send an event", s_display.width() / 2, 340);
    s_display.endWrite();
    unlock_display();
}

void board_vibrate(uint32_t duration_ms)
{
    if (!s_vibrator_ready) {
        return;
    }
    duration_ms = std::min<uint32_t>(duration_ms, 500);
    xTaskCreate(vibrate_task, "vibrate", VIBRATION_TASK_STACK_BYTES, reinterpret_cast<void *>(duration_ms), 3, nullptr);
}

void board_update(void)
{
    struct Guard { Guard() { lock_display(); } ~Guard() { unlock_display(); } } guard;
    const uint64_t now_ms = esp_timer_get_time() / 1000;
    if (!s_speech.id.empty() && !audio_backend_speech_active(s_speech_generation)) {
        stop_speech(!s_speech.ended);draw_status();
    }
    if (s_approval.tick(now_ms)) draw_status();
    const auto *current = s_approval.current();
    if (current && current->id != s_notified_approval) {
        s_notified_approval = current->id;
        if (s_vibrator_ready) xTaskCreate(pi_notify_task, "approval_notify", VIBRATION_TASK_STACK_BYTES, reinterpret_cast<void *>(2U), 3, nullptr);
        if (s_audio_ready) audio_backend_notify(audio_notification_t::APPROVAL);
    }
    const bool normal=s_input_modal==InputModal::NONE && !s_approval.modal();
    const bool a_down=s_button_a.down();
    auto a=s_button_a.update(gpio_get_level(BUTTON_A_PIN)==0,now_ms,s_ui_epoch+s_approval.generation,normal);
    log_stable_edge(true,a_down,s_button_a);
    handle_gesture(true,a,s_button_a.epoch,now_ms);
    const bool b_down=s_button_b.down();
    auto b=s_button_b.update(gpio_get_level(BUTTON_B_PIN)==0,now_ms,s_ui_epoch+s_approval.generation,
        !s_approval.modal() && (normal || s_input_modal==InputModal::SETTINGS));
    log_stable_edge(false,b_down,s_button_b);
    handle_gesture(false,b,s_button_b.epoch,now_ms);
    if (s_feedback_until && now_ms>=s_feedback_until) { cancel_input(false,"feedback-timeout");s_page=Page::PI;draw_status(); }
    if (s_input_modal!=InputModal::NONE && now_ms-s_modal_redraw>=1000) {s_modal_redraw=now_ms;draw_status("modal_tick");}
    if (s_message_visible && now_ms >= s_message_until_ms) {
        s_message_visible = false;
        draw_status();
    }
    if (s_pi.selected && std::strcmp(s_pi.state, "RUNNING") == 0) {
        const uint64_t second = now_ms / 1000;
        if (second != s_last_pi_elapsed_second) {
            s_last_pi_elapsed_second = second;
            s_pi_redraw_pending = true;
        }
    }
    if (s_pi.selected && agent_snapshot_needs_redraw(s_page,page_overlay_visible(),s_pi_redraw_pending) &&
        now_ms - s_last_pi_redraw_ms >= PI_REDRAW_MIN_INTERVAL_MS) {
        s_pi_redraw_pending = false;
        s_last_pi_redraw_ms = now_ms;
        draw_status("agent_activity_or_timer");
    }
}

board_button_t board_take_button_click(void)
{
    struct Guard { Guard() { lock_display(); } ~Guard() { unlock_display(); } } guard;
    auto result=s_page_click;s_page_click=BOARD_BUTTON_NONE;return result;
}

void board_approval_request(const char *id, const char *agent, const char *title, const char *detail, unsigned /*expires_in*/)
{
    lock_display();
    const uint64_t now = esp_timer_get_time()/1000;
    if (s_approval.receive({id, agent, title, detail, 0}, now)) {
        stop_speech();
        cancel_input(true,"approval");
        s_page_click=BOARD_BUTTON_NONE;
        s_message_visible = false;
        draw_status();
    }
    unlock_display();
}

void board_approval_cancelled(const char *id, const char *state)
{
    lock_display();
    if (s_approval.finish(id, state, esp_timer_get_time()/1000)) draw_status();
    if (s_approval_action.id == id) s_approval_action = {};
    unlock_display();
}

bool board_take_approval_response(char *id, size_t size, bool *approve)
{
    lock_display();
    const bool ready = !s_approval_action.id.empty();
    if (ready) {
        strlcpy(id, s_approval_action.id.c_str(), size);
        *approve = s_approval_action.approve;
        s_approval_action = {};
    }
    unlock_display();
    return ready;
}

bool board_take_control_message(char *out, size_t capacity) {
    struct Guard { Guard(){lock_display();} ~Guard(){unlock_display();} } guard;
    if (s_control_queue.empty()) return false;
    strlcpy(out,s_control_queue.front().c_str(),capacity);s_control_queue.pop_front();return true;
}
void board_capture_finished(uint64_t id, bool cancelled) {
    struct Guard { Guard(){lock_display();} ~Guard(){unlock_display();} } guard;
    ESP_LOGI(TAG,"[VOICE] capture-finished id=%016llx cancelled=%d modal=%s active_match=%d awaiting_confirmation=%d",
             static_cast<unsigned long long>(id),cancelled,modal_name(s_input_modal),
             id==s_voice_serial,s_voice_phase=="AWAITING_CONFIRMATION");
    if (id!=s_voice_serial || s_input_modal!=InputModal::VOICE || s_voice_phase=="AWAITING_CONFIRMATION") return;
    s_voice_phase=cancelled?"Capture cancelled":"TRANSCRIBING";draw_status();
}
#ifdef CONFIG_DEVICE_LAB_SPEECH_TRACE
namespace {
struct SpeechReceiveMetrics {
    std::string id;
    SpeechRxFrame last;
    int last_seq = -1, first_missing = -1;
    unsigned count = 0, accepted = 0;
    int64_t previous_us = 0, max_gap_us = 0, max_frame_us = 0, last_delta_us = 0;
    unsigned before = 0, after = 0;
    int64_t dispatch_us = 0;
    uint64_t played_bytes = 0;
    bool push = false, end_seen = false;
} s_receive_metrics;

static void speech_rx_log(const std::string &id, int seq, const SpeechRxFrame &rx,
                          int64_t delta, unsigned before, unsigned after, bool push, const char *event,
                          int64_t dispatch_us, uint64_t played_bytes) {
    ESP_LOGI(TAG,"[SPEECH_RX] event=%s id=%s seq=%d frame=%lu parts=%lu wire_bytes=%u frame_elapsed_ms=%lld complete_ms=%lld delta_from_previous_complete_ms=%lld queue_depth_before=%u queue_depth_after=%u push=%d played_bytes=%llu dispatch_ms=%lld",
             event,id.c_str(),seq,static_cast<unsigned long>(rx.identity),static_cast<unsigned long>(rx.parts),
             static_cast<unsigned>(rx.wire_bytes),static_cast<long long>((rx.complete_us-rx.first_us)/1000),
             static_cast<long long>(rx.complete_us/1000),static_cast<long long>(delta/1000),before,after,push,
             static_cast<unsigned long long>(played_bytes),static_cast<long long>(dispatch_us/1000));
    if (rx.complete_us-rx.first_us>250000)
        ESP_LOGI(TAG,"[SPEECH_RX_SLOW] id=%s seq=%d frame=%lu wire_bytes=%u parts=%lu first_ms=%lld complete_ms=%lld elapsed_ms=%lld largest_inter_part_gap_ms=%lld",
                 id.c_str(),seq,static_cast<unsigned long>(rx.identity),static_cast<unsigned>(rx.wire_bytes),
                 static_cast<unsigned long>(rx.parts),static_cast<long long>(rx.first_us/1000),
                 static_cast<long long>(rx.complete_us/1000),static_cast<long long>((rx.complete_us-rx.first_us)/1000),
                 static_cast<long long>(rx.max_part_gap_us/1000));
}

static void speech_rx_chunk(const std::string &id, int seq, const SpeechRxFrame *rx,
                            unsigned before, unsigned after, bool push) {
    if (!rx) return;
    auto &m=s_receive_metrics;
    const bool match=id==m.id;
    const int64_t dispatch_us=esp_timer_get_time()-rx->complete_us;
    const uint64_t played_bytes=audio_backend_speech_bytes_written();
    const int64_t delta=match && m.previous_us ? rx->complete_us-m.previous_us : 0;
    if (match) {
        if (seq!=m.last_seq+1 && m.first_missing<0) m.first_missing=m.last_seq+1;
        ++m.count; m.accepted+=push; m.max_gap_us=std::max(m.max_gap_us,delta);
        m.max_frame_us=std::max(m.max_frame_us,rx->complete_us-rx->first_us);
        m.last=*rx; m.last_seq=seq; m.previous_us=rx->complete_us; m.last_delta_us=delta;
        m.before=before; m.after=after; m.push=push; m.dispatch_us=dispatch_us; m.played_bytes=played_bytes;
    }
    if (seq==0 || seq%8==0 || rx->complete_us-rx->first_us>250000 || delta>250000 ||
        before>=AUDIO_SPEECH_QUEUE_FRAMES-2 || after>=AUDIO_SPEECH_QUEUE_FRAMES-2 || !push)
        speech_rx_log(id,seq,*rx,delta,before,after,push,"chunk",dispatch_us,played_bytes);
}

static void speech_rx_summary(const char *reason) {
    auto &m=s_receive_metrics;
    if (m.id.empty()) return;
    // Repeat the final chunk explicitly; parser deduplicates it by seq.
    if (m.count) speech_rx_log(m.id,m.last_seq,m.last,m.last_delta_us,m.before,m.after,m.push,"last",m.dispatch_us,m.played_bytes);
    ESP_LOGI(TAG,"[SPEECH_RX_SUMMARY] id=%s generation=%u reason=%s received=%u accepted=%u last_seq=%d first_missing=%d max_gap_ms=%lld max_frame_ms=%lld end_seen=%d",
             m.id.c_str(),s_speech_generation,reason,m.count,m.accepted,m.last_seq,m.first_missing,
             static_cast<long long>(m.max_gap_us/1000),static_cast<long long>(m.max_frame_us/1000),m.end_seen);
}
} // namespace
#endif

// Called with the display lock; chunks only validate/decode/enqueue, never touch the codec.
static bool speech_message(cJSON *root, const std::string &type, const SpeechRxFrame *rx) {
#ifndef CONFIG_DEVICE_LAB_SPEECH_TRACE
    (void)rx;
#endif
    const auto id=js(root,"speech_id");
    auto number=[&](const char *key) {const auto *v=cJSON_GetObjectItemCaseSensitive(root,key);return cJSON_IsNumber(v)?v->valuedouble:-1;};
    if (type=="speech_settings") {
        if (js(root,"target")==selected_target()) {
            s_speech_menu.load(js(root,"mode"));
            if (s_input_modal==InputModal::SETTINGS && js(root,"request_id")==s_speech_request && s_settings_phase=="SAVING SPEECH") s_settings_phase="MENU";
        }
    } else if (type=="speech_start") {
#ifdef CONFIG_DEVICE_LAB_SPEECH_TRACE
        s_receive_metrics={}; s_receive_metrics.id=id;
        if (rx) s_receive_metrics.previous_us=rx->complete_us;
        ESP_LOGI(TAG,"[SPEECH_RX_EVENT] event=start id=%s complete_ms=%lld",id.c_str(),
                 static_cast<long long>(rx ? rx->complete_us/1000 : -1));
#endif
        if (s_approval.modal() || (s_input_modal!=InputModal::NONE && !(s_input_modal==InputModal::VOICE && s_voice_phase=="SENT")) || js(root,"target")!=selected_target()) {stop_speech();return true;}
        if (s_input_modal==InputModal::VOICE) cancel_input(false,"speech-preemption");
        if (s_speech.start(id,number("sample_rate"),number("bits"),number("channels"))) {
            if (++s_speech_generation==0) ++s_speech_generation;
            if (!audio_backend_speech_start(s_speech_generation)) stop_speech();
            else {
                s_speech_text=js(root,"text").substr(0,1200);
                ESP_LOGI(TAG,"[SPEECH] start id=%s generation=%u source=%s rate=44100 bits=16 channels=1",id.c_str(),
                         s_speech_generation,js(root,"source").c_str());
            }
        }
    } else if (type=="speech_chunk") {
        if (s_speech.id.empty() || id!=s_speech.id) {
#ifdef CONFIG_DEVICE_LAB_SPEECH_TRACE
            const auto depth=audio_backend_speech_queue_depth();
            speech_rx_chunk(id,static_cast<int>(number("sequence")),rx,depth,depth,false);
#endif
            return false;
        }
        const auto audio=js(root,"audio");size_t size=0;
        static uint8_t decoded[AUDIO_SPEECH_BYTES];
        const bool valid=audio.size()<=9408 && mbedtls_base64_decode(decoded,sizeof(decoded),&size,
            reinterpret_cast<const unsigned char *>(audio.data()),audio.size())==0;
#ifdef CONFIG_DEVICE_LAB_SPEECH_TRACE
        const auto before=audio_backend_speech_queue_depth();
#endif
        const bool push=valid && s_speech.chunk(id,number("sequence"),size) && audio_backend_speech_push(s_speech_generation,decoded,size);
#ifdef CONFIG_DEVICE_LAB_SPEECH_TRACE
        speech_rx_chunk(id,static_cast<int>(number("sequence")),rx,before,audio_backend_speech_queue_depth(),push);
#endif
        if (!push) stop_speech();
        return false;
    } else if (type=="speech_end") {
#ifdef CONFIG_DEVICE_LAB_SPEECH_TRACE
        if (id==s_receive_metrics.id) s_receive_metrics.end_seen=true;
        ESP_LOGI(TAG,"[SPEECH_RX_EVENT] event=end id=%s complete_ms=%lld chunks=%.0f",id.c_str(),
                 static_cast<long long>(rx ? rx->complete_us/1000 : -1),number("chunks"));
        speech_rx_summary("end");
#endif
        if (s_speech.id.empty() || id!=s_speech.id) return false;
        const bool valid=s_speech.end(id,number("chunks"));
        const bool queued=valid && audio_backend_speech_push(s_speech_generation,nullptr,0,true);
        ESP_LOGI(TAG,"[SPEECH] end id=%s chunks=%.0f valid=%d queued=%d",id.c_str(),number("chunks"),valid,queued);
        if (!queued) stop_speech();
    } else if (type=="speech_cancel") {
        if (!id.empty() && id==s_speech.id) stop_speech(false);
    } else if (type=="speech_error") {
        stop_speech(false);
        if (!s_approval.modal() && s_input_modal==InputModal::NONE) {s_input_modal=InputModal::ERROR;s_modal_error="Speech unavailable";++s_ui_epoch;log_modal_transition(InputModal::NONE,"speech-error");}
    }
    return true;
}

void board_control_message(const char *json, const SpeechRxFrame *rx) {
    struct Guard { Guard(){lock_display();} ~Guard(){unlock_display();} } guard;
    auto *root=cJSON_Parse(json);if (!root) return;
    const auto type=js(root,"type");
    if (type.rfind("voice_",0)==0 || type.rfind("settings_",0)==0 || type=="speech_settings")
        ESP_LOGI(TAG,"[CONTROL] rx type=%s state=%s modal=%s voice_match=%d settings_match=%d",
                 type.c_str(),js(root,"state").c_str(),modal_name(s_input_modal),
                 js(root,"voice_id")==s_voice_id,js(root,"request_id")==s_settings_id);
    if (type.rfind("speech_",0)==0) {
        const bool redraw=speech_message(root,type,rx);cJSON_Delete(root);if (redraw) draw_status();return;
    }
    if (type.rfind("voice_",0)==0 && s_input_modal==InputModal::VOICE && js(root,"voice_id")==s_voice_id) {
        if (type=="voice_error") {audio_backend_record_stop(true);s_voice_phase="VOICE ERROR\n"+js(root,"message");s_transcript.clear();}
        else if (type=="voice_transcript") {
            s_voice_phase="AWAITING_CONFIRMATION";s_transcript=js(root,"transcript");++s_ui_epoch;
            ESP_LOGI(TAG,"[VOICE] transcript-ready bytes=%u non_ascii=%d",static_cast<unsigned>(s_transcript.size()),
                     std::any_of(s_transcript.begin(),s_transcript.end(),[](unsigned char c){return c>=0x80;}));
            unsigned fallback=0, missing=0;
            for (size_t offset=0;offset<s_transcript.size();) {
                const size_t size=utf8_character_size(s_transcript.data()+offset,s_transcript.size()-offset);
                const auto first=static_cast<unsigned char>(s_transcript[offset]);
                uint32_t code=size==1?first:first & ((1U << (7-size))-1U);
                for (size_t i=1;i<size;++i) code=(code<<6)|(static_cast<unsigned char>(s_transcript[offset+i])&0x3FU);
                lgfx::FontMetrics metrics;
                if (code>0xFFFF) ++missing;
                else if (code>=0x20 && !fonts::efontCN_16.updateFontMetric(&metrics,code)) {
                    if (fonts::efontTW_16.updateFontMetric(&metrics,code)) ++fallback;
                    else ++missing;
                }
                offset+=size;
            }
            ESP_LOGI(TAG,"[VOICE] glyph-coverage traditional_fallback=%u missing=%u",fallback,missing);
        }
        else if (js(root,"state")=="CANCELLED") cancel_input(false,"gateway-cancelled");
        else if (js(root,"state")=="SENT") {s_voice_phase="SENT";s_transcript.clear();s_feedback_until=esp_timer_get_time()/1000+1000;}
        else if (js(root,"state")=="TRANSCRIBING") {audio_backend_record_stop(false);s_voice_phase="TRANSCRIBING";}
    } else if (type.rfind("settings_",0)==0 && s_input_modal==InputModal::SETTINGS && js(root,"request_id")==s_settings_id) {
        if (type=="settings_error") {s_settings_phase="UPDATE FAILED";s_models.clear();s_modal_error=js(root,"message");}
        else if (js(root,"target")==selected_target()) {
            s_models.clear();s_model_index=0;s_effort_index=0;
            const auto current=js(root,"model"), effort=js(root,"effort");
            cJSON *item=nullptr;
            cJSON_ArrayForEach(item,cJSON_GetObjectItemCaseSensitive(root,"available_models")) {
                if (s_models.size()>=6) break;
                QuickModel m; m.id=js(item,"id");m.name=js(item,"name");m.default_effort=js(item,"default_effort");
                cJSON *e=nullptr;cJSON_ArrayForEach(e,cJSON_GetObjectItemCaseSensitive(item,"available_efforts"))
                    if (cJSON_IsString(e) && m.efforts.size()<32) m.efforts.emplace_back(e->valuestring);
                if (m.id==current) {s_model_index=s_models.size();if (!effort.empty()) m.default_effort=effort;}
                s_models.push_back(m);
            }
            // Only authoritative Gateway responses update the Agent page.
            strlcpy(s_pi.model,current.c_str(),sizeof(s_pi.model));strlcpy(s_pi.effort,effort.c_str(),sizeof(s_pi.effort));
            s_settings_phase=type=="settings_updated"?"MODEL UPDATED":"MODEL";
            if (type=="settings_updated") s_feedback_until=esp_timer_get_time()/1000+1000;
            ++s_ui_epoch;
        }
    }
    cJSON_Delete(root);draw_status();
}

bool board_voice_active(uint64_t id) {
    struct Guard { Guard(){lock_display();} ~Guard(){unlock_display();} } guard;
    return s_input_modal==InputModal::VOICE && s_voice_serial==id;
}

void board_agent_activity(const char *agent, const char *session_id, const char *type, const char *activity, const char *recent) {
    struct Guard { Guard(){lock_display();} ~Guard(){unlock_display();} } guard;
    const bool accepted=s_pi.selected && std::strcmp(s_pi.agent,agent)==0 && std::strcmp(s_pi.session_id,session_id)==0;
    ESP_LOGI(TAG,"[AGENT] activity agent=%s session=%s accepted=%d kind=%s bytes=%u page=%s modal=%s",
             agent,session_id,accepted,type,static_cast<unsigned>(std::strlen(activity)),page_name(s_page),modal_name(s_input_modal));
    if (!accepted) return;
    if (recent) {
        strlcpy(s_pi.activity_type,type,sizeof(s_pi.activity_type));
        strlcpy(s_pi.latest_activity,activity,sizeof(s_pi.latest_activity));
        set_pi_tail(recent);s_pi_redraw_pending=true;
    } else board_append_pi_activity(type,activity);
}
