/**
 * @license
 * Copyright 2025-2026 Open Home Foundation
 * SPDX-License-Identifier: Apache-2.0
 */
import { WebSocket } from "ws";
import {
  BLE_PROXY_PROTOCOL_VERSION,
  BinaryFrameOpcode,
  decodeBinaryFrame,
  encodeBinaryFrame
} from "../BleProxyProtocol.js";
function withTimeout(promise, ms, message) {
  return Promise.race([promise, new Promise((_, reject) => setTimeout(() => reject(new Error(message)), ms))]);
}
function ts() {
  const d = /* @__PURE__ */ new Date();
  const pad = (n, w = 2) => n.toString().padStart(w, "0");
  return `${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())}.${pad(d.getMilliseconds(), 3)}`;
}
function log(...args) {
  process.stdout.write(`${ts()} `);
  console.log(...args);
}
function warn(...args) {
  process.stdout.write(`${ts()} `);
  console.warn(...args);
}
function error(...args) {
  process.stdout.write(`${ts()} `);
  console.error(...args);
}
class NobleBleProxyClient {
  #serverUrl;
  #hciId;
  #ws;
  #noble;
  #connections = /* @__PURE__ */ new Map();
  #nextHandle = 1;
  #discoveredPeripherals = /* @__PURE__ */ new Map();
  #lastDiscoverFingerprint = /* @__PURE__ */ new Map();
  #commandHandlers = /* @__PURE__ */ new Map();
  #closing = false;
  constructor(serverUrl, hciId) {
    this.#serverUrl = serverUrl;
    this.#hciId = hciId;
    this.#registerCommandHandlers();
  }
  async connect() {
    await this.#loadNoble();
    return new Promise((resolve, reject) => {
      const ws = new WebSocket(this.#serverUrl);
      this.#ws = ws;
      ws.on("open", () => {
        ws.send(JSON.stringify({ type: "hello", version: BLE_PROXY_PROTOCOL_VERSION }));
      });
      let handshakeComplete = false;
      ws.on("message", (data, isBinary) => {
        if (isBinary) {
          this.#handleBinaryFrame(Buffer.from(data));
          return;
        }
        const msg = JSON.parse(data.toString());
        if (!handshakeComplete) {
          if (msg.type === "hello_response") {
            if (msg.error) {
              reject(new Error(`Handshake failed: ${msg.error} - ${msg.message}`));
              ws.close();
              return;
            }
            handshakeComplete = true;
            log(`BLE proxy handshake complete (protocol v${msg.version})`);
            resolve();
          }
          return;
        }
        if ("id" in msg && "command" in msg) {
          this.#handleCommand(msg).catch(
            (err) => error(`Unhandled error in command handler: ${err.message}`)
          );
        }
      });
      ws.on("close", (code, reason) => {
        if (!this.#closing) {
          log(`Disconnected from server (code=${code}${reason.length ? `, reason=${reason}` : ""})`);
        }
      });
      ws.on("unexpected-response", (_req, res) => {
        let body = "";
        res.on("data", (chunk) => body += chunk.toString());
        res.on("end", () => {
          const err = new Error(`Unexpected server response: ${res.statusCode}`);
          error(`Server rejected WebSocket upgrade: HTTP ${res.statusCode}`, body ? `- Body: ${body}` : "");
          error("Hint: Make sure the server is running with --ble-proxy (server must expose /ble endpoint)");
          reject(err);
        });
      });
      ws.on("error", (err) => {
        if (!handshakeComplete) {
          reject(err);
        } else {
          error("WebSocket error:", err);
        }
      });
    });
  }
  close() {
    this.#closing = true;
    for (const [handle, conn] of this.#connections) {
      if (conn.peripheral.state === "connected") {
        conn.peripheral.disconnectAsync().catch(
          (err) => warn(`[CONN] handle=${handle} disconnect during shutdown failed: ${err.message}`)
        );
      }
    }
    this.#connections.clear();
    this.#noble?.stop();
    this.#ws?.close();
  }
  async #loadNoble() {
    if (this.#hciId !== void 0) {
      process.env.NOBLE_HCI_DEVICE_ID = this.#hciId.toString();
    }
    const noble = (await import("@stoprocent/noble")).default;
    const nobleAsUnknown = noble;
    if (typeof nobleAsUnknown === "function") {
      const factory = nobleAsUnknown;
      this.#noble = factory({ extended: false });
    } else {
      this.#noble = noble;
    }
    this.#noble.on("warning", (message) => warn(`[NOBLE] warning: ${message}`));
    this.#noble.on("stateChange", (state) => log(`[NOBLE] stateChange: ${state}`));
  }
  #registerCommandHandlers() {
    this.#commandHandlers.set("start_scan", (id, _args) => this.#handleStartScan(id));
    this.#commandHandlers.set("stop_scan", (id) => this.#handleStopScan(id));
    this.#commandHandlers.set("connect", (id, args) => this.#handleConnect(id, args));
    this.#commandHandlers.set(
      "disconnect",
      (id, args) => this.#handleDisconnect(id, args.connection_handle)
    );
    this.#commandHandlers.set(
      "discover_services",
      (id, args) => this.#handleDiscoverServices(id, args)
    );
    this.#commandHandlers.set(
      "discover_characteristics",
      (id, args) => this.#handleDiscoverCharacteristics(id, args)
    );
    this.#commandHandlers.set(
      "read_characteristic",
      (id, args) => this.#handleReadCharacteristic(id, args)
    );
    this.#commandHandlers.set(
      "write_characteristic",
      (id, args) => this.#handleWriteCharacteristic(id, args)
    );
    this.#commandHandlers.set(
      "subscribe_characteristic",
      (id, args) => this.#handleSubscribeCharacteristic(id, args)
    );
    this.#commandHandlers.set(
      "unsubscribe_characteristic",
      (id, args) => this.#handleUnsubscribeCharacteristic(id, args)
    );
    this.#commandHandlers.set(
      "request_mtu",
      (id, args) => this.#handleRequestMtu(
        id,
        args.connection_handle,
        args.mtu
      )
    );
  }
  async #handleCommand(msg) {
    const argsStr = msg.args ? JSON.stringify(msg.args) : "";
    log(`[\u2190CMD] id=${msg.id} ${msg.command}${argsStr ? ` ${argsStr}` : ""}`);
    const handler = this.#commandHandlers.get(msg.command);
    if (!handler) {
      error(`[\u2192ERR] id=${msg.id} Unknown command: ${msg.command}`);
      this.#sendError(msg.id, "internal_error", `Unknown command: ${msg.command}`);
      return;
    }
    try {
      await handler(msg.id, msg.args ?? {});
    } catch (err) {
      error(`[\u2192ERR] id=${msg.id} ${msg.command} threw: ${err.message}`);
      this.#sendError(msg.id, "internal_error", `${err.message}`);
    }
  }
  // ─── Command Handlers ────────────────────────────────────────────────────
  async #handleStartScan(id) {
    if (!this.#noble) {
      this.#sendError(id, "bluetooth_unavailable", "Noble not initialized");
      return;
    }
    this.#lastDiscoverFingerprint.clear();
    this.#noble.removeAllListeners("discover");
    this.#noble.on("discover", (peripheral) => {
      const address = peripheral.address || peripheral.id;
      this.#discoveredPeripherals.set(address, peripheral);
      const serviceData = {};
      for (const sd of peripheral.advertisement.serviceData ?? []) {
        serviceData[sd.uuid] = Buffer.from(sd.data).toString("base64");
      }
      const name = peripheral.advertisement.localName ?? "(unnamed)";
      const connectable = peripheral.connectable ?? false;
      const serviceUuids = peripheral.advertisement.serviceUuids ?? [];
      const fingerprint = {
        name,
        connectable,
        serviceUuids: serviceUuids.join(","),
        serviceData: Object.entries(serviceData).map(([uuid, data]) => `${uuid}=${data}`).sort().join("|")
      };
      const prev = this.#lastDiscoverFingerprint.get(address);
      const changed = !prev || prev.name !== fingerprint.name || prev.connectable !== fingerprint.connectable || prev.serviceUuids !== fingerprint.serviceUuids || prev.serviceData !== fingerprint.serviceData;
      if (!changed) {
        return;
      }
      this.#lastDiscoverFingerprint.set(address, fingerprint);
      log(
        `[EVT] device_discovered addr=${address} name="${name}" rssi=${peripheral.rssi} services=${JSON.stringify(serviceUuids)} serviceData=${JSON.stringify(Object.keys(serviceData))}`
      );
      const event = {
        address,
        name: peripheral.advertisement.localName,
        rssi: peripheral.rssi,
        connectable,
        service_data: serviceData,
        service_uuids: serviceUuids
      };
      this.#sendEvent("device_discovered", event);
    });
    await this.#noble.startScanningAsync(["fff6"], true);
    log("[SCAN] BLE scan started (filter: fff6)");
    this.#sendSuccess(id);
  }
  async #handleStopScan(id) {
    await this.#noble?.stopScanningAsync();
    log("[SCAN] BLE scan stopped");
    this.#sendSuccess(id);
  }
  async #handleConnect(id, args) {
    const peripheral = this.#discoveredPeripherals.get(args.address);
    if (!peripheral) {
      error(
        `[CONN] No peripheral found for address "${args.address}". Known: ${[...this.#discoveredPeripherals.keys()].join(", ")}`
      );
      this.#sendError(id, "device_not_found", `No device found for address ${args.address}`);
      return;
    }
    const handle = this.#nextHandle++;
    const connState = {
      peripheral,
      services: /* @__PURE__ */ new Map(),
      characteristics: /* @__PURE__ */ new Map(),
      subscriptions: /* @__PURE__ */ new Map()
    };
    this.#connections.set(handle, connState);
    let disconnectedReason;
    const disconnectListener = () => {
      disconnectedReason = `peripheral disconnected (state=${peripheral.state})`;
      log(`[CONN] Peripheral handle=${handle} disconnected (state=${peripheral.state})`);
      this.#connections.delete(handle);
      this.#sendEvent("disconnected", { connection_handle: handle });
    };
    peripheral.once("disconnect", disconnectListener);
    log(`[CONN] Connecting to "${args.address}" (state=${peripheral.state})...`);
    try {
      log(`[SCAN] pausing scan for connect+interview...`);
      await this.#noble.stopScanningAsync();
      await peripheral.connectAsync();
      log(`[CONN] Connected handle=${handle} state=${peripheral.state} mtu=${peripheral.mtu ?? "?"}`);
      log(`[GATT] handle=${handle} discoverServicesAsync(["fff6"])...`);
      const services = await withTimeout(
        peripheral.discoverServicesAsync(["fff6"]),
        3e4,
        "discoverServices(fff6) timed out after 30s"
      );
      log(`[GATT] handle=${handle} services: ${services.map((s) => s.uuid).join(", ")} state=${peripheral.state}`);
      for (const service of services) {
        connState.services.set(service.uuid, service);
        if (service.uuid !== "fff6") continue;
        log(`[GATT] handle=${handle} discoverCharacteristicsAsync() on ${service.uuid}...`);
        const chars = await withTimeout(
          service.discoverCharacteristicsAsync(),
          3e4,
          `discoverCharacteristics(${service.uuid}) timed out after 30s`
        );
        for (const char of chars) {
          connState.characteristics.set(char.uuid, char);
        }
        log(
          `[GATT] handle=${handle} chars on ${service.uuid}: ${chars.map((c) => c.uuid).join(", ")} state=${peripheral.state}`
        );
      }
      const mtu = peripheral.mtu ?? 23;
      log(`[GATT] handle=${handle} ready mtu=${mtu}`);
      log(`[SCAN] resuming scan after connect+interview...`);
      await this.#noble.startScanningAsync(["fff6"], true);
      this.#sendSuccess(id, { connection_handle: handle, mtu });
    } catch (err) {
      const reason = disconnectedReason ?? err.message;
      error(`[CONN] handle=${handle} failed: ${reason}`);
      if (this.#connections.has(handle)) {
        this.#connections.delete(handle);
      }
      peripheral.removeListener("disconnect", disconnectListener);
      if (peripheral.state === "connected") {
        peripheral.disconnectAsync().catch(
          (disconnectErr) => warn(`[CONN] handle=${handle} cleanup disconnect failed: ${disconnectErr.message}`)
        );
      }
      this.#noble.startScanningAsync(["fff6"], true).catch(
        (scanErr) => warn(`[SCAN] failed to resume scanning after connect failure: ${scanErr.message}`)
      );
      this.#sendError(id, "internal_error", reason);
    }
  }
  async #handleDisconnect(id, connectionHandle) {
    const conn = this.#connections.get(connectionHandle);
    if (!conn) {
      this.#sendError(id, "not_connected", `No connection with handle ${connectionHandle}`);
      return;
    }
    if (conn.peripheral.state === "connected") {
      await conn.peripheral.disconnectAsync();
    }
    this.#connections.delete(connectionHandle);
    this.#sendSuccess(id);
  }
  async #handleDiscoverServices(id, args) {
    const conn = this.#connections.get(args.connection_handle);
    if (!conn) {
      this.#sendError(id, "not_connected", `No connection with handle ${args.connection_handle}`);
      return;
    }
    if (conn.services.size > 0) {
      const uuids = [...conn.services.keys()];
      log(`[GATT] handle=${args.connection_handle} services from cache: ${uuids.join(", ")}`);
      this.#sendSuccess(id, { services: uuids.map((uuid) => ({ uuid })) });
      return;
    }
    log(`[GATT] handle=${args.connection_handle} discovering services (lazy)...`);
    const services = await withTimeout(
      conn.peripheral.discoverServicesAsync([]),
      1e4,
      "discoverServices timed out after 10s"
    );
    for (const service of services) {
      conn.services.set(service.uuid, service);
    }
    log(`[GATT] handle=${args.connection_handle} discovered services: ${services.map((s) => s.uuid).join(", ")}`);
    this.#sendSuccess(id, {
      services: services.map((s) => ({ uuid: s.uuid }))
    });
  }
  async #handleDiscoverCharacteristics(id, args) {
    const conn = this.#connections.get(args.connection_handle);
    if (!conn) {
      this.#sendError(id, "not_connected", `No connection with handle ${args.connection_handle}`);
      return;
    }
    const service = conn.services.get(args.service_uuid);
    if (!service) {
      this.#sendError(id, "service_not_found", `Service ${args.service_uuid} not found`);
      return;
    }
    const cachedChars = service.characteristics ?? [];
    if (cachedChars.length > 0) {
      log(
        `[GATT] handle=${args.connection_handle} characteristics from cache for ${args.service_uuid}: ` + cachedChars.map((c) => `${c.uuid}[${c.properties.join(",")}]`).join(", ")
      );
      this.#sendSuccess(id, {
        characteristics: cachedChars.map((c) => ({
          uuid: c.uuid,
          properties: c.properties
        }))
      });
      return;
    }
    log(`[GATT] handle=${args.connection_handle} discovering characteristics for ${args.service_uuid} (lazy)...`);
    const characteristics = await withTimeout(
      service.discoverCharacteristicsAsync([]),
      1e4,
      `discoverCharacteristics(${args.service_uuid}) timed out after 10s`
    );
    for (const char of characteristics) {
      conn.characteristics.set(char.uuid, char);
    }
    log(
      `[GATT] handle=${args.connection_handle} discovered chars for ${args.service_uuid}: ` + characteristics.map((c) => `${c.uuid}[${c.properties.join(",")}]`).join(", ")
    );
    this.#sendSuccess(id, {
      characteristics: characteristics.map((c) => ({
        uuid: c.uuid,
        properties: c.properties
      }))
    });
  }
  async #handleReadCharacteristic(id, args) {
    const conn = this.#connections.get(args.connection_handle);
    if (!conn) {
      this.#sendError(id, "not_connected", `No connection with handle ${args.connection_handle}`);
      return;
    }
    const char = this.#findCharacteristic(conn, args.characteristic_uuid);
    if (!char) {
      this.#sendError(id, "characteristic_not_found", `Characteristic ${args.characteristic_uuid} not found`);
      return;
    }
    const data = await char.readAsync();
    log(`[GATT] read ${args.characteristic_uuid} \u2192 ${data.length} bytes`);
    this.#sendSuccess(id, { value: Buffer.from(data).toString("base64") });
  }
  async #handleWriteCharacteristic(id, args) {
    const conn = this.#connections.get(args.connection_handle);
    if (!conn) {
      this.#sendError(id, "not_connected", `No connection with handle ${args.connection_handle}`);
      return;
    }
    const char = this.#findCharacteristic(conn, args.characteristic_uuid);
    if (!char) {
      this.#sendError(id, "characteristic_not_found", `Characteristic ${args.characteristic_uuid} not found`);
      return;
    }
    const data = Buffer.from(args.value, "base64");
    const withResponse = args.response ?? false;
    log(`[GATT] write ${args.characteristic_uuid} ${data.length} bytes withResponse=${withResponse}`);
    await char.writeAsync(data, !withResponse);
    conn.lastWriteCharacteristic = char;
    this.#sendSuccess(id);
  }
  async #handleSubscribeCharacteristic(id, args) {
    const conn = this.#connections.get(args.connection_handle);
    if (!conn) {
      this.#sendError(id, "not_connected", `No connection with handle ${args.connection_handle}`);
      return;
    }
    const char = this.#findCharacteristic(conn, args.characteristic_uuid);
    if (!char) {
      this.#sendError(id, "characteristic_not_found", `Characteristic ${args.characteristic_uuid} not found`);
      return;
    }
    const handle = args.connection_handle;
    char.on("data", (data) => {
      log(`[GATT] notify ${args.characteristic_uuid} handle=${handle} ${data.length} bytes`);
      this.#sendBinaryFrame(BinaryFrameOpcode.Notification, handle, new Uint8Array(data));
    });
    await char.subscribeAsync();
    conn.subscriptions.set(args.characteristic_uuid, char);
    log(`[GATT] subscribe ${args.characteristic_uuid} handle=${handle}`);
    this.#sendSuccess(id);
  }
  async #handleUnsubscribeCharacteristic(id, args) {
    const conn = this.#connections.get(args.connection_handle);
    if (!conn) {
      this.#sendError(id, "not_connected", `No connection with handle ${args.connection_handle}`);
      return;
    }
    const char = conn.subscriptions.get(args.characteristic_uuid);
    if (!char) {
      this.#sendError(id, "not_subscribed", `Not subscribed to ${args.characteristic_uuid}`);
      return;
    }
    await char.unsubscribeAsync();
    char.removeAllListeners("data");
    conn.subscriptions.delete(args.characteristic_uuid);
    log(`[GATT] unsubscribe ${args.characteristic_uuid} handle=${args.connection_handle}`);
    this.#sendSuccess(id);
  }
  async #handleRequestMtu(id, connectionHandle, mtu) {
    const conn = this.#connections.get(connectionHandle);
    if (!conn) {
      this.#sendError(id, "not_connected", `No connection with handle ${connectionHandle}`);
      return;
    }
    const actualMtu = conn.peripheral.mtu ?? mtu;
    log(`[GATT] request_mtu handle=${connectionHandle} requested=${mtu} actual=${actualMtu}`);
    this.#sendSuccess(id, { mtu: actualMtu });
  }
  // ─── Binary Frame Handling ───────────────────────────────────────────────
  #handleBinaryFrame(data) {
    const frame = decodeBinaryFrame(new Uint8Array(data));
    log(`[\u2190BIN] opcode=${frame.opcode} handle=${frame.connectionHandle} payload=${frame.payload.length} bytes`);
    if (frame.opcode === BinaryFrameOpcode.WriteData) {
      const conn = this.#connections.get(frame.connectionHandle);
      if (!conn?.lastWriteCharacteristic) {
        warn(`[\u2190BIN] WriteData: no lastWriteCharacteristic for handle=${frame.connectionHandle}`);
        return;
      }
      conn.lastWriteCharacteristic.writeAsync(Buffer.from(frame.payload), false).catch((err) => {
        error("Binary write error:", err);
      });
    }
  }
  // ─── Helpers ─────────────────────────────────────────────────────────────
  #findCharacteristic(conn, uuid) {
    return conn.characteristics.get(uuid) ?? conn.characteristics.get(uuid.toLowerCase()) ?? conn.characteristics.get(uuid.toUpperCase().replace(/-/g, "").toLowerCase());
  }
  #sendSuccess(id, result) {
    this.#ws?.send(JSON.stringify({ id, success: true, result: result ?? {} }));
  }
  #sendError(id, error2, message) {
    this.#ws?.send(JSON.stringify({ id, success: false, error: error2, message }));
  }
  #sendEvent(event, data) {
    this.#ws?.send(JSON.stringify({ event, data }));
  }
  #sendBinaryFrame(opcode, connectionHandle, payload) {
    log(`[\u2192BIN] opcode=${opcode} handle=${connectionHandle} payload=${payload.length} bytes`);
    const frame = encodeBinaryFrame(opcode, connectionHandle, payload);
    this.#ws?.send(frame);
  }
}
export {
  NobleBleProxyClient
};
//# sourceMappingURL=NobleBleProxyClient.js.map
