Skip to main content

priv/native/ios/mob_bluetooth_nif.m

/* mob_bluetooth_nif — iOS BLE (CoreBluetooth) tier-1 plugin NIF (Objective-C).
 *
 * iOS does NOT expose Bluetooth Classic (the bt_* surface — discovery, pairing,
 * HFP/SPP — is Android-only; see lib/mob_bluetooth/platform.ex). What iOS
 * *does* offer is BLE via CoreBluetooth, a separate, parallel surface. This NIF
 * implements the iOS-only `ble_*` functions:
 *
 *   ble_scan/1           — CBCentralManager scan for nearby BLE peripherals,
 *                          filtered by a list of service-UUID binaries ([] =
 *                          unfiltered; a filter is required for background
 * scan) ble_stop_scan/0      — stop the scan ble_advertise/1      —
 * CBPeripheralManager advertise a local name (the BLE analog of Android's
 * make_discoverable) ble_stop_advertise/0 — stop advertising
 *
 * Messages match the existing `:bt` device-event family (2-tuples for plain
 * events, 3-tuples when there's a payload — same as the Android discovery
 * deliveries):
 *
 *   {:bt, :ble_scan_started}
 *   {:bt, :ble_device, %{id: <uuid>, name: <name|nil>, rssi: <int>}}
 *   {:bt, :ble_scan_stopped}
 *   {:bt, :ble_advertising}
 *   {:bt, :ble_advertise_stopped}
 *   {:bt, :error, %{reason: <atom>}}   (powered_off / unauthorized /
 *                                       unsupported / resetting / ...)
 *
 * Self-contained (core's send helpers are private statics). Compiled as ObjC
 * (-fobjc-arc) via the plugin objc-NIF path (manifest lang: :objc, platform:
 * :ios). The NIF is statically linked into the host binary on device.
 *
 * NOTE: CoreBluetooth needs a real radio — the iOS Simulator reports
 * CBManagerStateUnsupported, so this is only meaningfully exercised on a
 * physical device.
 */
#import <CoreBluetooth/CoreBluetooth.h>
#import <Foundation/Foundation.h>
#include <erl_nif.h>
#include <string.h>

// ── BEAM delivery helpers ─────────────────────────────────────────────────
// Each message is built in its own freshly-allocated env and sent with a NULL
// caller env (thread-safe from the CoreBluetooth dispatch queue).

// {:bt, <tag>} — a plain 2-tuple device event.
static void ble_send_simple(const ErlNifPid *pid, const char *tag) {
  ErlNifEnv *e = enif_alloc_env();
  ERL_NIF_TERM msg =
      enif_make_tuple2(e, enif_make_atom(e, "bt"), enif_make_atom(e, tag));
  enif_send(NULL, (ErlNifPid *)pid, e, msg);
  enif_free_env(e);
}

// {:bt, :error, %{reason: <atom>}}
static void ble_send_error(const ErlNifPid *pid, const char *reason) {
  ErlNifEnv *e = enif_alloc_env();
  ERL_NIF_TERM map = enif_make_new_map(e);
  enif_make_map_put(e, map, enif_make_atom(e, "reason"),
                    enif_make_atom(e, reason), &map);
  ERL_NIF_TERM msg = enif_make_tuple3(e, enif_make_atom(e, "bt"),
                                      enif_make_atom(e, "error"), map);
  enif_send(NULL, (ErlNifPid *)pid, e, msg);
  enif_free_env(e);
}

static ERL_NIF_TERM ble_nsstr_binary(ErlNifEnv *e, NSString *s) {
  const char *u = [s UTF8String];
  size_t len = u ? strlen(u) : 0;
  ERL_NIF_TERM bin;
  unsigned char *buf = enif_make_new_binary(e, len, &bin);
  if (len)
    memcpy(buf, u, len);
  return bin;
}

static void ble_send_device(const ErlNifPid *pid, NSString *uuid,
                            NSString *name, int rssi) {
  ErlNifEnv *e = enif_alloc_env();
  ERL_NIF_TERM map = enif_make_new_map(e);
  enif_make_map_put(e, map, enif_make_atom(e, "id"),
                    ble_nsstr_binary(e, uuid ?: @""), &map);
  ERL_NIF_TERM name_term =
      name ? ble_nsstr_binary(e, name) : enif_make_atom(e, "nil");
  enif_make_map_put(e, map, enif_make_atom(e, "name"), name_term, &map);
  enif_make_map_put(e, map, enif_make_atom(e, "rssi"), enif_make_int(e, rssi),
                    &map);
  ERL_NIF_TERM msg = enif_make_tuple3(e, enif_make_atom(e, "bt"),
                                      enif_make_atom(e, "ble_device"), map);
  enif_send(NULL, (ErlNifPid *)pid, e, msg);
  enif_free_env(e);
}

