/**
 * @license
 * Copyright 2022-2026 Matter.js Authors
 * SPDX-License-Identifier: Apache-2.0
 */
import {
  Bytes,
  ChannelType,
  Diagnostic,
  InternalError,
  Logger,
  Minutes,
  NetworkError,
  ServerAddress,
  Time,
  asError,
  createPromise
} from "#general";
import {
  BLE_MATTER_C1_CHARACTERISTIC_UUID,
  BLE_MATTER_C2_CHARACTERISTIC_UUID,
  BLE_MATTER_C3_CHARACTERISTIC_UUID,
  BLE_MATTER_SERVICE_UUID_SHORT,
  BLE_MAXIMUM_BTP_MTU,
  BTP_CONN_RSP_TIMEOUT,
  BTP_MAXIMUM_WINDOW_SIZE,
  BTP_SUPPORTED_VERSIONS,
  BleChannel,
  BleError,
  BtpCodec,
  BtpFlowError,
  BtpSessionHandler
} from "#protocol";
const logger = Logger.get("BleChannel");
function nobleUuidToUuid(uuid) {
  uuid = uuid.toUpperCase();
  if (uuid.length !== 32) {
    return uuid;
  }
  const parts = [
    uuid.substring(0, 8),
    uuid.substring(8, 12),
    uuid.substring(12, 16),
    uuid.substring(16, 20),
    uuid.substring(20, 32)
  ];
  return parts.join("-");
}
class NobleBleCentralInterface {
  #bleScanner;
  #connectionsInProgress = /* @__PURE__ */ new Set();
  #connectionGuards = /* @__PURE__ */ new Set();
  #openChannels = /* @__PURE__ */ new Map();
  #onMatterMessageListener;
  #closed = false;
  constructor(bleScanner) {
    this.#bleScanner = bleScanner;
  }
  openChannel(address, tryCount = 1) {
    if (this.#closed) {
      throw new NetworkError("Network interface is closed");
    }
    return new Promise((resolve, reject) => {
      let resolvedOrRejected = false;
      function rejectOnce(error) {
        if (!resolvedOrRejected) {
          resolvedOrRejected = true;
          reject(asError(error));
        } else {
          logger.debug(`Already resolved or rejected, ignore error:`, error);
        }
      }
      function resolveOnce(value) {
        if (!resolvedOrRejected) {
          resolvedOrRejected = true;
          resolve(value);
        } else {
          logger.debug(`Already resolved or rejected, ignore success`);
        }
      }
      if (this.#onMatterMessageListener === void 0) {
        rejectOnce(
          new InternalError(`Network Interface was not added to the system yet, so can not connect it.`)
        );
        return;
      }
      if (address.type !== "ble") {
        rejectOnce(new InternalError(`Unsupported address type ${address.type}.`));
        return;
      }
      const { peripheralAddress } = address;
      if (tryCount > 3) {
        rejectOnce(new BleError(`Failed to connect to peripheral ${peripheralAddress}`));
        return;
      }
      const { peripheral, hasAdditionalAdvertisementData } = this.#bleScanner.getDiscoveredDevice(peripheralAddress);
      if (this.#openChannels.has(address)) {
        rejectOnce(
          new BleError(
            `Peripheral ${peripheralAddress} is already connected. Only one connection supported right now.`
          )
        );
        return;
      }
      if (this.#connectionsInProgress.has(address)) {
        logger.debug(`Connection to peripheral ${peripheralAddress} is already in progress.`);
        return;
      }
      if (peripheral.state === "error") {
        rejectOnce(
          new BleError(
            `Can not connect to peripheral "${peripheralAddress}" because unexpected state "${peripheral.state}"`
          )
        );
        return;
      }
      const connectionGuard = {
        // Timeout when trying to connect to the device because sometimes connect fails and noble does
        // not emit an event. If device does not connect we do not try any longer and reject the promise
        // because a re-discovery is the best option to get teh device into a good state again
        connectTimeout: Time.getTimer("BLE connect timeout", Minutes(2), () => {
          logger.debug(`Timeout while connecting to peripheral ${peripheralAddress}`);
          peripheral.removeListener("connect", connectHandler);
          peripheral.removeListener("disconnect", reTryHandler);
          clearConnectionGuard();
          rejectOnce(new BleError(`Timeout while connecting to peripheral ${peripheralAddress}`));
        }),
        disconnectTimeout: Time.getTimer("BLE disconnect timeout", Minutes.one, () => {
          logger.debug(`Timeout while disconnecting to peripheral ${peripheralAddress}`);
          peripheral.removeListener("disconnect", reTryHandler);
          clearConnectionGuard();
          rejectOnce(new BleError(`Timeout while disconnecting to peripheral ${peripheralAddress}`));
        }),
        // Timeout when trying to interview the device because sometimes when no response from device
        // comes noble does not resolve promises
        interviewTimeout: Time.getTimer("BLE interview timeout", Minutes.one, () => {
          logger.debug(`Timeout while interviewing peripheral ${peripheralAddress}`);
          peripheral.removeListener("disconnect", reTryHandler);
          clearConnectionGuard();
          if (peripheral.state === "connected") {
            peripheral.disconnectAsync().catch((error) => logger.error(`Ignored error while disconnecting`, error));
          }
          rejectOnce(new BleError(`Timeout while interviewing peripheral ${peripheralAddress}`));
        })
      };
      this.#connectionGuards.add(connectionGuard);
      const clearConnectionGuard = () => {
        const { connectTimeout, interviewTimeout, disconnectTimeout } = connectionGuard;
        connectTimeout?.stop();
        interviewTimeout?.stop();
        disconnectTimeout?.stop();
        this.#connectionGuards.delete(connectionGuard);
      };
      const reTryHandler = (error) => {
        clearConnectionGuard();
        this.#connectionsInProgress.delete(address);
        peripheral.removeListener("connect", connectHandler);
        peripheral.removeListener("disconnect", reTryHandler);
        if (error) {
          logger.error(
            `Peripheral ${peripheralAddress} disconnected while trying to connect, try again`,
            error
          );
        } else {
          logger.info(`Peripheral ${peripheralAddress} disconnected while trying to connect, try again`);
        }
        this.openChannel(address, tryCount + 1).then(resolveOnce).catch(rejectOnce);
      };
      const connectHandler = async (error) => {
        connectionGuard.connectTimeout.stop();
        if (!this.#connectionGuards.has(connectionGuard)) {
          return;
        }
        if (error) {
          clearConnectionGuard();
          rejectOnce(new BleError(`Error while connecting to peripheral ${peripheralAddress}`, error));
          return;
        }
        if (this.#onMatterMessageListener === void 0) {
          clearConnectionGuard();
          rejectOnce(new InternalError(`Network Interface was not added to the system yet or was cleared.`));
          return;
        }
        if (this.#connectionsInProgress.has(address)) {
          return;
        }
        this.#connectionsInProgress.add(address);
        try {
          connectionGuard.interviewTimeout.start();
          const services = await peripheral.discoverServicesAsync([BLE_MATTER_SERVICE_UUID_SHORT]);
          if (!this.#connectionGuards.has(connectionGuard)) {
            return;
          }
          logger.debug(
            `Peripheral ${peripheralAddress}: Found services: ${services.map((s) => s.uuid).join(", ")}`
          );
          for (const service of services) {
            logger.debug(`Peripheral ${peripheralAddress}: Handle service: ${service.uuid}`);
            if (service.uuid !== BLE_MATTER_SERVICE_UUID_SHORT) continue;
            const characteristics = await service.discoverCharacteristicsAsync();
            if (!this.#connectionGuards.has(connectionGuard)) {
              return;
            }
            let characteristicC1ForWrite;
            let characteristicC2ForSubscribe;
            let additionalCommissioningRelatedData;
            for (const characteristic of characteristics) {
              logger.debug(
                `Peripheral ${peripheralAddress}: Handle characteristic:`,
                characteristic.uuid,
                characteristic.properties
              );
              switch (nobleUuidToUuid(characteristic.uuid)) {
                case BLE_MATTER_C1_CHARACTERISTIC_UUID:
                  logger.debug(`Peripheral ${peripheralAddress}: Found C1 characteristic`);
                  characteristicC1ForWrite = characteristic;
                  break;
                case BLE_MATTER_C2_CHARACTERISTIC_UUID:
                  logger.debug(`Peripheral ${peripheralAddress}: Found C2 characteristic`);
                  characteristicC2ForSubscribe = characteristic;
                  break;
                case BLE_MATTER_C3_CHARACTERISTIC_UUID:
                  logger.debug(`Peripheral ${peripheralAddress}: Found C3 characteristic`);
                  if (hasAdditionalAdvertisementData) {
                    logger.debug(
                      `Peripheral ${peripheralAddress}: Reading additional commissioning related data`
                    );
                    const data = await characteristic.readAsync();
                    if (!this.#connectionGuards.has(connectionGuard)) {
                      return;
                    }
                    additionalCommissioningRelatedData = new Uint8Array(data);
                    logger.debug(`Peripheral ${peripheralAddress}: Additional data:`, data);
                  }
              }
            }
            if (!characteristicC1ForWrite || !characteristicC2ForSubscribe) {
              logger.debug(
                `Peripheral ${peripheralAddress}: Missing required Matter characteristics. Ignore.`
              );
              continue;
            }
            connectionGuard.interviewTimeout.stop();
            peripheral.removeListener("disconnect", reTryHandler);
            this.#openChannels.set(address, peripheral);
            try {
              resolveOnce(
                await NobleBleChannel.create(
                  peripheral,
                  characteristicC1ForWrite,
                  characteristicC2ForSubscribe,
                  this.#onMatterMessageListener,
                  additionalCommissioningRelatedData
                )
              );
              clearConnectionGuard();
              this.#connectionsInProgress.delete(address);
            } catch (error2) {
              this.#connectionsInProgress.delete(address);
              this.#openChannels.delete(address);
              if (peripheral.state === "connected") {
                logger.debug(
                  `Disconnect because of initialization error of peripheral ${ServerAddress.urlFor(address)}`
                );
                await peripheral.disconnectAsync().catch(
                  (error3) => logger.debug(
                    `Peripheral ${peripheral.address}: Error while disconnecting`,
                    error3
                  )
                );
              }
              reTryHandler(error2);
              return;
            }
          }
        } catch (error2) {
          if (this.#connectionGuards.has(connectionGuard)) {
            reTryHandler(error2);
          }
          return;
        } finally {
          this.#connectionsInProgress.delete(address);
          clearConnectionGuard();
        }
        peripheral.removeListener("disconnect", reTryHandler);
        rejectOnce(
          new BleError(`Peripheral ${peripheralAddress} does not have the required Matter characteristics`)
        );
      };
      if (peripheral.state === "connected") {
        logger.debug(`Peripheral ${peripheralAddress}: Already connected`);
        connectHandler().catch((error) => logger.warn(`Error while connecting`, error));
      } else if (peripheral.state === "disconnecting") {
        logger.debug(`Peripheral ${peripheralAddress}: Disconnect in progress`);
        connectionGuard.disconnectTimeout.start();
        tryCount--;
        peripheral.once("disconnect", reTryHandler);
      } else {
        if (peripheral.state === "connecting") {
          peripheral.cancelConnect();
        }
        connectionGuard.connectTimeout.start();
        peripheral.once("connect", connectHandler);
        peripheral.once("disconnect", reTryHandler);
        logger.debug(`Peripheral ${peripheralAddress}: Connect to Peripheral now (try ${tryCount})`);
        peripheral.connectAsync().catch((error) => {
          if (!this.#connectionGuards.has(connectionGuard)) {
            return;
          }
          logger.info(`Peripheral ${peripheralAddress}: Error while connecting to peripheral`, error);
          reTryHandler(error);
        });
      }
    });
  }
  onData(listener) {
    this.#onMatterMessageListener = listener;
    return {
      close: async () => await this.close()
    };
  }
  async close() {
    this.#closed = true;
    for (const peripheral of this.#openChannels.values()) {
      if (peripheral.state === "connected") {
        logger.debug(`Peripheral ${peripheral.address}: Disconnect from peripheral while closing central`);
        peripheral.disconnectAsync().catch((error) => logger.error(`Peripheral ${peripheral.address}: Error while disconnecting`, error));
      }
    }
    this.#openChannels.clear();
  }
  supports(type, _address) {
    if (type !== ChannelType.BLE) {
      return false;
    }
    return true;
  }
}
class NobleBleChannel extends BleChannel {
  constructor(peripheral, btpSession) {
    super();
    this.peripheral = peripheral;
    this.btpSession = btpSession;
    peripheral.once("disconnect", () => {
      logger.debug(`Disconnected from peripheral ${peripheral.address}. Closing BTP session`);
      this.#connected = false;
      void this.btpSession.close();
    });
  }
  static async create(peripheral, characteristicC1ForWrite, characteristicC2ForSubscribe, onMatterMessageListener, _additionalCommissioningRelatedData) {
    const { address: peripheralAddress } = peripheral;
    let mtu = peripheral.mtu ?? 0;
    if (mtu > BLE_MAXIMUM_BTP_MTU) {
      mtu = BLE_MAXIMUM_BTP_MTU;
    }
    logger.debug(
      `Peripheral ${peripheralAddress}: Using MTU=${mtu} bytes (Peripheral supports up to ${peripheral.mtu} bytes)`
    );
    const {
      promise: handshakeResponseReceivedPromise,
      resolver: handshakeResolver,
      rejecter: handshakeRejecter
    } = createPromise();
    const handshakeHandler = (data, isNotification) => {
      if (data[0] === 101 && data[1] === 108 && data.length === 6) {
        logger.info(
          `Peripheral ${peripheralAddress}: Received Matter handshake response: ${data.toString("hex")}.`
        );
        btpHandshakeTimeout.stop();
        handshakeResolver(data);
      } else {
        logger.debug(
          `Peripheral ${peripheralAddress}: Received first data on C2: ${data.toString("hex")} (isNotification: ${isNotification}) - No handshake response, ignoring`
        );
      }
    };
    const btpHandshakeTimeout = Time.getTimer("BLE handshake timeout", BTP_CONN_RSP_TIMEOUT, async () => {
      characteristicC2ForSubscribe.removeListener("data", handshakeHandler);
      await characteristicC2ForSubscribe.unsubscribeAsync().catch((error) => logger.error(`Peripheral ${peripheralAddress}: Error while unsubscribing`, error));
      logger.debug(
        `Peripheral ${peripheralAddress}: Handshake Response not received. Disconnect from peripheral`
      );
      handshakeRejecter(new BleError(`Peripheral ${peripheralAddress}: Handshake Response not received`));
    }).start();
    const btpHandshakeRequest = BtpCodec.encodeBtpHandshakeRequest({
      versions: BTP_SUPPORTED_VERSIONS,
      attMtu: mtu,
      clientWindowSize: BTP_MAXIMUM_WINDOW_SIZE
    });
    logger.debug(
      `Peripheral ${peripheralAddress}: Sending BTP handshake request: ${Diagnostic.json(btpHandshakeRequest)}`
    );
    await characteristicC1ForWrite.writeAsync(Buffer.from(Bytes.of(btpHandshakeRequest)), false);
    characteristicC2ForSubscribe.once("data", handshakeHandler);
    logger.debug(`Peripheral ${peripheralAddress}: Subscribing to C2 characteristic`);
    await characteristicC2ForSubscribe.subscribeAsync();
    const handshakeResponse = await handshakeResponseReceivedPromise;
    const btpSession = await BtpSessionHandler.createAsCentral(
      new Uint8Array(handshakeResponse),
      // callback to write data to characteristic C1
      async (data) => {
        return await characteristicC1ForWrite.writeAsync(Buffer.from(Bytes.of(data)), false);
      },
      // callback to disconnect the BLE connection
      async () => {
        if (peripheral.state !== "connected" || !nobleChannel.connected) return;
        logger.debug(`Peripheral ${peripheralAddress}: Disconnect from peripheral because btp session closed`);
        characteristicC2ForSubscribe.unsubscribeAsync().then(
          () => peripheral.disconnectAsync().then(
            () => logger.debug(`Peripheral ${peripheralAddress}: Disconnected from peripheral`),
            (error) => logger.debug(`Peripheral ${peripheralAddress}: Error while unsubscribing`, error)
          ),
          (error) => logger.debug(`Peripheral ${peripheralAddress}: Error while disconnecting`, error)
        );
      },
      // callback to forward decoded and de-assembled Matter messages to ExchangeManager
      async (data) => {
        if (onMatterMessageListener === void 0) {
          throw new InternalError(`No listener registered for Matter messages`);
        }
        onMatterMessageListener(nobleChannel, data);
      }
    );
    characteristicC2ForSubscribe.on("data", (data, isNotification) => {
      logger.debug(
        `Peripheral ${peripheralAddress}: received data on C2: ${data.toString("hex")} (isNotification: ${isNotification})`
      );
      void btpSession.handleIncomingBleData(new Uint8Array(data));
    });
    const nobleChannel = new NobleBleChannel(peripheral, btpSession);
    return nobleChannel;
  }
  #connected = true;
  get connected() {
    return this.#connected && this.peripheral.state === "connected";
  }
  /**
   * Send a Matter message to the connected device - need to do BTP assembly first.
   *
   * @param data
   */
  async send(data) {
    if (!this.connected) {
      logger.debug(
        `Peripheral ${this.peripheral.address}: Cannot send data because not connected to peripheral.`
      );
      return;
    }
    if (this.btpSession === void 0) {
      throw new BtpFlowError(
        `Peripheral ${this.peripheral.address}: Cannot send data, no BTP session initialized`
      );
    }
    await this.btpSession.sendMatterMessage(data);
  }
  // Channel<Bytes>
  get name() {
    return `${this.type}://${this.peripheral.address}`;
  }
  async close() {
    await this.btpSession.close();
    if (this.connected) {
      this.peripheral.disconnectAsync().catch(
        (error) => logger.error(`Peripheral ${this.peripheral.address}: Error while disconnecting`, error)
      );
    }
  }
}
export {
  NobleBleCentralInterface,
  NobleBleChannel
};
//# sourceMappingURL=NobleBleChannel.js.map
