#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 "ui_state.h"
#include "wearable_renderer.h"
#include "device_status.h"
#include "device_preferences_nvs.h"
#include "wearable_fonts.h"
#include "rx8130_rtc.h"
#include "esp_wifi.h"
#include <atomic>
#include <ctime>
#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;
const wearable::FontBook s_fonts;

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;
TaskHandle_t s_display_owner;
std::atomic<bool> s_draw_requested{false};
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;
Rx8130Rtc s_rtc;
uint64_t s_rtc_boot_utc_ms=0;
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::PI; // Compatibility backing metric; shell owns navigation.
wearable::UiState s_ui;
wearable::LoadedPreferences s_saved_preferences;
wearable::WallClock s_clock;
wearable::BatteryStatus s_battery;
std::atomic<bool> s_vibration_enabled{true};
int s_wifi_rssi=-127;
std::string s_preference_feedback;
uint64_t s_last_shell_tick=0;

void sync_display_page() {
    s_page=s_ui.app==wearable::App::Monitor?Page(s_ui.monitor_page):
        s_ui.app==wearable::App::Help?Page::COUNT:Page::PI;
}
bool s_message_visible;
uint64_t s_message_until_ms;
std::string s_message_title,s_message_body;
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;
}

void initialize_rtc_nonfatal() {
    auto result=s_rtc.begin(s_i2c_bus,RTC_ADDRESS);
    if(result==ESP_OK) result=s_rtc.read_utc_ms(s_rtc_boot_utc_ms);
    ESP_LOGI(TAG,"[TIME] RX8130 init=%s boot_utc_ms=%llu",
        esp_err_to_name(result),static_cast<unsigned long long>(s_rtc_boot_utc_ms));
    if(result!=ESP_OK) s_rtc_boot_utc_ms=0;
}

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 wearable::palette(s_ui.preferences.theme).failed;
    }
    if (value >= 75) {
        return wearable::palette(s_ui.preferences.theme).approval;
    }
    return wearable::palette(s_ui.preferences.theme).done;
}

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_fonts.apply(s_display,wearable::TypeRole::PageTitle,s_ui.preferences.text_size,
                  wearable::palette(s_ui.preferences.theme).primary,TFT_BLACK);
    s_display.setTextDatum(textdatum_t::top_center);
    s_display.drawString(title, s_display.width() / 2, 38);
}