static const char *ble_state_reason(CBManagerState s) {
  switch (s) {
  case CBManagerStatePoweredOff:
    return "powered_off";
  case CBManagerStateUnauthorized:
    return "unauthorized";
  case CBManagerStateUnsupported:
    return "unsupported";
  case CBManagerStateResetting:
    return "resetting";
  default:
    return "unavailable";
  }
}

// ── CoreBluetooth delegate + state ────────────────────────────────────────

@interface MobBle
    : NSObject <CBCentralManagerDelegate, CBPeripheralManagerDelegate>
@property(nonatomic, strong) dispatch_queue_t q;
@property(nonatomic, strong) CBCentralManager *central;
@property(nonatomic, strong) CBPeripheralManager *peripheral;
@property(nonatomic, assign) BOOL wantScan;
@property(nonatomic, assign) BOOL wantAdvertise;
@property(nonatomic, copy) NSString *advName;
@property(nonatomic, assign) ErlNifPid scanPid;
@property(nonatomic, assign) BOOL haveScanPid;
@property(nonatomic, assign) ErlNifPid advPid;
@property(nonatomic, assign) BOOL haveAdvPid;
// CBUUIDs to filter the scan by (nil = scan for everything). iOS REQUIRES an
// explicit service-UUID filter for background scanning — `nil` is silently
// dropped when the app is backgrounded.
@property(nonatomic, strong) NSArray *scanServiceUUIDs;
- (void)startScanIfReady;
- (void)startAdvertiseIfReady;
@end

// Globally retained so the delegate (held weakly by the CB managers) outlives
// every NIF call. Created once on first use.
static MobBle *g_ble = nil;

static MobBle *ble_get(void) {
  static dispatch_once_t once;
  dispatch_once(&once, ^{
    g_ble = [MobBle new];
    g_ble.q = dispatch_queue_create("ca.mob.ble", DISPATCH_QUEUE_SERIAL);
  });
  return g_ble;
}

@implementation MobBle

- (void)startScanIfReady {
  if (self.wantScan && self.central.state == CBManagerStatePoweredOn) {
    // self.scanServiceUUIDs is nil for an unfiltered (foreground) scan, or the
    // caller's CBUUID list — required for background scanning.
    [self.central scanForPeripheralsWithServices:self.scanServiceUUIDs
                                         options:nil];
    if (self.haveScanPid)
      ble_send_simple(&self->_scanPid, "ble_scan_started");
  }
}

- (void)startAdvertiseIfReady {
  if (self.wantAdvertise && self.peripheral.state == CBManagerStatePoweredOn) {
    NSDictionary *data =
        self.advName ? @{CBAdvertisementDataLocalNameKey : self.advName} : @{};
    [self.peripheral startAdvertising:data];
  }
}

// ── Central (scan) ──
- (void)centralManagerDidUpdateState:(CBCentralManager *)central {
  if (central.state == CBManagerStatePoweredOn) {
    [self startScanIfReady];
  } else if (self.wantScan && self.haveScanPid) {
    ble_send_error(&self->_scanPid, ble_state_reason(central.state));
  }
}

- (void)centralManager:(CBCentralManager *)central
    didDiscoverPeripheral:(CBPeripheral *)peripheral
        advertisementData:(NSDictionary<NSString *, id> *)advertisementData
                     RSSI:(NSNumber *)RSSI {
  if (!self.haveScanPid)
    return;
  NSString *name = peripheral.name;
  if (!name)
    name = advertisementData[CBAdvertisementDataLocalNameKey];
  ble_send_device(&self->_scanPid, peripheral.identifier.UUIDString, name,
                  RSSI ? RSSI.intValue : 0);
}

// ── Peripheral (advertise) ──
- (void)peripheralManagerDidUpdateState:(CBPeripheralManager *)peripheral {
  if (peripheral.state == CBManagerStatePoweredOn) {
    [self startAdvertiseIfReady];
  } else if (self.wantAdvertise && self.haveAdvPid) {
    ble_send_error(&self->_advPid, ble_state_reason(peripheral.state));
  }
}

