#ifdef ESP_PLATFORM
#include <sdkconfig.h>
#endif

#if defined(CONFIG_BT_NIMBLE_ENABLED)

#include <bluetooth/esp32_ble_internal.h>

#include <tactility/device.h>
#include <tactility/driver.h>

#include <host/ble_att.h>
#include <host/ble_gap.h>
#include <host/ble_hs.h>
#include <host/ble_sm.h>
#include <host/ble_store.h>
#include <host/ble_uuid.h>
#include <host/util/util.h>
#include <nimble/nimble_port.h>
#include <nimble/nimble_port_freertos.h>
#include <services/gap/ble_svc_gap.h>
#include <services/gatt/ble_svc_gatt.h>
#include <store/config/ble_store_config.h>

#include <atomic>
#include <cstring>

constexpr auto* TAG = "esp32_ble";
#include <tactility/log.h>
#include <esp_timer.h>

#if defined(CONFIG_ESP_HOSTED_ENABLED)
#include <esp_hosted.h>
// esp_hosted_misc.h lacks its own extern "C" guard, so its declarations get C++
// name-mangled when included from a .cpp file, causing "undefined reference" at
// link time against the library's plain-C symbols.
extern "C" {
#include <esp_hosted_misc.h>
}
#endif

// ble_store_config_init() is not declared in the public header in some IDF versions.
extern "C" void ble_store_config_init(void);

// Sub-module API structs (defined in their respective cpp files).
extern const BtHidDeviceApi nimble_hid_device_api;
extern const BtSerialApi nimble_serial_api;
extern const BtMidiApi   nimble_midi_api;

// File-static BleCtx pointer used only by NimBLE host callbacks whose signature
// is fixed by the NimBLE API (on_sync, on_reset) and cannot carry a Device*.
// All other callbacks receive Device* via their void* arg parameter.
static BleCtx* s_ctx = nullptr;

// ---- Context accessor ----
// Returns the BleCtx for any BLE device (root or child).
// If device is the root BLE device (BLUETOOTH_TYPE) its driver_data IS the BleCtx.
// If device is a child BLE device its parent is the root BLE device.
// We must NOT use device_get_parent blindly: the DTS root node is the parent of ble0,
// so device_get_parent(ble0) returns a non-null node whose driver_data is not BleCtx.

BleCtx* ble_get_ctx(struct Device* device) {
    if (device_get_type(device) == &BLUETOOTH_TYPE) {
        return (BleCtx*)device_get_driver_data(device);
    }
    struct Device* parent = device_get_parent(device);
    return parent ? (BleCtx*)device_get_driver_data(parent) : nullptr;
}

// ---- Forward declarations ----
static void host_task(void* param);
static void on_sync();
static void on_reset(int reason);
static void dispatch_enable(BleCtx* ctx);
static void dispatch_disable(BleCtx* ctx);
static int  gap_event_handler(struct ble_gap_event* event, void* arg);

// ---- General field accessor implementations ----

BtRadioState ble_get_radio_state(struct Device* device) {
    return ble_get_ctx(device)->radio_state.load();
}

bool ble_hid_get_host_active(struct Device* device) {
    return ble_get_ctx(device)->hid_host_active.load();
}
bool ble_get_scan_active(struct Device* device) {
    return ble_get_ctx(device)->scan_active.load();
}
void ble_set_scan_active(struct Device* device, bool v) {
    ble_get_ctx(device)->scan_active.store(v);
}

// ---- Event publishing ----

void ble_publish_event(struct Device* device, struct BtEvent event) {
    BleCtx* ctx = ble_get_ctx(device);
    if (!ctx) return;
    // Copy under mutex so callbacks can safely call add/remove_event_callback
    BleCallbackEntry local[BLE_MAX_CALLBACKS];
    size_t count;
    xSemaphoreTake(ctx->cb_mutex, portMAX_DELAY);
    count = ctx->callback_count;
    memcpy(local, ctx->callbacks, count * sizeof(BleCallbackEntry));
    xSemaphoreGive(ctx->cb_mutex);
    for (size_t i = 0; i < count; i++) {
        local[i].fn(ctx->device, local[i].ctx, event);
    }
}

// ---- Advertising restart helper ----
//TODO: Fix Restart name-only advertising after a rename.
static void adv_restart_callback(void* arg) {
    struct Device* device = (struct Device*)arg;
    BleCtx* ctx = ble_get_ctx(device);
    if (ctx->radio_state.load() != BT_RADIO_STATE_ON) return;

    uint16_t hid_conn = ble_hid_get_conn_handle(ctx->hid_device_child);

    if (ctx->midi_active.load() && ctx->midi_conn_handle.load() == BLE_HS_CONN_HANDLE_NONE) {
        ble_start_advertising(device, &MIDI_SVC_UUID);
    } else if (ctx->spp_active.load() && ctx->spp_conn_handle.load() == BLE_HS_CONN_HANDLE_NONE) {
        ble_start_advertising(device, &NUS_SVC_UUID);
    } else if (ctx->hid_active.load() && hid_conn == BLE_HS_CONN_HANDLE_NONE) {
        ble_start_advertising_hid(device, hid_appearance);
    }
}

void ble_schedule_adv_restart(struct Device* device, uint64_t delay_us) {
    BleCtx* ctx = ble_get_ctx(device);
    if (delay_us == 0) {
        adv_restart_callback(device);
        return;
    }
    if (ctx->adv_restart_timer == nullptr) {
        esp_timer_create_args_t args = {};
        args.callback        = adv_restart_callback;
        args.arg             = device;
        args.dispatch_method = ESP_TIMER_TASK;
        args.name            = "ble_adv_restart";
        int rc = esp_timer_create(&args, &ctx->adv_restart_timer);
        if (rc != ESP_OK) {
            LOG_E(TAG, "adv_restart timer create failed (rc=%d)", rc);
            return;
        }
    }
    esp_timer_stop(ctx->adv_restart_timer);
    int rc = esp_timer_start_once(ctx->adv_restart_timer, delay_us);
    if (rc != ESP_OK) {
        LOG_E(TAG, "adv_restart timer start failed (rc=%d)", rc);
    }
}

// ---- GAP connection event handler ----