void draw_footer()
{
    const auto colors=wearable::palette(s_ui.preferences.theme);
    char hint[48];snprintf(hint,sizeof(hint),"MONITOR %u/5",unsigned(s_ui.monitor_page)+1);
    s_fonts.apply(s_display,wearable::TypeRole::Footer,s_ui.preferences.text_size,colors.accent,TFT_BLACK);
    s_display.setTextDatum(textdatum_t::top_center);s_display.setTextColor(colors.accent,TFT_BLACK);
    s_display.drawString(hint,234,374);
    s_display.setTextColor(colors.secondary,TFT_BLACK);
    s_display.drawString(s_cloud_connected?"AGENT ONLINE":"GATEWAY RECONNECTING",234,404);
}

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_fonts.apply(s_display,wearable::TypeRole::AgentBodyLarge,s_ui.preferences.text_size,
                  wearable::palette(s_ui.preferences.theme).primary,TFT_BLACK);
    s_display.setTextDatum(textdatum_t::middle_left);
    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_fonts.apply(s_display,wearable::TypeRole::StatusPrimary,s_ui.preferences.text_size,
                      wearable::palette(s_ui.preferences.theme).primary,TFT_BLACK);
        s_display.setTextDatum(textdatum_t::middle_center);
        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_fonts.apply(s_display,wearable::TypeRole::Meta,s_ui.preferences.text_size,
                  wearable::palette(s_ui.preferences.theme).secondary,TFT_BLACK);
    s_display.setTextDatum(textdatum_t::top_left);
    s_display.drawString("NET", 105, 298);
    s_display.setTextColor(wearable::palette(s_ui.preferences.theme).primary, 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_fonts.apply(s_display,wearable::TypeRole::AgentBody,s_ui.preferences.text_size,
                  wearable::palette(s_ui.preferences.theme).primary,TFT_BLACK);
    s_display.setTextDatum(textdatum_t::top_center);
    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(wearable::palette(s_ui.preferences.theme).secondary, 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_fonts.apply(s_display,wearable::TypeRole::AgentBody,s_ui.preferences.text_size,
                  wearable::palette(s_ui.preferences.theme).primary,TFT_BLACK);
    s_display.setTextDatum(textdatum_t::top_center);
    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_fonts.apply(s_display,wearable::TypeRole::AgentBody,s_ui.preferences.text_size,
                  wearable::palette(s_ui.preferences.theme).primary,TFT_BLACK);
    s_display.setTextDatum(textdatum_t::top_center);
    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(wearable::palette(s_ui.preferences.theme).failed, TFT_BLACK);
        s_fonts.apply(s_display,wearable::TypeRole::StatusPrimary,s_ui.preferences.text_size,
                      wearable::palette(s_ui.preferences.theme).failed,TFT_BLACK);
        s_display.setTextDatum(textdatum_t::top_center);
        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_fonts.apply(s_display,wearable::TypeRole::AgentBodyLarge,s_ui.preferences.text_size,
                  wearable::palette(s_ui.preferences.theme).done,TFT_BLACK);
    s_display.setTextDatum(textdatum_t::top_center);
    std::snprintf(text, sizeof(text), "RX  %s", rx_rate);
    s_display.drawString(text, s_display.width() / 2, 130);
    s_fonts.apply(s_display,wearable::TypeRole::Meta,s_ui.preferences.text_size,
                  wearable::palette(s_ui.preferences.theme).primary,TFT_BLACK);
    s_display.setTextDatum(textdatum_t::top_center);
    std::snprintf(text, sizeof(text), "Total %s", rx_total);
    s_display.drawString(text, s_display.width() / 2, 190);
    s_fonts.apply(s_display,wearable::TypeRole::AgentBodyLarge,s_ui.preferences.text_size,
                  wearable::palette(s_ui.preferences.theme).accent,TFT_BLACK);
    s_display.setTextDatum(textdatum_t::top_center);
    std::snprintf(text, sizeof(text), "TX  %s", tx_rate);
    s_display.drawString(text, s_display.width() / 2, 252);
    s_fonts.apply(s_display,wearable::TypeRole::Meta,s_ui.preferences.text_size,
                  wearable::palette(s_ui.preferences.theme).primary,TFT_BLACK);
    s_display.setTextDatum(textdatum_t::top_center);
    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";
}
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);
}
void stop_speech(bool notify=true) {
    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");
    if (s_input_modal==InputModal::SETTINGS && s_ui.menu==wearable::Menu::Edit &&
        s_ui.field==wearable::Field::Brightness)
        s_display.setPanelBrightness(wearable::panel_value(s_ui.preferences.brightness));
    s_input_modal=InputModal::NONE; s_ui.close_settings(); 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 before_menu=s_ui.menu;const auto before_field=s_ui.field;
            const auto effect=s_ui.settings_click(a);
            s_preference_feedback.clear();
            if(effect==wearable::UiEffect::CloseSettings) cancel_input(false,"settings-back");
            else if(effect==wearable::UiEffect::AgentSettings) {
                if(s_pi.selected && s_cloud_connected) {
                    s_settings_id=new_identity();s_settings_target=selected_target();
                    s_settings_phase="LOADING";settings_command("settings_get");
                }
            } else if(effect==wearable::UiEffect::SpeechSettings) {
                if(s_pi.selected && s_cloud_connected) {
                    s_read_screen=read_screen_text();s_speech_menu.open();s_speech_menu.click(true);s_speech_menu.click(false);
                    s_settings_phase="SPEECH";speech_command("speech_settings_get");
                }
            } else if(effect==wearable::UiEffect::Apply) {
                // board_update holds the display owner lock; no ISR/network setter.
                s_display.setPanelBrightness(wearable::panel_value(s_ui.preferences.brightness));
                s_vibration_enabled=s_ui.preferences.vibration_enabled;
                const auto result=preferences_save(s_saved_preferences,s_ui.preferences);
                s_preference_feedback=result==ESP_OK?"APPLIED / SAVED":"APPLIED / NOT SAVED";
                ESP_LOGI(TAG,"[PREFS] apply brightness=%u panel=%u vibration=%u home=%u text=%u theme=%u slot=%d generation=%lu saved=%d readonly=%d",
                    s_ui.preferences.brightness,wearable::panel_value(s_ui.preferences.brightness),
                    s_ui.preferences.vibration_enabled,unsigned(s_ui.preferences.home_screen),
                    unsigned(s_ui.preferences.text_size),unsigned(s_ui.preferences.theme),s_saved_preferences.slot,
                    (unsigned long)s_saved_preferences.generation,result==ESP_OK,s_saved_preferences.read_only);
            }
            if(a && before_menu==wearable::Menu::Edit && before_field==wearable::Field::Brightness)
                s_display.setPanelBrightness(wearable::panel_value(s_ui.draft.brightness));
            ++s_ui_epoch;
        }
        else if(g==Gesture::CLICK && s_settings_phase=="SPEECH") {
            const auto action=s_speech_menu.click(a);
            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_ui.open_settings();s_preference_feedback.clear();
            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();
}
wearable::ShellView shell_view() {
    wearable::ShellView view;
    view.selected=s_pi.selected;view.wifi=s_wifi_connected;view.gateway=s_cloud_connected;
    view.agent=s_pi.agent;view.session=s_pi.name;view.model=s_pi.model;view.effort=s_pi.effort;view.state=s_pi.state;
    static std::string project;
    project=s_pi.cwd;
    while(project.size()>1 && project.back()=='/') project.pop_back();
    const auto slash=project.find_last_of('/');if(slash!=std::string::npos) project=project.substr(slash+1);
    if(project.empty()) project=s_pi.name;
    view.project=project.c_str();
    view.activity_type=s_pi.activity_type;view.activity=s_pi.latest_activity;view.recent=s_pi_tail?s_pi_tail:"";
    view.final_text=s_pi.has_final_text?s_pi.final_text:nullptr;
    view.now_ms=esp_timer_get_time()/1000;view.last_update_ms=s_pi.received_at_ms;
    view.elapsed=s_pi.elapsed_seconds;
    if(!strcmp(s_pi.state,"RUNNING") && view.now_ms>=s_pi.received_at_ms)
        view.elapsed+=(view.now_ms-s_pi.received_at_ms)/1000;
    static char time[16],date[40],battery[24];
    const auto local_ms=s_clock.local_ms(view.now_ms);
    if(local_ms) {
        const time_t local=time_t(local_ms/1000);struct tm broken{};gmtime_r(&local,&broken);
        strftime(time,sizeof(time),"%H:%M",&broken);strftime(date,sizeof(date),"%a %b %d",&broken);
    } else {strlcpy(time,"--:--",sizeof(time));strlcpy(date,"Time unavailable",sizeof(date));}
    if(s_battery.valid) snprintf(battery,sizeof(battery),"BAT %.2fV",s_battery.millivolts/1000.0);
    else strlcpy(battery,"BAT --",sizeof(battery));
    view.time=time;view.date=date;view.battery=battery;view.rssi=s_wifi_rssi;
    view.firmware=s_version;view.wifi_detail=s_wifi_detail;view.save_feedback=s_preference_feedback.c_str();
    return view;
}
void draw_shell_app() {
    const auto view=shell_view();
    wearable::ShellRenderer<StopWatchDisplay,wearable::FontBook> renderer(s_display,s_fonts,s_ui,view);
    if(s_ui.app==wearable::App::Watch) renderer.watch();
    else if(s_ui.app==wearable::App::Help) renderer.help();
    else renderer.agent();
}
void draw_local_settings() {
    const auto view=shell_view();
    wearable::ShellRenderer<StopWatchDisplay,wearable::FontBook>(s_display,s_fonts,s_ui,view).settings();
}
void draw_input_modal() {
    if(s_input_modal==InputModal::SETTINGS && s_settings_phase=="MENU") {draw_local_settings();return;}
    draw_header(s_input_modal==InputModal::SETTINGS?"SETTINGS":s_input_modal==InputModal::ERROR?"ERROR":"VOICE");
    s_fonts.apply(s_display,wearable::TypeRole::Meta,s_ui.preferences.text_size,
                  wearable::palette(s_ui.preferences.theme).primary,TFT_BLACK);
    s_display.setTextDatum(textdatum_t::middle_center);
    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=="SPEECH"?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";}
    s_fonts.apply(s_display,wearable::TypeRole::AgentBody,s_ui.preferences.text_size,
                  wearable::palette(s_ui.preferences.theme).primary,TFT_BLACK);
    auto lines=wrap_activity_text(body.c_str(),304);
    // No touch required: long transcripts page automatically, preserving all text.
    const size_t count=std::max<int32_t>(1,(360-184)/(s_display.fontHeight()+6));
    size_t start=lines.size()>count?((esp_timer_get_time()/1000000/4)%((lines.size()+count-1)/count))*count:0;
    s_display.setTextDatum(textdatum_t::top_left);
    const int line=s_display.fontHeight()+6;
    for (size_t i=start;i<std::min(start+count,lines.size());++i) s_display.drawString(lines[i].c_str(),82,184+(i-start)*line);
    s_fonts.apply(s_display,wearable::TypeRole::Footer,s_ui.preferences.text_size,
                  wearable::palette(s_ui.preferences.theme).accent,TFT_BLACK);
    s_display.setTextDatum(textdatum_t::middle_center);
    s_display.drawString(footer.c_str(),234,386);
}