- (void)peripheralManagerDidStartAdvertising:(CBPeripheralManager *)peripheral
                                       error:(NSError *)error {
  if (!self.haveAdvPid)
    return;
  if (error)
    ble_send_error(&self->_advPid, "advertise_failed");
  else
    ble_send_simple(&self->_advPid, "ble_advertising");
}

@end

// ── NIFs ──────────────────────────────────────────────────────────────────

// ble_scan(ServiceUUIDs) — ServiceUUIDs is a (possibly empty) list of UUID
// binaries. Empty list scans for everything (foreground only); a non-empty
// filter is what makes background scanning work (iOS drops a nil filter when
// backgrounded). Unparseable UUID strings are skipped.
static ERL_NIF_TERM nif_ble_scan(ErlNifEnv *env, int argc,
                                 const ERL_NIF_TERM argv[]) {
  (void)argc;
  NSMutableArray *uuids = [NSMutableArray array];
  ERL_NIF_TERM head, tail = argv[0];
  while (enif_get_list_cell(env, tail, &head, &tail)) {
    ErlNifBinary bin;
    if (enif_inspect_binary(env, head, &bin) ||
        enif_inspect_iolist_as_binary(env, head, &bin)) {
      NSString *s = [[NSString alloc] initWithBytes:bin.data
                                             length:bin.size
                                           encoding:NSUTF8StringEncoding];
      @try {
        if (s)
          [uuids addObject:[CBUUID UUIDWithString:s]];
      } @catch (NSException *e) {
        // Skip an invalid UUID string rather than crash the scan.
      }
    }
  }
  NSArray *filter = uuids.count ? [uuids copy] : nil;

  MobBle *b = ble_get();
  ErlNifPid pid;
  enif_self(env, &pid);
  dispatch_async(b.q, ^{
    b.scanPid = pid;
    b.haveScanPid = YES;
    b.wantScan = YES;
    b.scanServiceUUIDs = filter;
    if (!b.central)
      b.central = [[CBCentralManager alloc] initWithDelegate:b queue:b.q];
    [b startScanIfReady];
  });
  return enif_make_atom(env, "ok");
}

static ERL_NIF_TERM nif_ble_stop_scan(ErlNifEnv *env, int argc,
                                      const ERL_NIF_TERM argv[]) {
  (void)argc;
  (void)argv;
  MobBle *b = ble_get();
  ErlNifPid pid;
  enif_self(env, &pid);
  dispatch_async(b.q, ^{
    b.wantScan = NO;
    if (b.central && b.central.state == CBManagerStatePoweredOn)
      [b.central stopScan];
    ble_send_simple(&pid, "ble_scan_stopped");
  });
  return enif_make_atom(env, "ok");
}

static ERL_NIF_TERM nif_ble_advertise(ErlNifEnv *env, int argc,
                                      const ERL_NIF_TERM argv[]) {
  (void)argc;
  ErlNifBinary bin;
  if (!enif_inspect_binary(env, argv[0], &bin) &&
      !enif_inspect_iolist_as_binary(env, argv[0], &bin))
    return enif_make_badarg(env);
  NSString *name = [[NSString alloc] initWithBytes:bin.data
                                            length:bin.size
                                          encoding:NSUTF8StringEncoding];
  MobBle *b = ble_get();
  ErlNifPid pid;
  enif_self(env, &pid);
  dispatch_async(b.q, ^{
    b.advPid = pid;
    b.haveAdvPid = YES;
    b.advName = name;
    b.wantAdvertise = YES;
    if (!b.peripheral)
      b.peripheral = [[CBPeripheralManager alloc] initWithDelegate:b
                                                             queue:b.q
                                                           options:nil];
    [b startAdvertiseIfReady];
  });
  return enif_make_atom(env, "ok");
}

static ERL_NIF_TERM nif_ble_stop_advertise(ErlNifEnv *env, int argc,
                                           const ERL_NIF_TERM argv[]) {
  (void)argc;
  (void)argv;
  MobBle *b = ble_get();
  ErlNifPid pid;
  enif_self(env, &pid);
  dispatch_async(b.q, ^{
    b.wantAdvertise = NO;
    if (b.peripheral)
      [b.peripheral stopAdvertising];
    ble_send_simple(&pid, "ble_advertise_stopped");
  });
  return enif_make_atom(env, "ok");
}

// ═══════════════════════════════════════════════════════════════════════════
// BLE GATT peripheral surface (MobBluetooth.Le) — advertise a service, notify
// subscribed centrals, receive writes. Independent of the MobBle scan/advertise
// subsystem above: its own CBPeripheralManager (g_peripheral) + delegate.
// ═══════════════════════════════════════════════════════════════════════════