static int gap_event_handler(struct ble_gap_event* event, void* arg) {
    struct Device* device = (struct Device*)arg;
    BleCtx* ctx = ble_get_ctx(device);

    switch (event->type) {
        case BLE_GAP_EVENT_CONNECT:
            if (event->connect.status == 0) {
                LOG_I(TAG, "Connected (handle=%u hid_active=%d hid_conn=%u)",
                         event->connect.conn_handle,
                         (int)ctx->hid_active.load(),
                         (unsigned)ble_hid_get_conn_handle(ctx->hid_device_child));
                // Do NOT call ble_gap_security_initiate() here.
                // Windows BLE MIDI initiates encryption itself; calling here creates a race
                // with REPEAT_PAIRING+RETRY → two concurrent SM procedures → disconnect.
                // On esp_hosted, ENC_CHANGE can arrive BEFORE CONNECT, so re-initiating
                // on an already-encrypted link triggers unwanted re-pairing.
            } else {
                LOG_W(TAG, "Connection failed (status=%d)", event->connect.status);
                // Delay restart so NimBLE can clean up SMP/connection state before peer retries.
                ble_schedule_adv_restart(device, 500'000);
            }
            break;

        case BLE_GAP_EVENT_DISCONNECT: {
            LOG_I(TAG, "Disconnected (reason=%d)", event->disconnect.reason);
            uint16_t hdl = event->disconnect.conn.conn_handle;
            bool was_spp  = (ctx->spp_conn_handle.load()  == hdl);
            bool was_midi = (ctx->midi_conn_handle.load() == hdl);
            bool was_hid  = (ble_hid_get_conn_handle(ctx->hid_device_child) == hdl);
            if (was_spp)  ctx->spp_conn_handle.store(BLE_HS_CONN_HANDLE_NONE);
            if (was_midi) { ctx->midi_conn_handle.store(BLE_HS_CONN_HANDLE_NONE); ctx->midi_use_indicate.store(false); }
            if (was_hid)  ble_hid_set_conn_handle(ctx->hid_device_child, BLE_HS_CONN_HANDLE_NONE);
            ctx->link_encrypted.store(false);
            // If HID was stopped while connected, switch profile to None now that the
            // connection is gone. ble_gatts_mutable() is true here (no active connection,
            // advertising stopped by hid_device_stop), so switch_profile is safe.
            // Skip during shutdown — ble_gatts_reset() is unsafe while nimble_port_stop() runs.
            if (was_hid && !ctx->hid_active.load() && current_hid_profile != BleHidProfile::None &&
                ctx->radio_state.load() != BT_RADIO_STATE_OFF_PENDING) {
                if (!ble_hid_switch_profile(ctx->hid_device_child, BleHidProfile::None)) {
                    LOG_E(TAG, "disconnect: switch to None failed — GATT state inconsistent");
                }
            }
            // Restart advertising whenever a service is active without a live connection.
            // Covers both normal disconnect and Windows discovery-only connections.
            // Skip if the radio is going OFF_PENDING — nimble_port_stop() is in progress
            // and calling ble_gap_adv_start() from within the NimBLE host task while the
            // controller is shutting down would block the host task and hang nimble_port_stop().
            if (ctx->radio_state.load() != BT_RADIO_STATE_OFF_PENDING) {
                uint16_t hid_conn_now = ble_hid_get_conn_handle(ctx->hid_device_child);
                if (ctx->midi_active.load() && ctx->midi_conn_handle.load() == BLE_HS_CONN_HANDLE_NONE) {
                    ble_start_advertising(device, &MIDI_SVC_UUID);
                } else if (ctx->spp_active.load() && ctx->spp_conn_handle.load() == BLE_HS_CONN_HANDLE_NONE) {
                    ble_start_advertising(device, &NUS_SVC_UUID);
                } else if (ctx->hid_active.load() && hid_conn_now == BLE_HS_CONN_HANDLE_NONE) {
                    ble_start_advertising_hid(device, hid_appearance);
                }
            }
            break;
        }

        case BLE_GAP_EVENT_SUBSCRIBE:
            LOG_I(TAG, "Subscribe attr=%u cur_notify=%u cur_indicate=%u (nus_tx=%u midi_io=%u)",
                     event->subscribe.attr_handle,
                     (unsigned)event->subscribe.cur_notify,
                     (unsigned)event->subscribe.cur_indicate,
                     nus_tx_handle, midi_io_handle);
            if (event->subscribe.attr_handle != nus_tx_handle &&
                event->subscribe.attr_handle != midi_io_handle &&
                event->subscribe.cur_indicate) {
                // Service Changed subscription — ignore (Windows discovers on its own)
                LOG_I(TAG, "Service Changed subscription (attr=%u) — ignoring",
                         event->subscribe.attr_handle);
            } else if (event->subscribe.attr_handle == nus_tx_handle) {
                if (!ctx->spp_active.load()) {
                    LOG_I(TAG, "SPP CCCD subscribed but spp_active=false — ignoring");
                    break;
                }
                ctx->spp_conn_handle.store(event->subscribe.conn_handle);
                LOG_I(TAG, "SPP client subscribed (nus_tx=%u)", nus_tx_handle);
                // Fire PROFILE_STATE_CHANGED so Tactility bridge can persist the profile
                // off the NimBLE host task (file I/O → stack overflow on nimble_host).
                {
                    struct ble_gap_conn_desc sub_desc = {};
                    if (ble_gap_conn_find(event->subscribe.conn_handle, &sub_desc) == 0) {
                        struct BtEvent e = {};
                        e.type = BT_EVENT_PROFILE_STATE_CHANGED;
                        memcpy(e.profile_state.addr, sub_desc.peer_id_addr.val, 6);
                        e.profile_state.profile = BT_PROFILE_SPP;
                        e.profile_state.state   = BT_PROFILE_STATE_CONNECTED;
                        ble_publish_event(device, e);
                    }
                }
            } else if (event->subscribe.attr_handle == midi_io_handle) {
                if ((event->subscribe.cur_notify || event->subscribe.cur_indicate) && !ctx->midi_active.load()) {
                    LOG_I(TAG, "MIDI CCCD subscribed but midi_active=false — ignoring");
                    break;
                }
                if (event->subscribe.cur_notify || event->subscribe.cur_indicate) {
                    ctx->midi_conn_handle.store(event->subscribe.conn_handle);
                    ctx->midi_use_indicate.store(event->subscribe.cur_indicate != 0);
                    LOG_I(TAG, "MIDI client subscribed (midi_io=%u indicate=%d)",
                             midi_io_handle, (int)ctx->midi_use_indicate.load());
                    // Fire PROFILE_STATE_CHANGED so Tactility bridge persists the profile.
                    {
                        struct ble_gap_conn_desc sub_desc = {};
                        if (ble_gap_conn_find(event->subscribe.conn_handle, &sub_desc) == 0) {
                            struct BtEvent e = {};
                            e.type = BT_EVENT_PROFILE_STATE_CHANGED;
                            memcpy(e.profile_state.addr, sub_desc.peer_id_addr.val, 6);
                            e.profile_state.profile = BT_PROFILE_MIDI;
                            e.profile_state.state   = BT_PROFILE_STATE_CONNECTED;
                            ble_publish_event(device, e);
                        }
                    }
                    // Send MIDI Active Sensing immediately after subscription.
                    static const uint8_t active_sensing_pkt[3] = { 0x80, 0x80, 0xFE };
                    struct os_mbuf* as_om = ble_hs_mbuf_from_flat(active_sensing_pkt, 3);
                    if (as_om != nullptr) {
                        int as_rc = ctx->midi_use_indicate.load()
                            ? ble_gatts_indicate_custom(ctx->midi_conn_handle.load(), midi_io_handle, as_om)
                            : ble_gatts_notify_custom(ctx->midi_conn_handle.load(), midi_io_handle, as_om);
                        if (as_rc != 0) os_mbuf_free_chain(as_om);
                        LOG_I(TAG, "Active Sensing (subscribe) rc=%d", as_rc);
                    }
                } else {
                    ctx->midi_conn_handle.store(BLE_HS_CONN_HANDLE_NONE);
                    ctx->midi_use_indicate.store(false);
                    LOG_I(TAG, "MIDI client unsubscribed");
                }
            } else if (event->subscribe.cur_notify &&
                       (event->subscribe.attr_handle == hid_kb_input_handle ||
                        event->subscribe.attr_handle == hid_consumer_input_handle ||
                        event->subscribe.attr_handle == hid_mouse_input_handle ||
                        event->subscribe.attr_handle == hid_gamepad_input_handle)) {
                const char* rpt_name =
                    (event->subscribe.attr_handle == hid_kb_input_handle)       ? "keyboard" :
                    (event->subscribe.attr_handle == hid_consumer_input_handle)  ? "consumer" :
                    (event->subscribe.attr_handle == hid_mouse_input_handle)     ? "mouse"    :
                    (event->subscribe.attr_handle == hid_gamepad_input_handle)   ? "gamepad"  : "unknown";
                if (!ctx->hid_active.load()) {
                    LOG_I(TAG, "HID CCCD subscribed (%s) but hid_active=false — ignoring", rpt_name);
                    break;
                }
                LOG_I(TAG, "HID CCCD subscribed: %s (attr=%u conn=%u)",
                         rpt_name, event->subscribe.attr_handle, event->subscribe.conn_handle);
                if (ble_hid_get_conn_handle(ctx->hid_device_child) == BLE_HS_CONN_HANDLE_NONE) {
                    ble_hid_set_conn_handle(ctx->hid_device_child, event->subscribe.conn_handle);
                }
            }
            break;

        case BLE_GAP_EVENT_MTU:
            LOG_I(TAG, "MTU updated (conn=%u mtu=%u)",
                     event->mtu.conn_handle, event->mtu.value);
            break;

        case BLE_GAP_EVENT_CONN_UPDATE: {
            struct ble_gap_conn_desc desc = {};
            if (ble_gap_conn_find(event->conn_update.conn_handle, &desc) == 0) {
                LOG_I(TAG, "Conn params updated (status=%d itvl=%u latency=%u timeout=%u)",
                         event->conn_update.status, desc.conn_itvl,
                         desc.conn_latency, desc.supervision_timeout);
            }
            break;
        }

        case BLE_GAP_EVENT_CONN_UPDATE_REQ:
            *event->conn_update_req.self_params = *event->conn_update_req.peer_params;
            return 0;

        case BLE_GAP_EVENT_ENC_CHANGE:
            LOG_I(TAG, "Encryption changed (conn=%u status=%d)",
                     event->enc_change.conn_handle, event->enc_change.status);
            if (event->enc_change.status == 0) {
                ctx->link_encrypted.store(true);
                // For HID device: set hid_conn_handle at encryption time so Phase 2 bonded
                // reconnects are tracked even when BLE_GAP_EVENT_SUBSCRIBE doesn't fire.
                // Windows only writes HID CCCDs in Phase 2; NimBLE may restore them from NVS
                // silently (no SUBSCRIBE event). Without this, hid_conn_handle stays NONE
                // and hid_device_is_connected() returns false for the entire Phase 2 session.
                if (ctx->hid_active.load() &&
                    ble_hid_get_conn_handle(ctx->hid_device_child) == BLE_HS_CONN_HANDLE_NONE) {
                    ble_hid_set_conn_handle(ctx->hid_device_child, event->enc_change.conn_handle);
                    LOG_I(TAG, "HID conn handle set on ENC_CHANGE (conn=%u)",
                             event->enc_change.conn_handle);
                }
                // Fire PAIR_RESULT so Tactility bridge can persist the paired device
                // off the NimBLE host task (file I/O → stack overflow on nimble_host).
                struct ble_gap_conn_desc desc = {};
                if (ble_gap_conn_find(event->enc_change.conn_handle, &desc) == 0) {
                    struct BtEvent e = {};
                    e.type = BT_EVENT_PAIR_RESULT;
                    memcpy(e.pair_result.addr, desc.peer_id_addr.val, 6);
                    e.pair_result.result  = BT_PAIR_RESULT_SUCCESS;
                    e.pair_result.profile = ctx->midi_active.load() ? BT_PROFILE_MIDI
                                          : ctx->spp_active.load()  ? BT_PROFILE_SPP
                                          : ctx->hid_active.load()  ? BT_PROFILE_HID_DEVICE
                                                                     : BT_PROFILE_HID_HOST;
                    ble_publish_event(device, e);
                }
            }
            // Re-send Active Sensing now that the link is encrypted.
            if (event->enc_change.status == 0 &&
                ctx->midi_conn_handle.load() == event->enc_change.conn_handle) {
                static const uint8_t as_pkt[3] = { 0x80, 0x80, 0xFE };
                struct os_mbuf* om = ble_hs_mbuf_from_flat(as_pkt, 3);
                if (om != nullptr) {
                    int rc = ctx->midi_use_indicate.load()
                        ? ble_gatts_indicate_custom(ctx->midi_conn_handle.load(), midi_io_handle, om)
                        : ble_gatts_notify_custom(ctx->midi_conn_handle.load(), midi_io_handle, om);
                    if (rc != 0) os_mbuf_free_chain(om);
                    LOG_I(TAG, "Active Sensing (post-enc) rc=%d", rc);
                }
            }
            break;

        case BLE_GAP_EVENT_PASSKEY_ACTION: {
            LOG_I(TAG, "Passkey action (conn=%u action=%u)",
                     event->passkey.conn_handle, event->passkey.params.action);
            struct ble_sm_io pkey = {};
            pkey.action = event->passkey.params.action;
            if (pkey.action == BLE_SM_IOACT_NONE) {
                ble_sm_inject_io(event->passkey.conn_handle, &pkey);
            }
            break;
        }

        case BLE_GAP_EVENT_REPEAT_PAIRING: {
            LOG_I(TAG, "Repeat pairing (conn=%u encrypted=%d)",
                     event->repeat_pairing.conn_handle, (int)ctx->link_encrypted.load());
            struct ble_gap_conn_desc desc;
            if (ble_gap_conn_find(event->repeat_pairing.conn_handle, &desc) == 0) {
                ble_store_util_delete_peer(&desc.peer_id_addr);
                if (!ctx->link_encrypted.load()) {
                    // Fire BOND_LOST so Tactility bridge removes the stored device record.
                    struct BtEvent e = {};
                    e.type = BT_EVENT_PAIR_RESULT;
                    memcpy(e.pair_result.addr, desc.peer_id_addr.val, 6);
                    e.pair_result.result = BT_PAIR_RESULT_BOND_LOST;
                    ble_publish_event(device, e);
                }
            }
            // If already encrypted, IGNORE so the current session continues.
            // RETRY would start a 30-second SMP timer that always expires (peer won't
            // respond to a new Pair Request on an encrypted link) → forced disconnect.
            if (ctx->link_encrypted.load()) {
                LOG_I(TAG, "Repeat pairing: link encrypted — ignoring");
                return BLE_GAP_REPEAT_PAIRING_IGNORE;
            }
            return BLE_GAP_REPEAT_PAIRING_RETRY;
        }

        default:
            break;
    }
    return 0;
}

// ---- NimBLE host sync / reset callbacks ----

static void on_sync() {
    LOG_I(TAG, "NimBLE synced (nus_tx=%u midi_io=%u hid_kb=%u hid_cs=%u hid_ms=%u hid_gp=%u)",
             nus_tx_handle, midi_io_handle,
             hid_kb_input_handle, hid_consumer_input_handle,
             hid_mouse_input_handle, hid_gamepad_input_handle);
    BleCtx* ctx = s_ctx;
    if (ctx == nullptr) return;

    ctx->pending_reset_count.store(0);

    uint8_t own_addr_type;
    int rc = ble_hs_util_ensure_addr(0);
    if (rc != 0) LOG_E(TAG, "ensure_addr failed (rc=%d)", rc);
    rc = ble_hs_id_infer_auto(0, &own_addr_type);
    if (rc != 0) LOG_E(TAG, "infer addr type failed (rc=%d)", rc);

    // Sync GATT handle values — pass the child device so the GATT callbacks
    // can retrieve child driver data (BleSppCtx / BleMidiCtx) without globals.
    ble_spp_init_gatt_handles(ctx->serial_child);
    ble_midi_init_gatt_handles(ctx->midi_child);

    ctx->radio_state.store(BT_RADIO_STATE_ON);
    struct BtEvent e = {};
    e.type = BT_EVENT_RADIO_STATE_CHANGED;
    e.radio_state = BT_RADIO_STATE_ON;
    ble_publish_event(ctx->device, e);

    // The Tactility bridge handles auto-start (SPP/MIDI/HID) in response to
    // BT_EVENT_RADIO_STATE_CHANGED(ON), dispatched to the main task above.
    // On a multi-core ESP32 the main task can preempt the NimBLE host task
    // between ble_publish_event() and here, call hid/spp/midi_device_start(),
    // set hid/spp/midi_active, and already start profile-specific advertising.
    // Only start name-only advertising if no profile has been activated yet;
    // otherwise we would overwrite the correct profile advertising with name-only,
    // causing Windows to connect without seeing the HID/SPP/MIDI service UUID.
    if (!ctx->hid_active.load() && !ctx->spp_active.load() && !ctx->midi_active.load()) {
        ble_start_advertising(ctx->device, nullptr);
    }
}

static void dispatch_disable_timer_cb(void* arg) {
    BleCtx* ctx = (BleCtx*)arg;
    // Matches api_set_radio_enabled()'s locking so the give-up teardown can't run
    // concurrently with a user-initiated dispatch_enable()/dispatch_disable() on
    // the same ctx (both would otherwise race nimble_port_stop()/deinit()).
    xSemaphoreTake(ctx->radio_mutex, portMAX_DELAY);
    dispatch_disable(ctx);
    xSemaphoreGive(ctx->radio_mutex);
}

// Runs on the NimBLE host task, but only once the event loop is idle again —
// i.e. strictly after the ble_hs_reset()/ble_hs_sync() call that triggered the
// giveup has fully returned (this event was queued behind it on g_eventq_dflt).
// Safe to kick disable_timer here: no in-flight NimBLE call is touching
// ble_hs_timer on this task anymore. See giveup_event's comment in BleCtx.
static void giveup_event_cb(struct ble_npl_event* ev) {
    BleCtx* ctx = (BleCtx*)ble_npl_event_get_arg(ev);
    if (ctx != nullptr && ctx->disable_timer != nullptr) {
        esp_timer_start_once(ctx->disable_timer, 0);
    }
}

static void on_reset(int reason) {
    LOG_W(TAG, "NimBLE host reset (reason=%d)", reason);
    BleCtx* ctx = s_ctx;
    if (ctx == nullptr) return;

    if (ctx->radio_state.load() == BT_RADIO_STATE_ON_PENDING) {
        int count = ctx->pending_reset_count.fetch_add(1) + 1;
        if (count == 3) {
            LOG_E(TAG, "BT controller unresponsive after 3 resets — giving up");
            // Do NOT fire disable_timer directly from here: ble_hs_reset() (our
            // caller) unconditionally falls through to ble_hs_sync() right after
            // reset_cb returns, which touches the unlocked global ble_hs_timer.
            // Queue the giveup behind that on NimBLE's own event queue instead —
            // see giveup_event_cb().
            ble_npl_eventq_put(nimble_port_get_dflt_eventq(), &ctx->giveup_event);
        }
    }
}

static void host_task(void* param) {
    LOG_I(TAG, "BLE host task started");
    nimble_port_run();
    // Signal that this task will not process any further NimBLE events (g_eventq_dflt
    // is fully drained past the stop sentinel) before dispatch_disable() is allowed to
    // proceed to nimble_port_deinit(). nimble_port_stop()'s own semaphore only confirms
    // the stop event was serviced, not that the host task has stopped dequeuing events —
    // a fresh HCI-ack-wait timeout can still queue and run ble_hs_ev_reset in that gap,
    // racing against deinit freeing ble_hs_timer's callout (crash: ble_hs_timer_sched ->
    // ble_npl_callout_is_active on a freed/zeroed callout).
    if (s_ctx != nullptr && s_ctx->host_task_done_sem != nullptr) {
        xSemaphoreGive(s_ctx->host_task_done_sem);
    }
    // nimble_port_deinit() is called by dispatch_disable() after nimble_port_stop() returns.
    nimble_port_freertos_deinit();
}

// ---- Advertising helpers ----

void ble_start_advertising(struct Device* device, const ble_uuid128_t* svc_uuid) {
    ble_gap_adv_stop();

    // Always sync the GAP name from ctx right before building the advertising packet,
    // so bluetooth_set_device_name() is honoured regardless of call timing.
    BleCtx* _name_ctx = ble_get_ctx(device);
    if (_name_ctx) ble_svc_gap_device_name_set(_name_ctx->device_name);

    int rc;
    if (svc_uuid != nullptr) {
        const char* name = ble_svc_gap_device_name();
        uint8_t name_len  = (uint8_t)strlen(name);
        uint8_t short_len = (name_len > 8) ? 8 : name_len;

        ble_uuid128_t uuid_copy = *svc_uuid;
        struct ble_hs_adv_fields fields;
        memset(&fields, 0, sizeof(fields));
        fields.flags                = BLE_HS_ADV_F_DISC_GEN | BLE_HS_ADV_F_BREDR_UNSUP;
        fields.uuids128             = &uuid_copy;
        fields.num_uuids128         = 1;
        fields.uuids128_is_complete = 1;
        fields.name                 = (const uint8_t*)name;
        fields.name_len             = short_len;
        fields.name_is_complete     = 0; // AD type 0x08 = Shortened Local Name

        rc = ble_gap_adv_set_fields(&fields);
        if (rc != 0) {
            LOG_W(TAG, "startAdvertising: set_fields with name failed rc=%d, retrying", rc);
            fields.name     = nullptr;
            fields.name_len = 0;
            rc = ble_gap_adv_set_fields(&fields);
            if (rc != 0) {
                LOG_E(TAG, "startAdvertising: set_fields failed rc=%d", rc);
                return;
            }
        }
        struct ble_hs_adv_fields rsp;
        memset(&rsp, 0, sizeof(rsp));
        rsp.name             = (const uint8_t*)name;
        rsp.name_len         = name_len;
        rsp.name_is_complete = 1;
        rc = ble_gap_adv_rsp_set_fields(&rsp);
        if (rc != 0) {
            LOG_W(TAG, "startAdvertising: rsp_set_fields rc=%d (non-fatal)", rc);
        }
        LOG_I(TAG, "startAdvertising: UUID mode (uuid[0..3]=%02x%02x%02x%02x)",
                 svc_uuid->value[0], svc_uuid->value[1],
                 svc_uuid->value[2], svc_uuid->value[3]);
    } else {
        struct ble_hs_adv_fields fields;
        memset(&fields, 0, sizeof(fields));
        fields.flags = BLE_HS_ADV_F_DISC_GEN | BLE_HS_ADV_F_BREDR_UNSUP;
        const char* name = ble_svc_gap_device_name();
        fields.name             = (const uint8_t*)name;
        fields.name_len         = (uint8_t)strlen(name);
        fields.name_is_complete = 1;
        rc = ble_gap_adv_set_fields(&fields);
        if (rc != 0) {
            LOG_E(TAG, "startAdvertising: set_fields failed rc=%d", rc);
            return;
        }
        LOG_I(TAG, "startAdvertising: name-only mode");
    }

    struct ble_gap_adv_params adv_params;
    memset(&adv_params, 0, sizeof(adv_params));
    adv_params.conn_mode = BLE_GAP_CONN_MODE_UND;
    adv_params.disc_mode = BLE_GAP_DISC_MODE_GEN;
    adv_params.itvl_min  = 160; // 100 ms
    adv_params.itvl_max  = 240; // 150 ms

    rc = ble_gap_adv_start(BLE_OWN_ADDR_PUBLIC, nullptr, BLE_HS_FOREVER,
                           &adv_params, gap_event_handler, device);
    if (rc != 0 && rc != BLE_HS_EALREADY) {
        LOG_E(TAG, "startAdvertising: adv_start failed rc=%d", rc);
    } else {
        LOG_I(TAG, "startAdvertising: OK");
    }
}

void ble_start_advertising_hid(struct Device* device, uint16_t appearance) {
    ble_gap_adv_stop();

    // Always sync the GAP name from ctx right before building the advertising packet.
    BleCtx* _name_ctx = ble_get_ctx(device);
    if (_name_ctx) ble_svc_gap_device_name_set(_name_ctx->device_name);

    const char* name  = ble_svc_gap_device_name();
    uint8_t name_len  = (uint8_t)strlen(name);
    uint8_t short_len = (name_len > 8) ? 8 : name_len;

    static const ble_uuid16_t hid_uuid16 = BLE_UUID16_INIT(0x1812);

    struct ble_hs_adv_fields fields;
    memset(&fields, 0, sizeof(fields));
    fields.flags                = BLE_HS_ADV_F_DISC_GEN | BLE_HS_ADV_F_BREDR_UNSUP;
    fields.appearance           = appearance;
    fields.appearance_is_present = 1;
    fields.uuids16              = &hid_uuid16;
    fields.num_uuids16          = 1;
    fields.uuids16_is_complete  = 1;
    fields.name                 = (const uint8_t*)name;
    fields.name_len             = short_len;
    fields.name_is_complete     = 0;

    int rc = ble_gap_adv_set_fields(&fields);
    if (rc != 0) {
        LOG_W(TAG, "startAdvertisingHid: set_fields with name failed rc=%d, retrying", rc);
        fields.name     = nullptr;
        fields.name_len = 0;
        rc = ble_gap_adv_set_fields(&fields);
        if (rc != 0) {
            LOG_E(TAG, "startAdvertisingHid: set_fields failed rc=%d", rc);
            return;
        }
    }

    struct ble_hs_adv_fields rsp;
    memset(&rsp, 0, sizeof(rsp));
    rsp.name             = (const uint8_t*)name;
    rsp.name_len         = name_len;
    rsp.name_is_complete = 1;
    rc = ble_gap_adv_rsp_set_fields(&rsp);
    if (rc != 0) {
        LOG_W(TAG, "startAdvertisingHid: rsp_set_fields rc=%d (non-fatal)", rc);
    }

    struct ble_gap_adv_params adv_params;
    memset(&adv_params, 0, sizeof(adv_params));
    adv_params.conn_mode = BLE_GAP_CONN_MODE_UND;
    adv_params.disc_mode = BLE_GAP_DISC_MODE_GEN;
    adv_params.itvl_min  = 160;
    adv_params.itvl_max  = 240;

    rc = ble_gap_adv_start(BLE_OWN_ADDR_PUBLIC, nullptr, BLE_HS_FOREVER,
                           &adv_params, gap_event_handler, device);
    if (rc != 0 && rc != BLE_HS_EALREADY) {
        LOG_E(TAG, "startAdvertisingHid: adv_start failed rc=%d", rc);
    } else {
        LOG_I(TAG, "startAdvertisingHid: OK");
    }
}

// ---- Dispatch helpers ----

static void dispatch_enable(BleCtx* ctx) {
    LOG_I(TAG, "dispatch_enable()");

    if (ctx->radio_state.load() != BT_RADIO_STATE_OFF) {
        LOG_W(TAG, "Cannot enable from current state");
        return;
    }

    ctx->radio_state.store(BT_RADIO_STATE_ON_PENDING);
    {
        struct BtEvent e = {};
        e.type = BT_EVENT_RADIO_STATE_CHANGED;
        e.radio_state = BT_RADIO_STATE_ON_PENDING;
        ble_publish_event(ctx->device, e);
    }

#if defined(CONFIG_ESP_HOSTED_ENABLED)
    // Over esp_hosted (SDIO to a co-processor, e.g. C6), the slave's on-chip BT
    // controller is never auto-started at slave boot — it only comes up in
    // response to these RPCs. Without them, HCI Reset (the first command NimBLE
    // ever sends) is handed to esp_vhci_host_send_packet() on the slave with no
    // controller underneath to consume it, so it's silently dropped and NimBLE
    // times out (BLE_HS_ETIMEOUT_HCI) forever. Must run before nimble_port_init().
    // esp_hosted_bt_controller_init/enable() both bail out immediately (no retry,
    // no blocking) if the SDIO/SPI transport to the co-processor isn't marked up
    // yet. On boot, BT can auto-enable before Wi-Fi has driven that bring-up, so
    // explicitly (re)connect here and let it block until the slave handshake
    // (the "Attempt connection with slave" / "Card init success" sequence)
    // completes — esp_hosted_connect_to_slave() is a thin wrapper that's safe to
    // call again if the transport is already up.
    if (esp_hosted_connect_to_slave() != ESP_OK) {
        LOG_W(TAG, "esp_hosted_connect_to_slave failed");
    }
    if (esp_hosted_bt_controller_init() != ESP_OK) {
        LOG_W(TAG, "esp_hosted_bt_controller_init failed");
    }
    if (esp_hosted_bt_controller_enable() != ESP_OK) {
        LOG_W(TAG, "esp_hosted_bt_controller_enable failed");
    }
#endif

    int rc = nimble_port_init();
    if (rc != 0) {
        LOG_E(TAG, "nimble_port_init failed (rc=%d)", rc);
        ctx->radio_state.store(BT_RADIO_STATE_OFF);
        struct BtEvent e = {};
        e.type = BT_EVENT_RADIO_STATE_CHANGED;
        e.radio_state = BT_RADIO_STATE_OFF;
        ble_publish_event(ctx->device, e);
        return;
    }

    // npl_funcs only becomes valid after nimble_port_init() above; must (re-)init
    // the giveup event each enable cycle, not once at device-start time.
    ble_npl_event_init(&ctx->giveup_event, giveup_event_cb, ctx);

    ble_hs_cfg.sync_cb         = on_sync;
    ble_hs_cfg.reset_cb        = on_reset;
    ble_hs_cfg.store_status_cb = ble_store_util_status_rr;

    ble_hs_cfg.sm_io_cap         = BLE_SM_IO_CAP_NO_IO;
    ble_hs_cfg.sm_bonding        = 1;
    ble_hs_cfg.sm_mitm           = 1;
    ble_hs_cfg.sm_sc             = 1;
    ble_hs_cfg.sm_our_key_dist   = BLE_SM_PAIR_KEY_DIST_ENC | BLE_SM_PAIR_KEY_DIST_ID;
    ble_hs_cfg.sm_their_key_dist = BLE_SM_PAIR_KEY_DIST_ENC | BLE_SM_PAIR_KEY_DIST_ID;

    ble_store_config_init();
    ble_hs_cfg.store_read_cb   = ble_store_config_read;
    ble_hs_cfg.store_write_cb  = ble_store_config_write;
    ble_hs_cfg.store_delete_cb = ble_store_config_delete;

    // Clear any stale "value_changed" flags left in NVS CCCD records.
    // Old firmware called ble_svc_gatt_changed(0, 0xFFFF) from switchGattProfile(),
    // which sets value_changed=1 in NVS for all bonded-but-disconnected peers.
    // On reconnect NimBLE sends a Service Changed indication; Windows disconnects
    // without ACKing (it re-discovers instead), so NimBLE never clears the flag,
    // causing an infinite reconnect loop. Purge all value_changed flags on every
    // enable so the first reconnect after re-enable always succeeds.
    {
        // Zero-initialize: peer_addr={0} matches any peer (== BLE_ADDR_ANY),
        // chr_val_handle=0 matches any handle — both are wildcards per the
        // ble_store_config_find_cccd() implementation.
        struct ble_store_key_cccd cccd_key = {};
        struct ble_store_value_cccd cccd_val = {};
        int n_cleared = 0;
        while (ble_store_read_cccd(&cccd_key, &cccd_val) == 0) {
            if (cccd_val.value_changed) {
                cccd_val.value_changed = 0;
                ble_store_write_cccd(&cccd_val);
                n_cleared++;
            }
            cccd_key.idx++;
        }
        if (n_cleared > 0) {
            LOG_I(TAG, "Cleared %d stale CCCD value_changed flag(s) from NVS", n_cleared);
        }
    }

    ble_svc_gap_init();
    ble_svc_gatt_init();

    // Register base GATT services (NUS + MIDI; HID added by switch_profile when started)
    ble_hid_init_gatt();

    ble_svc_gap_device_name_set(ctx->device_name);
    ble_att_set_preferred_mtu(BLE_ATT_MTU_MAX);

#if defined(CONFIG_ESP_HOSTED_ENABLED)
    ble_hci_gate_set_active(true);  // Open gate: NimBLE transport pool is ready
#endif
    // Drain any stale "done" signal left over from a previous cycle (e.g. if the
    // last dispatch_disable() timed out waiting on it — see xSemaphoreTake below
    // with pdMS_TO_TICKS(2000)) so dispatch_disable() only ever consumes the
    // completion signal for THIS host task instance.
    if (ctx->host_task_done_sem != nullptr) {
        xSemaphoreTake(ctx->host_task_done_sem, 0);
    }
    // Start NimBLE host task (on_sync will fire when ready)
    nimble_port_freertos_init(host_task);
}

static void dispatch_disable(BleCtx* ctx) {
    LOG_I(TAG, "dispatch_disable()");

    if (ctx->radio_state.load() == BT_RADIO_STATE_OFF) {
        LOG_W(TAG, "Already off");
        return;
    }

    ctx->radio_state.store(BT_RADIO_STATE_OFF_PENDING);
    {
        struct BtEvent e = {};
        e.type = BT_EVENT_RADIO_STATE_CHANGED;
        e.radio_state = BT_RADIO_STATE_OFF_PENDING;
        ble_publish_event(ctx->device, e);
    }

    // Blocking: waits for nimble_port_run() to exit.
    // Do NOT call ble_gap_adv_stop()/disc_cancel() before — if controller is
    // unresponsive they generate more HCI timeouts before the stop takes effect.
    // The HCI gate must stay OPEN here: nimble_port_stop() → ble_hs_stop() sends
    // HCI commands and needs to receive the command-complete events back from the
    // controller. Closing the gate before this point starves NimBLE of those events,
    // causing HCI timeouts and a cascade of resets that crash in ble_hs_timer_sched.
    nimble_port_stop();
    // nimble_port_stop()'s internal semaphore only confirms the stop sentinel event was
    // serviced — not that host_task()'s call to nimble_port_run() has returned. A reset
    // event (e.g. from an independent HCI-ack-wait timeout) can still be running on the
    // host task in that gap. Wait for host_task() to explicitly signal completion before
    // freeing anything NimBLE still references (ble_hs_timer et al in nimble_port_deinit()),
    // otherwise ble_hs_timer_sched() on the host task can dereference a freed callout —
    // see host_task() for the crash this fixes.
    if (ctx->host_task_done_sem != nullptr) {
        if (xSemaphoreTake(ctx->host_task_done_sem, pdMS_TO_TICKS(2000)) != pdTRUE) {
            LOG_W(TAG, "host task did not signal completion in time");
        }
    }
#if defined(CONFIG_ESP_HOSTED_ENABLED)
    // Close the gate NOW — after nimble_port_stop() returns the NimBLE host task has
    // exited and nimble_port_deinit() is about to zero npl_funcs. Any HCI packet
    // arriving after this point must not reach ble_transport_alloc_evt().
    ble_hci_gate_set_active(false);
    if (!ble_hci_gate_wait_idle(20)) {
        LOG_W(TAG, "HCI gate drain timed out");
    }
#endif
    nimble_port_deinit();

#if defined(CONFIG_ESP_HOSTED_ENABLED)
    // Symmetric with the enable-side esp_hosted_bt_controller_init/enable() calls.
    if (esp_hosted_bt_controller_disable() != ESP_OK) {
        LOG_W(TAG, "esp_hosted_bt_controller_disable failed");
    }
    if (esp_hosted_bt_controller_deinit(true) != ESP_OK) {
        LOG_W(TAG, "esp_hosted_bt_controller_deinit failed");
    }
#endif

    ctx->spp_conn_handle.store(BLE_HS_CONN_HANDLE_NONE);
    ctx->spp_active.store(false);
    ctx->midi_conn_handle.store(BLE_HS_CONN_HANDLE_NONE);
    ctx->midi_active.store(false);
    ctx->hid_active.store(false);
    ctx->link_encrypted.store(false);
    ctx->pending_reset_count.store(0);
    current_hid_profile    = BleHidProfile::None;
    active_hid_rpt_map     = nullptr;
    active_hid_rpt_map_len = 0;

    // Clear hid_conn_handle in the child device driver data if still alive.
    ble_hid_set_conn_handle(ctx->hid_device_child, BLE_HS_CONN_HANDLE_NONE);

    if (ctx->midi_keepalive_timer != nullptr) {
        esp_timer_stop(ctx->midi_keepalive_timer);
        esp_timer_delete(ctx->midi_keepalive_timer);
        ctx->midi_keepalive_timer = nullptr;
    }
    if (ctx->adv_restart_timer != nullptr) {
        esp_timer_stop(ctx->adv_restart_timer);
        esp_timer_delete(ctx->adv_restart_timer);
        ctx->adv_restart_timer = nullptr;
    }

    ctx->radio_state.store(BT_RADIO_STATE_OFF);
    {
        struct BtEvent e = {};
        e.type = BT_EVENT_RADIO_STATE_CHANGED;
        e.radio_state = BT_RADIO_STATE_OFF;
        ble_publish_event(ctx->device, e);
    }
}

// ---- BluetoothApi implementations ----

static error_t api_get_radio_state(struct Device* device, enum BtRadioState* state) {
    BleCtx* ctx = (BleCtx*)device_get_driver_data(device);
    if (!ctx || !state) return ERROR_INVALID_ARGUMENT;
    *state = ctx->radio_state.load();
    return ERROR_NONE;
}

static error_t api_set_radio_enabled(struct Device* device, bool enabled) {
    BleCtx* ctx = (BleCtx*)device_get_driver_data(device);
    if (!ctx) return ERROR_INVALID_STATE;
    xSemaphoreTake(ctx->radio_mutex, portMAX_DELAY);
    if (enabled) {
        dispatch_enable(ctx);
    } else {
        dispatch_disable(ctx);
    }
    xSemaphoreGive(ctx->radio_mutex);
    return ERROR_NONE;
}

static error_t api_scan_start(struct Device* device) {
    BleCtx* ctx = (BleCtx*)device_get_driver_data(device);
    if (!ctx) return ERROR_INVALID_STATE;

    if (ctx->radio_state.load() != BT_RADIO_STATE_ON) {
        LOG_W(TAG, "scan_start: radio not on");
        return ERROR_INVALID_STATE;
    }
    if (ctx->scan_active.load()) {
        LOG_W(TAG, "scan_start: already scanning");
        return ERROR_INVALID_STATE;
    }

    ble_scan_clear_results(device);

    struct ble_gap_disc_params disc_params = {};
    disc_params.passive         = 0;
    disc_params.filter_duplicates = 1;

    uint8_t own_addr_type;
    ble_hs_id_infer_auto(0, &own_addr_type);

    int rc = ble_gap_disc(own_addr_type, 5000, &disc_params, ble_gap_disc_event_handler, device);
    if (rc != 0 && rc != BLE_HS_EALREADY) {
        LOG_E(TAG, "ble_gap_disc failed (rc=%d)", rc);
        return ERROR_UNDEFINED;
    }

    ctx->scan_active.store(true);
    {
        struct BtEvent e = {};
        e.type = BT_EVENT_SCAN_STARTED;
        ble_publish_event(device, e);
    }
    return ERROR_NONE;
}

static error_t api_scan_stop(struct Device* device) {
    BleCtx* ctx = (BleCtx*)device_get_driver_data(device);
    if (!ctx) return ERROR_INVALID_STATE;
    ble_gap_disc_cancel();
    ctx->scan_active.store(false);
    struct BtEvent e = {};
    e.type = BT_EVENT_SCAN_FINISHED;
    ble_publish_event(device, e);
    return ERROR_NONE;
}

static bool api_is_scanning(struct Device* device) {
    BleCtx* ctx = (BleCtx*)device_get_driver_data(device);
    return ctx != nullptr && ctx->scan_active.load();
}

static error_t api_pair(struct Device* /*device*/, const BtAddr /*addr*/) {
    // Pairing handled automatically by NimBLE SM during connection.
    // ENC_CHANGE fires when encryption is established (PAIR_RESULT event).
    return ERROR_NONE;
}

static error_t api_unpair(struct Device* device, const BtAddr addr) {
    // Remove from NimBLE bond store. Settings file removal is handled by
    // the Tactility bluetooth::unpair() wrapper which calls settings::remove().
    ble_addr_t ble_addr = {};
    ble_addr.type = BLE_ADDR_PUBLIC;
    memcpy(ble_addr.val, addr, BT_ADDR_LEN);
    ble_store_util_delete_peer(&ble_addr);
    return ERROR_NONE;
}

static error_t api_get_paired_peers(struct Device* /*device*/, struct BtPeerRecord* /*out*/, size_t* /*count*/) {
    // Paired peer enumeration reads filesystem — handled in Tactility layer.
    return ERROR_NOT_SUPPORTED;
}

static error_t api_connect(struct Device* device, const BtAddr addr, enum BtProfileId profile) {
    BleCtx* ctx = (BleCtx*)device_get_driver_data(device);
    if (!ctx) return ERROR_INVALID_STATE;
    if (profile == BT_PROFILE_HID_DEVICE) {
        return nimble_hid_device_api.start(ctx->hid_device_child, BT_HID_DEVICE_MODE_KEYBOARD);
    } else if (profile == BT_PROFILE_SPP) {
        return ble_spp_start_internal(ctx->serial_child);
    } else if (profile == BT_PROFILE_MIDI) {
        return ble_midi_start_internal(ctx->midi_child);
    }
    // BT_PROFILE_HID_HOST is handled entirely in the Tactility layer.
    return ERROR_NOT_SUPPORTED;
}

static error_t api_disconnect(struct Device* device, const BtAddr addr, enum BtProfileId profile) {
    BleCtx* ctx = (BleCtx*)device_get_driver_data(device);
    if (!ctx) return ERROR_INVALID_STATE;
    if (profile == BT_PROFILE_HID_DEVICE) {
        return nimble_hid_device_api.stop(ctx->hid_device_child);
    } else if (profile == BT_PROFILE_SPP) {
        return nimble_serial_api.stop(ctx->serial_child);
    } else if (profile == BT_PROFILE_MIDI) {
        return nimble_midi_api.stop(ctx->midi_child);
    }
    return ERROR_NOT_SUPPORTED;
}

static error_t api_add_event_callback(struct Device* device, void* cb_ctx, BtEventCallback fn) {
    BleCtx* ctx = (BleCtx*)device_get_driver_data(device);
    if (!ctx || !fn) return ERROR_INVALID_ARGUMENT;
    xSemaphoreTake(ctx->cb_mutex, portMAX_DELAY);
    if (ctx->callback_count >= BLE_MAX_CALLBACKS) {
        xSemaphoreGive(ctx->cb_mutex);
        return ERROR_OUT_OF_MEMORY;
    }
    ctx->callbacks[ctx->callback_count].fn  = fn;
    ctx->callbacks[ctx->callback_count].ctx = cb_ctx;
    ctx->callback_count++;
    xSemaphoreGive(ctx->cb_mutex);
    return ERROR_NONE;
}

static error_t api_remove_event_callback(struct Device* device, BtEventCallback fn) {
    BleCtx* ctx = (BleCtx*)device_get_driver_data(device);
    if (!ctx || !fn) return ERROR_INVALID_ARGUMENT;
    xSemaphoreTake(ctx->cb_mutex, portMAX_DELAY);
    for (size_t i = 0; i < ctx->callback_count; i++) {
        if (ctx->callbacks[i].fn == fn) {
            // Shift remaining entries down
            for (size_t j = i; j + 1 < ctx->callback_count; j++) {
                ctx->callbacks[j] = ctx->callbacks[j + 1];
            }
            ctx->callback_count--;
            xSemaphoreGive(ctx->cb_mutex);
            return ERROR_NONE;
        }
    }
    xSemaphoreGive(ctx->cb_mutex);
    return ERROR_NOT_FOUND;
}

static error_t api_set_device_name(struct Device* device, const char* name) {
    BleCtx* ctx = (BleCtx*)device_get_driver_data(device);
    if (!ctx || !name) return ERROR_INVALID_ARGUMENT;
    if (strlen(name) > BLE_DEVICE_NAME_MAX) return ERROR_INVALID_ARGUMENT;
    xSemaphoreTake(ctx->radio_mutex, portMAX_DELAY);
    strncpy(ctx->device_name, name, BLE_DEVICE_NAME_MAX);
    ctx->device_name[BLE_DEVICE_NAME_MAX] = '\0';
    if (ctx->radio_state.load() == BT_RADIO_STATE_ON) {
        ble_svc_gap_device_name_set(ctx->device_name);
        // Restart advertising so the new name is broadcast immediately.
        // ble_schedule_adv_restart checks active profiles; no-op if nothing is advertising.
        ble_schedule_adv_restart(device, 0);
    }
    xSemaphoreGive(ctx->radio_mutex);
    return ERROR_NONE;
}

static error_t api_get_device_name(struct Device* device, char* buf, size_t buf_len) {
    BleCtx* ctx = (BleCtx*)device_get_driver_data(device);
    if (!ctx || !buf || buf_len == 0) return ERROR_INVALID_ARGUMENT;
    xSemaphoreTake(ctx->radio_mutex, portMAX_DELAY);
    strncpy(buf, ctx->device_name, buf_len - 1);
    buf[buf_len - 1] = '\0';
    xSemaphoreGive(ctx->radio_mutex);
    return ERROR_NONE;
}

static void api_set_hid_host_active(struct Device* device, bool active) {
    BleCtx* ctx = (BleCtx*)device_get_driver_data(device);
    if (ctx) ctx->hid_host_active.store(active);
}

static void api_fire_event(struct Device* device, struct BtEvent event) {
    BleCtx* ctx = (BleCtx*)device_get_driver_data(device);
    if (ctx) ble_publish_event(device, event);
}

// ---- BluetoothApi struct ----

const BluetoothApi nimble_bluetooth_api = {
    .get_radio_state        = api_get_radio_state,
    .set_radio_enabled      = api_set_radio_enabled,
    .scan_start             = api_scan_start,
    .scan_stop              = api_scan_stop,
    .is_scanning            = api_is_scanning,
    .pair                   = api_pair,
    .unpair                 = api_unpair,
    .get_paired_peers       = api_get_paired_peers,
    .connect                = api_connect,
    .disconnect             = api_disconnect,
    .add_event_callback     = api_add_event_callback,
    .remove_event_callback  = api_remove_event_callback,
    .set_device_name        = api_set_device_name,
    .get_device_name        = api_get_device_name,
    .set_hid_host_active    = api_set_hid_host_active,
    .fire_event             = api_fire_event,
};

// ---- Driver definitions ----
// Child driver structs (esp32_ble_serial_driver, esp32_ble_midi_driver,
// esp32_ble_hid_device_driver) are defined in their respective sub-module files
// and declared extern in esp32_ble_internal.h.

// ---- Driver lifecycle ----

static void create_child_device(struct Device* parent, const char* name,
                                Driver* drv, struct Device*& out) {
    out = new Device { .address = 0, .name = name, .config = nullptr, .parent = nullptr, .internal = nullptr };
    device_construct(out);
    device_set_parent(out, parent);
    device_set_driver(out, drv);
    device_add(out);
    device_start(out);
}

static void destroy_child_device(struct Device*& child) {
    if (child == nullptr) return;
    device_stop(child);
    device_remove(child);
    device_destruct(child);
    delete child;
    child = nullptr;
}

static error_t esp32_ble_start_device(struct Device* device) {
    BleCtx* ctx = new BleCtx();
    ctx->radio_mutex = xSemaphoreCreateMutex();
    ctx->cb_mutex    = xSemaphoreCreateMutex();
    ctx->radio_state.store(BT_RADIO_STATE_OFF);
    ctx->scan_active.store(false);
    ctx->hid_host_active.store(false);
    ctx->callback_count = 0;
    ctx->spp_conn_handle.store(BLE_HS_CONN_HANDLE_NONE);
    ctx->spp_active.store(false);
    ctx->midi_conn_handle.store(BLE_HS_CONN_HANDLE_NONE);
    ctx->midi_active.store(false);
    ctx->midi_use_indicate.store(false);
    ctx->hid_active.store(false);
    ctx->link_encrypted.store(false);
    ctx->pending_reset_count.store(0);
    ctx->midi_keepalive_timer = nullptr;
    ctx->adv_restart_timer    = nullptr;
    ctx->disable_timer        = nullptr;
    // Device name: prefer the Kconfig-supplied TT_DEVICE_NAME, fall back to "Tactility"
    const char* cfg_name = CONFIG_TT_DEVICE_NAME;
    if (cfg_name == nullptr || cfg_name[0] == '\0') cfg_name = "Tactility";
    strncpy(ctx->device_name, cfg_name, BLE_DEVICE_NAME_MAX);
    ctx->device_name[BLE_DEVICE_NAME_MAX] = '\0';
    ctx->device               = device;
    ctx->serial_child         = nullptr;
    ctx->midi_child           = nullptr;
    ctx->hid_device_child     = nullptr;
    ctx->scan_mutex           = xSemaphoreCreateMutex();
    ctx->scan_count           = 0;
    memset(ctx->scan_results, 0, sizeof(ctx->scan_results));
    ctx->host_task_done_sem   = xSemaphoreCreateBinary();
    if (ctx->host_task_done_sem == nullptr) {
        LOG_E(TAG, "start_device: host_task_done_sem create failed");
    }

    // Create the disable timer used to dispatch dispatchDisable off the NimBLE host task.
    esp_timer_create_args_t disable_args = {};
    disable_args.callback        = dispatch_disable_timer_cb;
    disable_args.arg             = ctx;
    disable_args.dispatch_method = ESP_TIMER_TASK;
    disable_args.name            = "ble_disable";
    int rc = esp_timer_create(&disable_args, &ctx->disable_timer);
    if (rc != ESP_OK) {
        LOG_E(TAG, "start_device: disable timer create failed (rc=%d)", rc);
    }

    device_set_driver_data(device, ctx);
    s_ctx = ctx;

    // Create child devices for the serial, MIDI and HID device profiles.
    // device_start() on each child will invoke start_device (for serial/midi)
    // which initialises their driver data (BleSppCtx / BleMidiCtx).
    create_child_device(device, "ble_serial",     &esp32_ble_serial_driver,     ctx->serial_child);
    create_child_device(device, "ble_midi",       &esp32_ble_midi_driver,       ctx->midi_child);
    create_child_device(device, "ble_hid_device", &esp32_ble_hid_device_driver, ctx->hid_device_child);

    return ERROR_NONE;
}

static error_t esp32_ble_stop_device(struct Device* device) {
    BleCtx* ctx = (BleCtx*)device_get_driver_data(device);
    if (!ctx) return ERROR_NONE;

    // Destroy child devices before stopping the radio and freeing the context.
    // device_stop() on each child will invoke stop_device (for serial/midi/hid)
    // which frees their driver data.
    destroy_child_device(ctx->hid_device_child);
    destroy_child_device(ctx->midi_child);
    destroy_child_device(ctx->serial_child);

    if (ctx->radio_state.load() != BT_RADIO_STATE_OFF) {
        xSemaphoreTake(ctx->radio_mutex, portMAX_DELAY);
        dispatch_disable(ctx);
        xSemaphoreGive(ctx->radio_mutex);
    }

    if (ctx->scan_mutex != nullptr) {
        vSemaphoreDelete(ctx->scan_mutex);
        ctx->scan_mutex = nullptr;
    }

    if (ctx->host_task_done_sem != nullptr) {
        vSemaphoreDelete(ctx->host_task_done_sem);
        ctx->host_task_done_sem = nullptr;
    }

    if (ctx->disable_timer != nullptr) {
        esp_timer_stop(ctx->disable_timer);
        esp_timer_delete(ctx->disable_timer);
        ctx->disable_timer = nullptr;
    }

    s_ctx = nullptr;
    device_set_driver_data(device, nullptr);
    delete ctx;
    return ERROR_NONE;
}

// ---- Driver registration ----

Driver esp32_bluetooth_driver = {
    .name         = "esp32_bluetooth",
    .compatible   = (const char*[]) { "espressif,esp32-ble", nullptr },
    .start_device = esp32_ble_start_device,
    .stop_device  = esp32_ble_stop_device,
    .api          = &nimble_bluetooth_api,
    .device_type  = &BLUETOOTH_TYPE,
    .owner        = nullptr,
    .internal     = nullptr,
};

#endif // CONFIG_BT_NIMBLE_ENABLED