void draw_pi()
{
    const auto* previous=s_display.getFont();
    draw_shell_app();
    s_display.setFont(previous);
}

void draw_approval()
{
    draw_header("APPROVAL");
    s_fonts.apply(s_display,wearable::TypeRole::StatusPrimary,s_ui.preferences.text_size,
                  wearable::palette(s_ui.preferences.theme).approval,TFT_BLACK);
    s_display.setTextDatum(textdatum_t::middle_center);
    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_fonts.apply(s_display,wearable::TypeRole::AgentBody,s_ui.preferences.text_size,
                  wearable::palette(s_ui.preferences.theme).primary,TFT_BLACK);
    s_display.setTextDatum(textdatum_t::middle_center);
    const int line=s_display.fontHeight()+5;
    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 * line);
    auto lines = wrap_activity_text(view->detail.c_str(), 304);
    const size_t maximum=std::max<int32_t>(1,(358-205)/line);
    if (lines.size() > maximum) { lines.resize(maximum); 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, 205 + i * line);
    s_fonts.apply(s_display,wearable::TypeRole::Footer,s_ui.preferences.text_size,
                  wearable::palette(s_ui.preferences.theme).done,TFT_BLACK);
    s_display.setTextDatum(textdatum_t::middle_center);
    s_display.drawString(s_approval.submitted ? "SENDING..." : "A  APPROVE", 234, 374);
    if (!s_approval.submitted) {
        s_display.setTextColor(wearable::palette(s_ui.preferences.theme).failed, TFT_BLACK);
        s_display.drawString("B  REJECT", 234, 404);
    }
}