// ── Globals (ARC: strong refs keep these alive across NIF calls) ──────────

static CBPeripheralManager *g_peripheral = nil;

// Caller pid captured at ble_start_advertising. Last caller wins (single
// consumer — the screen GenServer that owns the peripheral).
static ErlNifPid g_ble_pid;
static BOOL g_have_pid = NO;

// The parsed service + advertise data, stashed at ble_start_advertising and
// applied once CBPeripheralManager reports CBManagerStatePoweredOn (the manager
// is async: you cannot addService / startAdvertising before then).
static CBMutableService *g_pending_service = nil;
static NSString *g_local_name = nil;
static BOOL g_want_advertise = NO;

// CBUUID(string) → CBMutableCharacteristic, for ble_notify lookup by UUID.
static NSMutableDictionary<CBUUID *, CBMutableCharacteristic *> *g_chars = nil;

// CoreBluetooth's peripheral role exposes NO raw central connect/disconnect
// callback — only subscribe/unsubscribe to a characteristic. We approximate
// connection lifecycle by assigning an incrementing int per distinct
// CBCentral.identifier on first subscribe (central_connected) and reclaiming it
// on last unsubscribe (central_disconnected). This is a per-platform
// APPROXIMATION: on Android these events come from real GATT connection-state
// changes, here they're inferred from subscription. A central that connects but
// never subscribes is invisible to us, and one subscribed to two
// characteristics counts as connected once.
static NSMutableDictionary<NSUUID *, NSNumber *> *g_centrals =
    nil; // identifier → int handle
static int g_next_central = 1;

// The delegate object (held strong so CBPeripheralManager's weak delegate
// pointer stays valid).
static id g_delegate = nil;

// Outbound notification queue. updateValue:onSubscribedCentrals: returns NO
// when CoreBluetooth's transmit queue is full (common with back-to-back
// note-on/off); the value is then dropped unless we retry. We enqueue every
// notification and drain the queue, stopping on the first NO and resuming in
// peripheralManagerIsReadyToUpdateSubscribers: — the standard CoreBluetooth
// pattern, so note-offs don't get lost (which would leave stuck notes). Each
// entry is @{@"ch": CBMutableCharacteristic, @"data": NSData}. Capped so a
// burst with no subscriber can't grow without bound. All access is on the main
// queue.
static NSMutableArray<NSDictionary *> *g_tx_queue = nil;
static const NSUInteger MOB_BLE_TX_CAP = 256;
static void ble_flush_tx(void);

// ── Delivery helpers ──────────────────────────────────────────────────────
//
// Each builds a {:bt_le, tag[, payload]} tuple in a fresh env and posts it to
// g_ble_pid. Map payloads are built with enif_make_map_from_arrays to match the
// zig side's erts.makeMap.

static void ble_send_atom2(const char *tag) {
  if (!g_have_pid)
    return;
  ErlNifEnv *e = enif_alloc_env();
  ERL_NIF_TERM msg =
      enif_make_tuple2(e, enif_make_atom(e, "bt_le"), enif_make_atom(e, tag));
  enif_send(NULL, &g_ble_pid, e, msg);
  enif_free_env(e);
}

// {:bt_le, :advertising_failed, %{reason: <atom>}}
static void ble_send_failed(const char *reason) {
  if (!g_have_pid)
    return;
  ErlNifEnv *e = enif_alloc_env();
  ERL_NIF_TERM key = enif_make_atom(e, "reason");
  ERL_NIF_TERM val = enif_make_atom(e, reason);
  ERL_NIF_TERM map;
  enif_make_map_from_arrays(e, &key, &val, 1, &map);
  ERL_NIF_TERM msg =
      enif_make_tuple3(e, enif_make_atom(e, "bt_le"),
                       enif_make_atom(e, "advertising_failed"), map);
  enif_send(NULL, &g_ble_pid, e, msg);
  enif_free_env(e);
}

// {:bt_le, <tag>, %{central: <int>}} — connected / disconnected.
static void ble_send_central(const char *tag, int central) {
  if (!g_have_pid)
    return;
  ErlNifEnv *e = enif_alloc_env();
  ERL_NIF_TERM key = enif_make_atom(e, "central");
  ERL_NIF_TERM val = enif_make_int(e, central);
  ERL_NIF_TERM map;
  enif_make_map_from_arrays(e, &key, &val, 1, &map);
  ERL_NIF_TERM msg = enif_make_tuple3(e, enif_make_atom(e, "bt_le"),
                                      enif_make_atom(e, tag), map);
  enif_send(NULL, &g_ble_pid, e, msg);
  enif_free_env(e);
}

// {:bt_le, <tag>, %{characteristic: <<uuid>>}} — subscribed / unsubscribed.
// The characteristic value is a UTF-8 BINARY of the UUID string (matches the
// zig mobBleMakeCharMap, which builds a binary from the UUID C string).
static void ble_send_char(const char *tag, NSString *uuid) {
  if (!g_have_pid)
    return;
  const char *s = uuid.UTF8String;
  size_t len = strlen(s);
  ErlNifEnv *e = enif_alloc_env();
  ERL_NIF_TERM ubin;
  unsigned char *ubuf = enif_make_new_binary(e, len, &ubin);
  memcpy(ubuf, s, len);
  ERL_NIF_TERM key = enif_make_atom(e, "characteristic");
  ERL_NIF_TERM map;
  enif_make_map_from_arrays(e, &key, &ubin, 1, &map);
  ERL_NIF_TERM msg = enif_make_tuple3(e, enif_make_atom(e, "bt_le"),
                                      enif_make_atom(e, tag), map);
  enif_send(NULL, &g_ble_pid, e, msg);
  enif_free_env(e);
}

// {:bt_le, :write, %{characteristic: <<uuid>>, bytes: <<raw>>}}.
static void ble_send_write(NSString *uuid, const uint8_t *bytes, size_t len) {
  if (!g_have_pid)
    return;
  const char *s = uuid.UTF8String;
  size_t ulen = strlen(s);
  ErlNifEnv *e = enif_alloc_env();
  ERL_NIF_TERM ubin;
  unsigned char *ubuf = enif_make_new_binary(e, ulen, &ubin);
  memcpy(ubuf, s, ulen);
  ERL_NIF_TERM bbin;
  unsigned char *bbuf = enif_make_new_binary(e, len, &bbin);
  if (len && bytes)
    memcpy(bbuf, bytes, len);
  ERL_NIF_TERM keys[2] = {enif_make_atom(e, "characteristic"),
                          enif_make_atom(e, "bytes")};
  ERL_NIF_TERM vals[2] = {ubin, bbin};
  ERL_NIF_TERM map;
  enif_make_map_from_arrays(e, keys, vals, 2, &map);
  ERL_NIF_TERM msg = enif_make_tuple3(e, enif_make_atom(e, "bt_le"),
                                      enif_make_atom(e, "write"), map);
  enif_send(NULL, &g_ble_pid, e, msg);
  enif_free_env(e);
}

// ── central-handle bookkeeping (approximation, see g_centrals note) ────────

// Assign/return the int handle for a central; emit central_connected the first
// time we see it.
static int ble_central_handle(CBCentral *central) {
  if (!g_centrals)
    g_centrals = [NSMutableDictionary dictionary];
  NSUUID *key = central.identifier;
  NSNumber *existing = g_centrals[key];
  if (existing)
    return existing.intValue;
  int handle = g_next_central++;
  g_centrals[key] = @(handle);
  ble_send_central("central_connected", handle);
  return handle;
}

// Reclaim a central's handle on unsubscribe; emit central_disconnected.
static void ble_central_drop(CBCentral *central) {
  if (!g_centrals)
    return;
  NSUUID *key = central.identifier;
  NSNumber *existing = g_centrals[key];
  if (!existing)
    return;
  [g_centrals removeObjectForKey:key];
  ble_send_central("central_disconnected", existing.intValue);
}

// ── CBPeripheralManagerDelegate ────────────────────────────────────────────

@interface MobBlePeripheralDelegate : NSObject <CBPeripheralManagerDelegate>
@end

@implementation MobBlePeripheralDelegate

// State transitions. We can only add the service + start advertising once the
// manager is PoweredOn; any earlier intent is held in g_pending_service /
// g_want_advertise and applied here. Non-PoweredOn terminal states map to an
// advertising_failed reason atom.
- (void)peripheralManagerDidUpdateState:(CBPeripheralManager *)peripheral {
  switch (peripheral.state) {
  case CBManagerStatePoweredOn:
    if (g_want_advertise && g_pending_service) {
      // addService is async; advertising is kicked off in the
      // didAddService callback so the service is registered first.
      [peripheral addService:g_pending_service];
    }
    break;
  case CBManagerStatePoweredOff:
    ble_send_failed("powered_off");
    break;
  case CBManagerStateUnauthorized:
    ble_send_failed("unauthorized");
    break;
  case CBManagerStateUnsupported:
    ble_send_failed("unsupported");
    break;
  case CBManagerStateResetting:
  case CBManagerStateUnknown:
  default:
    // Transient — wait for the next state update.
    break;
  }
}