void draw_status(const char *reason)
{
    // Wi-Fi sys_evt has a small stack. Callbacks only request work; main owns
    // fonts, layout and panel transactions. Keep the established display lock.
    if(s_display_owner && xTaskGetCurrentTaskHandle()!=s_display_owner) {
        s_draw_requested=true;return;
    }
    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;size_t internal_before,largest_before,psram_before;
        ~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) {
                const auto internal_after=heap_caps_get_free_size(MALLOC_CAP_INTERNAL);
                const auto largest_after=heap_caps_get_largest_free_block(MALLOC_CAP_INTERNAL);
                const auto psram_after=heap_caps_get_free_size(MALLOC_CAP_SPIRAM);
                ESP_LOGI(TAG,"[UI_PERF] draw complete elapsed_ms=%lld internal_delta=%lld internal_free=%u largest_delta=%lld largest=%u psram_delta=%lld psram_free=%u glyph_missing=%lu",
                    static_cast<long long>((esp_timer_get_time()-started)/1000),
                    static_cast<long long>(internal_after)-static_cast<long long>(internal_before),
                    static_cast<unsigned>(internal_after),
                    static_cast<long long>(largest_after)-static_cast<long long>(largest_before),
                    static_cast<unsigned>(largest_after),
                    static_cast<long long>(psram_after)-static_cast<long long>(psram_before),
                    static_cast<unsigned>(psram_after),static_cast<unsigned long>(s_fonts.missing()));
            }
            unlock_display();
        }
    } diagnostic{changed,esp_timer_get_time(),heap_caps_get_free_size(MALLOC_CAP_INTERNAL),
        heap_caps_get_largest_free_block(MALLOC_CAP_INTERNAL),heap_caps_get_free_size(MALLOC_CAP_SPIRAM)};
    s_display.startWrite();
    s_display.fillScreen(TFT_BLACK);
    s_display.setFont(&fonts::Font0);
    s_display.setTextSize(1);
    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_fonts.apply(s_display,wearable::TypeRole::AgentBody,s_ui.preferences.text_size,
                      wearable::palette(s_ui.preferences.theme).primary,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);
        const int line=s_display.fontHeight()+6;const size_t count=std::max<int32_t>(1,(390-184)/line);
        for (size_t i=0;i<std::min(count,lines.size());++i) s_display.drawString(lines[i].c_str(),82,184+i*line);
        return;
    }
    if(s_message_visible) {
        draw_header(s_message_title.c_str());
        s_fonts.apply(s_display,wearable::TypeRole::AgentBody,s_ui.preferences.text_size,
                      wearable::palette(s_ui.preferences.theme).primary,TFT_BLACK);
        s_display.setTextDatum(textdatum_t::top_left);
        const auto lines=wrap_activity_text(s_message_body.c_str(),304,false);
        const int line=s_display.fontHeight()+6;const size_t count=std::max<int32_t>(1,(390-160)/line);
        for(size_t i=0;i<std::min(count,lines.size());++i) s_display.drawString(lines[i].c_str(),82,160+i*line);
        return;
    }
    if(s_ui.app!=wearable::App::Monitor) {
        draw_shell_app();s_pi_redraw_pending=false;s_last_pi_redraw_ms=esp_timer_get_time()/1000;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)
{
    if(!s_vibration_enabled.load()) {vTaskDelete(nullptr);return;}
    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) {
        if(!s_vibration_enabled.load()) break; // Always finish an already-started pulse with stop.
        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);
}

void device_status_task(void*) {
    uint64_t next_log_ms=0;
    while(true) {
        lock_display();
        const bool recording=s_input_modal==InputModal::VOICE && s_voice_phase=="RECORDING";
        unlock_display();
        if(!recording) {
            uint16_t millivolts=0;
            const auto result=s_pmic.readVbat(&millivolts);
            wifi_ap_record_t ap{};const auto wifi=esp_wifi_sta_get_ap_info(&ap);
            lock_display();
            s_battery.valid=result==M5PM1_OK && millivolts>=2000 && millivolts<=5000;
            if(s_battery.valid) s_battery.millivolts=millivolts;else ++s_battery.failures;
            s_wifi_rssi=wifi==ESP_OK?ap.rssi:-127;
            const uint64_t now_ms=esp_timer_get_time()/1000;
            if(now_ms>=next_log_ms) {
                next_log_ms=now_ms+60000;
                ESP_LOGI(TAG,"[STATUS] battery_mv=%u valid=%d read_failures=%lu rssi=%d",millivolts,
                    s_battery.valid,(unsigned long)s_battery.failures,s_wifi_rssi);
            }
            unlock_display();
        }
        vTaskDelay(pdMS_TO_TICKS(3000));
    }
}

}  // namespace

esp_err_t board_init(const char *firmware_version)
{
    s_display_owner=xTaskGetCurrentTaskHandle();
    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;
    }
    initialize_rtc_nonfatal();
    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;
    }
    s_saved_preferences=preferences_load();s_ui.boot(s_saved_preferences.value);sync_display_page();
    if(s_rtc_boot_utc_ms) s_clock.sync(double(s_rtc_boot_utc_ms),esp_timer_get_time()/1000,
        s_ui.preferences.timezone_offset_minutes);
    s_vibration_enabled=s_ui.preferences.vibration_enabled;
    lock_display();s_display.setPanelBrightness(wearable::panel_value(s_ui.preferences.brightness));unlock_display();
    ESP_LOGI(TAG,"[PREFS] load slot=%d generation=%lu readonly=%d brightness=%u home=%u text=%u theme=%u vibration=%u",
        s_saved_preferences.slot,(unsigned long)s_saved_preferences.generation,s_saved_preferences.read_only,
        s_ui.preferences.brightness,unsigned(s_ui.preferences.home_screen),unsigned(s_ui.preferences.text_size),
        unsigned(s_ui.preferences.theme),s_ui.preferences.vibration_enabled);
    if(xTaskCreate(device_status_task,"device_status",3072,nullptr,2,nullptr)!=pdPASS)
        ESP_LOGW(TAG,"[STATUS] battery polling unavailable (nonfatal)");
    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)
{
    struct Guard { Guard(){lock_display();} ~Guard(){unlock_display();} } guard;
    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) {
        const bool interrupted_voice = s_input_modal==InputModal::VOICE && s_voice_phase!="SENT";
        stop_speech(false);
        s_message_visible = false;
        if(!(s_input_modal==InputModal::SETTINGS && s_settings_phase=="MENU"))
            cancel_input(false,"cloud-disconnect");
        s_control_queue.clear();
        s_approval.disconnect();
        s_approval_action = {};
        s_notified_approval.clear();
        if (interrupted_voice) {
            s_input_modal=InputModal::ERROR;
            s_modal_error="Voice interrupted\nTry again";
        }
    }
    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;
    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();
        if(!(s_input_modal==InputModal::SETTINGS && s_settings_phase=="MENU")) 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));
    copy_agent_final(s_pi.name,snapshot->name == nullptr ? s_pi.session_id : snapshot->name);
    copy_agent_final(s_pi.cwd,snapshot->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] backing target changed; app=%u preserved",unsigned(s_ui.app));
        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 && s_vibration_enabled.load()) {
        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() || s_input_modal!=InputModal::NONE) return;
    s_message_visible=false;s_ui.navigate(true);sync_display_page();++s_ui_epoch;
    ESP_LOGI(TAG,"[SHELL] A app=%u metric=%u",unsigned(s_ui.app),s_ui.monitor_page);
    draw_status();
}
void board_previous_page(void)
{
    struct Guard { Guard() { lock_display(); } ~Guard() { unlock_display(); } } guard;
    if(s_approval.modal() || s_input_modal!=InputModal::NONE) return;
    s_message_visible=false;s_ui.navigate(false);sync_display_page();++s_ui_epoch;
    ESP_LOGI(TAG,"[SHELL] B app=%u metric=%u",unsigned(s_ui.app),s_ui.monitor_page);
    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() || !s_display_ready) return;
    s_message_visible=true;s_message_until_ms=esp_timer_get_time()/1000+5000;
    s_message_title=title?title:"";s_message_body=body?body:"";
    draw_status("message");
}