// Service registered (or failed). On success, start advertising the service
// UUID + local name; on failure report add_service_failed.
- (void)peripheralManager:(CBPeripheralManager *)peripheral
            didAddService:(CBService *)service
                    error:(NSError *)error {
  if (error) {
    ble_send_failed("add_service_failed");
    return;
  }
  NSMutableDictionary *adv = [NSMutableDictionary dictionary];
  adv[CBAdvertisementDataServiceUUIDsKey] = @[ service.UUID ];
  if (g_local_name)
    adv[CBAdvertisementDataLocalNameKey] = g_local_name;
  [peripheral startAdvertising:adv];
}

// startAdvertising result → advertising_started / advertising_failed.
- (void)peripheralManagerDidStartAdvertising:(CBPeripheralManager *)peripheral
                                       error:(NSError *)error {
  if (error) {
    ble_send_failed("advertise_failed");
  } else {
    ble_send_atom2("advertising_started");
  }
}

// A central subscribed to one of our characteristics. Emit central_connected
// (first time we see this central) then subscribed.
- (void)peripheralManager:(CBPeripheralManager *)peripheral
                         central:(CBCentral *)central
    didSubscribeToCharacteristic:(CBCharacteristic *)characteristic {
  ble_central_handle(central);
  ble_send_char("subscribed", characteristic.UUID.UUIDString);
}

// A central unsubscribed. Emit unsubscribed then central_disconnected (we treat
// an unsubscribe as the central leaving — see g_centrals approximation note).
- (void)peripheralManager:(CBPeripheralManager *)peripheral
                             central:(CBCentral *)central
    didUnsubscribeFromCharacteristic:(CBCharacteristic *)characteristic {
  ble_send_char("unsubscribed", characteristic.UUID.UUIDString);
  ble_central_drop(central);
}

// Incoming writes. CoreBluetooth batches multiple ATT write requests and
// requires EXACTLY ONE respondToRequest: per batch (responding to the first
// request acknowledges the whole batch). BLE-MIDI uses write-without-response,
// so request.value may be nil/empty — handle that safely.
- (void)peripheralManager:(CBPeripheralManager *)peripheral
    didReceiveWriteRequests:(NSArray<CBATTRequest *> *)requests {
  for (CBATTRequest *req in requests) {
    NSData *data = req.value;
    const uint8_t *bytes = data ? (const uint8_t *)data.bytes : NULL;
    size_t len = data ? (size_t)data.length : 0;
    ble_send_write(req.characteristic.UUID.UUIDString, bytes, len);
  }
  if (requests.count > 0) {
    [peripheral respondToRequest:requests[0] withResult:CBATTErrorSuccess];
  }
}

// CoreBluetooth's transmit queue has space again — drain any notifications that
// got a NO from updateValue: (e.g. a note-off right after its note-on).
- (void)peripheralManagerIsReadyToUpdateSubscribers:
    (CBPeripheralManager *)peripheral {
  ble_flush_tx();
}

@end

// ── JSON → CoreBluetooth mapping ───────────────────────────────────────────

static CBCharacteristicProperties ble_props_from_array(NSArray *props) {
  CBCharacteristicProperties out = 0;
  for (id p in props) {
    if (![p isKindOfClass:[NSString class]])
      continue;
    NSString *s = (NSString *)p;
    if ([s isEqualToString:@"read"])
      out |= CBCharacteristicPropertyRead;
    else if ([s isEqualToString:@"write"])
      out |= CBCharacteristicPropertyWrite;
    else if ([s isEqualToString:@"write_without_response"])
      out |= CBCharacteristicPropertyWriteWithoutResponse;
    else if ([s isEqualToString:@"notify"])
      out |= CBCharacteristicPropertyNotify;
    else if ([s isEqualToString:@"indicate"])
      out |= CBCharacteristicPropertyIndicate;
  }
  return out;
}

static CBAttributePermissions ble_perms_from_array(NSArray *props) {
  CBAttributePermissions out = 0;
  for (id p in props) {
    if (![p isKindOfClass:[NSString class]])
      continue;
    NSString *s = (NSString *)p;
    if ([s isEqualToString:@"read"])
      out |= CBAttributePermissionsReadable;
    else if ([s isEqualToString:@"write"] ||
             [s isEqualToString:@"write_without_response"])
      out |= CBAttributePermissionsWriteable;
  }
  return out;
}