void board_vibrate(uint32_t duration_ms)
{
    if (!s_vibrator_ready || !s_vibration_enabled.load()) {
        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_draw_requested.exchange(false)) draw_status("callback_request");
    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 && s_vibration_enabled.load()) 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");sync_display_page();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;
        }
    }
    const uint64_t shell_interval=!strcmp(s_pi.state,"RUNNING")?1000:
        s_ui.app==wearable::App::Watch?60000:6000;
    if(now_ms/shell_interval!=s_last_shell_tick && !page_overlay_visible() && s_ui.app!=wearable::App::Monitor) {
        s_last_shell_tick=now_ms/shell_interval;draw_status("shell_clock_tick");
    }
    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_voice_interrupted(uint64_t id) {
    struct Guard { Guard(){lock_display();} ~Guard(){unlock_display();} } guard;
    if (id!=s_voice_serial || s_input_modal!=InputModal::VOICE) return;
    cancel_input(false,"voice-link-interrupted");
    s_input_modal=InputModal::ERROR;
    s_modal_error="Voice interrupted\nTry again";
    draw_status();
}
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();
}
// 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 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="SPEECH";
        }
    } else if (type=="speech_start") {
        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) 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;
        if (!valid || !s_speech.chunk(id,number("sequence"),size) || !audio_backend_speech_push(s_speech_generation,decoded,size)) stop_speech();
        return false;
    } else if (type=="speech_end") {
        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) {
    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);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);
}

void board_sync_utc_time(double server_time_ms, int timezone_offset_minutes)
{
    struct Guard { Guard() { lock_display(); } ~Guard() { unlock_display(); } } guard;
    const bool accepted=s_clock.sync(server_time_ms,esp_timer_get_time()/1000,timezone_offset_minutes);
    esp_err_t rtc_result=ESP_ERR_INVALID_ARG,prefs_result=ESP_OK;
    if(accepted) {
        rtc_result=s_rtc.write_utc_ms(uint64_t(server_time_ms));
        if(s_ui.preferences.timezone_offset_minutes!=timezone_offset_minutes) {
            s_ui.preferences.timezone_offset_minutes=timezone_offset_minutes;
            s_ui.draft.timezone_offset_minutes=timezone_offset_minutes;
            prefs_result=preferences_save(s_saved_preferences,s_ui.preferences);
        }
    }
    ESP_LOGI(TAG,"[TIME] gateway UTC accepted=%d offset_minutes=%d rtc_write=%s timezone_save=%s",
        accepted,timezone_offset_minutes,esp_err_to_name(rtc_result),esp_err_to_name(prefs_result));
}