// Build the CBMutableService + characteristics from the advertise JSON, stash
// everything in the globals. Returns YES on a usable parse.
static BOOL ble_build_service(NSDictionary *json) {
  NSString *service_uuid = json[@"service_uuid"];
  if (![service_uuid isKindOfClass:[NSString class]])
    return NO;

  g_local_name = [json[@"local_name"] isKindOfClass:[NSString class]]
                     ? json[@"local_name"]
                     : nil;
  g_chars = [NSMutableDictionary dictionary];

  NSMutableArray<CBMutableCharacteristic *> *char_list = [NSMutableArray array];
  NSArray *chars = json[@"characteristics"];
  if ([chars isKindOfClass:[NSArray class]]) {
    for (id c in chars) {
      if (![c isKindOfClass:[NSDictionary class]])
        continue;
      NSDictionary *cd = (NSDictionary *)c;
      NSString *cuuid = cd[@"uuid"];
      if (![cuuid isKindOfClass:[NSString class]])
        continue;
      NSArray *props = [cd[@"properties"] isKindOfClass:[NSArray class]]
                           ? cd[@"properties"]
                           : @[];
      CBCharacteristicProperties cprops = ble_props_from_array(props);
      CBAttributePermissions cperms = ble_perms_from_array(props);

      // CoreBluetooth requires value:nil for any characteristic that
      // supports notify/indicate (or otherwise has a dynamic value);
      // passing a cached value for such a characteristic raises. We have
      // no initial value to supply anyway, so always create with nil.
      CBUUID *cb_uuid = [CBUUID UUIDWithString:cuuid];
      CBMutableCharacteristic *ch =
          [[CBMutableCharacteristic alloc] initWithType:cb_uuid
                                             properties:cprops
                                                  value:nil
                                            permissions:cperms];
      [char_list addObject:ch];
      g_chars[cb_uuid] = ch;
    }
  }

  CBMutableService *svc = [[CBMutableService alloc]
      initWithType:[CBUUID UUIDWithString:service_uuid]
           primary:YES];
  svc.characteristics = char_list;
  g_pending_service = svc;
  return YES;
}

// Drain the outbound notification queue. MUST run on the CB (main) queue. Stops
// at the first updateValue: NO (transmit queue full);
// peripheralManagerIsReadyToUpdateSubscribers: calls back in to resume.
static void ble_flush_tx(void) {
  if (!g_peripheral || !g_tx_queue)
    return;
  while (g_tx_queue.count > 0) {
    NSDictionary *item = g_tx_queue.firstObject;
    BOOL ok = [g_peripheral updateValue:item[@"data"]
                      forCharacteristic:item[@"ch"]
                   onSubscribedCentrals:nil];
    if (!ok)
      break;
    [g_tx_queue removeObjectAtIndex:0];
  }
}

// ── NIFs ────────────────────────────────────────────────────────────────────

// ble_start_advertising(json_binary) → :ok
// Parses the advertise JSON, builds the service, captures the caller pid, and
// (re)creates the CBPeripheralManager. Actual addService / startAdvertising
// happens asynchronously once the manager reports PoweredOn.
static ERL_NIF_TERM nif_ble_start_advertising(ErlNifEnv *env, int argc,
                                              const ERL_NIF_TERM argv[]) {
  (void)argc;
  ErlNifBinary bin;
  if (!enif_inspect_binary(env, argv[0], &bin) &&
      !enif_inspect_iolist_as_binary(env, argv[0], &bin))
    return enif_make_badarg(env);

  NSData *data = [NSData dataWithBytes:bin.data length:bin.size];
  NSError *jerr = nil;
  id parsed = [NSJSONSerialization JSONObjectWithData:data
                                              options:0
                                                error:&jerr];
  if (jerr || ![parsed isKindOfClass:[NSDictionary class]])
    return enif_make_badarg(env);

  enif_self(env, &g_ble_pid);
  g_have_pid = YES;

  if (!ble_build_service((NSDictionary *)parsed)) {
    ble_send_failed("bad_config");
    return enif_make_atom(env, "ok");
  }
  g_want_advertise = YES;

  // Reset central + tx bookkeeping for a fresh advertising session.
  g_centrals = [NSMutableDictionary dictionary];
  g_next_central = 1;
  g_tx_queue = [NSMutableArray array];

  if (!g_delegate)
    g_delegate = [[MobBlePeripheralDelegate alloc] init];

  if (g_peripheral) {
    // Reuse the existing manager; if it's already PoweredOn,
    // peripheralManagerDidUpdateState won't fire again, so drive the
    // add/advertise path directly — on the CB (main) queue.
    if (g_peripheral.state == CBManagerStatePoweredOn) {
      CBMutableService *svc = g_pending_service;
      dispatch_async(dispatch_get_main_queue(), ^{
        [g_peripheral addService:svc];
      });
    }
  } else {
    // nil queue → callbacks on the main queue. Simplest and fine here.
    g_peripheral = [[CBPeripheralManager alloc] initWithDelegate:g_delegate
                                                           queue:nil];
  }
  return enif_make_atom(env, "ok");
}

// ble_stop_advertising() → :ok
static ERL_NIF_TERM nif_ble_stop_advertising(ErlNifEnv *env, int argc,
                                             const ERL_NIF_TERM argv[]) {
  (void)argc;
  (void)argv;
  g_want_advertise = NO;
  // CoreBluetooth calls on the manager's (main) queue.
  dispatch_async(dispatch_get_main_queue(), ^{
    if (g_peripheral) {
      [g_peripheral stopAdvertising];
      [g_peripheral removeAllServices];
    }
    [g_tx_queue removeAllObjects];
  });
  return enif_make_atom(env, "ok");
}

// ble_notify(char_uuid_binary, bytes_binary) → :ok
// Pushes a value to all subscribed centrals for the named characteristic.
static ERL_NIF_TERM nif_ble_notify(ErlNifEnv *env, int argc,
                                   const ERL_NIF_TERM argv[]) {
  (void)argc;
  ErlNifBinary ubin, bbin;
  if (!enif_inspect_binary(env, argv[0], &ubin) &&
      !enif_inspect_iolist_as_binary(env, argv[0], &ubin))
    return enif_make_badarg(env);
  if (!enif_inspect_binary(env, argv[1], &bbin) &&
      !enif_inspect_iolist_as_binary(env, argv[1], &bbin))
    return enif_make_badarg(env);

  if (!g_peripheral || !g_chars)
    return enif_make_atom(env, "ok");

  NSString *uuid_str = [[NSString alloc] initWithBytes:ubin.data
                                                length:ubin.size
                                              encoding:NSUTF8StringEncoding];
  if (!uuid_str)
    return enif_make_atom(env, "ok");
  CBUUID *cb_uuid = [CBUUID UUIDWithString:uuid_str];
  CBMutableCharacteristic *ch = g_chars[cb_uuid];
  if (!ch)
    return enif_make_atom(env, "ok");

  NSData *payload = [NSData dataWithBytes:bbin.data length:bbin.size];
  CBMutableCharacteristic *target = ch;
  // CoreBluetooth is not thread-safe and must be driven on the manager's queue
  // (main, since we created it with queue:nil). This NIF runs on a BEAM
  // scheduler thread, so hop to main, enqueue, and drain — the queue + the
  // peripheralManagerIsReadyToUpdateSubscribers: resume handle backpressure so
  // a note-off isn't dropped when its note-on filled the transmit queue.
  dispatch_async(dispatch_get_main_queue(), ^{
    if (!g_tx_queue)
      g_tx_queue = [NSMutableArray array];
    if (g_tx_queue.count >= MOB_BLE_TX_CAP) {
      [g_tx_queue removeObjectAtIndex:0]; // sustained backpressure: drop oldest
    }
    [g_tx_queue addObject:@{@"ch" : target, @"data" : payload}];
    ble_flush_tx();
  });
  return enif_make_atom(env, "ok");
}

// ── Registration ──────────────────────────────────────────────────────────
static ErlNifFunc nif_funcs[] = {
    {"ble_scan", 1, nif_ble_scan, 0},
    {"ble_stop_scan", 0, nif_ble_stop_scan, 0},
    {"ble_advertise", 1, nif_ble_advertise, 0},
    {"ble_stop_advertise", 0, nif_ble_stop_advertise, 0},
    {"ble_start_advertising", 1, nif_ble_start_advertising, 0},
    {"ble_stop_advertising", 0, nif_ble_stop_advertising, 0},
    {"ble_notify", 2, nif_ble_notify, 0},
};

ERL_NIF_INIT(mob_bluetooth_nif, nif_funcs, NULL, NULL, NULL, NULL)