/**
 * @license
 * Copyright 2025-2026 Open Home Foundation
 * SPDX-License-Identifier: Apache-2.0
 */
import {
  Abort,
  AsyncObservable,
  camelize,
  CommissioningClient,
  FabricIndex,
  isObject,
  Logger,
  MatterAggregateError,
  Millis,
  Minutes,
  NodeId,
  Observable,
  Seconds,
  ServerAddress,
  SoftwareUpdateManager,
  Time,
  DnsRecordType
} from "@matter/main";
import { OperationalCredentialsClient } from "@matter/main/behaviors";
import {
  AccessControl,
  BasicInformation,
  Binding,
  BridgedDeviceBasicInformation,
  GeneralCommissioning,
  OperationalCredentials
} from "@matter/main/clusters";
import { WebRtcTransportProvider } from "@matter/main/clusters/web-rtc-transport-provider";
import { DeviceAttestationCheck, Invoke, PeerAddress, Read, PeerSet } from "@matter/main/protocol";
import {
  AttributeId,
  ClusterId,
  ClusterType,
  DeviceTypeId,
  EndpointNumber,
  GroupId,
  ManualPairingCodeCodec,
  QrPairingCodeCodec,
  SpecificationVersion,
  Status,
  StatusResponseError,
  VendorId
} from "@matter/main/types";
import { NodeStates } from "@project-chip/matter.js/device";
import { ClusterMap, GlobalAttributes } from "../model/ModelMapper.js";
import {
  buildAttributePath,
  convertCommandDataToMatter,
  convertMatterToWebSocketTagBased,
  convertWebSocketTagBasedToMatter,
  getDateAsString,
  splitAttributePath,
  toBigIntAwareJson
} from "../server/Converters.js";
import {
  ServerError,
  UpdateSource
} from "../types/WebSocketMessageTypes.js";
import { formatNodeId } from "../util/formatNodeId.js";
import { pingIp } from "../util/network.js";
import { WebRtcTransportRequestorServer } from "./behaviors/WebRtcTransportRequestorServer.js";
import { CustomClusterPoller } from "./CustomClusterPoller.js";
import { Nodes } from "./Nodes.js";
import { attachWebRtcCallbackBridge } from "./WebRtcCallbackBridge.js";
const logger = Logger.get("ControllerCommandHandler");
const RECONNECT_TIMEOUT = Minutes(3);
const FULL_UPDATE_CLUSTER_IDS = /* @__PURE__ */ new Set([BasicInformation.id, BridgedDeviceBasicInformation.id]);
function determineMatterVersion(attributes) {
  const specificationVersion = attributes["0/40/21"];
  const dataModelRevision = attributes["0/40/0"];
  if (typeof specificationVersion === "number" && specificationVersion > 0) {
    const { major, minor, patch } = SpecificationVersion.decode(specificationVersion);
    return `${major}.${minor}.${patch}`;
  }
  if (typeof dataModelRevision === "number") {
    if (dataModelRevision <= 16) {
      return "<1.2.0";
    } else if (dataModelRevision === 17) {
      return "1.2.0";
    }
  }
  return void 0;
}
class ControllerCommandHandler {
  #controller;
  #started = false;
  #connected = false;
  #bleEnabled;
  #bleProxyEnabled;
  #otaEnabled;
  /** Node management and attribute cache */
  #nodes = new Nodes();
  /** Cache of available updates keyed by nodeId */
  #availableUpdates = /* @__PURE__ */ new Map();
  /** Poller for custom cluster attributes (Eve energy, etc.) */
  #customClusterPoller;
  /** Per-node timers that fire when Reconnecting state exceeds the timeout */
  #reconnectTimers = /* @__PURE__ */ new Map();
  /** Track in-flight invoke-commands for deduplication across all WebSocket connections */
  #inFlightInvokes = /* @__PURE__ */ new Map();
  events = {
    started: new AsyncObservable(),
    attributeChanged: new Observable(),
    eventChanged: new Observable(),
    nodeAdded: new Observable(),
    nodeStateChanged: new Observable(),
    /** Emitted when node availability changes (for sending node_updated events) */
    nodeAvailabilityChanged: new Observable(),
    nodeStructureChanged: new Observable(),
    nodeDecommissioned: new Observable(),
    nodeEndpointAdded: new Observable(),
    nodeEndpointRemoved: new Observable(),
    webRtcCallback: new Observable()
  };
  #peers;
  constructor(controllerInstance, bleEnabled, bleProxyEnabled, otaEnabled) {
    this.#controller = controllerInstance;
    this.#bleEnabled = bleEnabled;
    this.#bleProxyEnabled = bleProxyEnabled;
    logger.info(`BLE is ${bleEnabled ? "enabled" : "disabled"}${bleProxyEnabled ? " (proxy mode)" : ""}`);
    this.#otaEnabled = otaEnabled;
    this.#customClusterPoller = new CustomClusterPoller({
      nodeConnected: (peer) => !!(this.#nodes.has(peer.nodeId) && this.#nodes.get(peer.nodeId).isConnected),
      handleReadAttributes: (peer, paths, fabricFiltered) => this.handleReadAttributes(peer.nodeId, paths, fabricFiltered)
    });
  }
  /**
   * Build the canonical PeerAddress for the given node on this controller's fabric.
   *
   * Throws if the controller's fabric is not yet resolved. Callers must run after
   * controller start; a silent fallback would intern PeerAddressMap entries under the
   * wrong fabric index and leak poller registrations.
   */
  #peerOf(nodeId) {
    const fabric = this.#controller.fabric;
    if (fabric === void 0) {
      throw new Error(`Cannot resolve PeerAddress for node ${nodeId}: controller fabric is not initialized`);
    }
    return PeerAddress({ fabricIndex: fabric.fabricIndex, nodeId });
  }
  /**
   * Format a NodeId as a PeerAddress string for logging.
   * Uses the controller's fabric index when available, otherwise "?" is used.
   */
  formatNode(nodeId) {
    const fabricIndex = this.#controller.fabric?.fabricIndex;
    return formatNodeId(nodeId, fabricIndex);
  }
  get started() {
    return this.#started;
  }
  get bleEnabled() {
    return this.#bleEnabled;
  }
  get bleProxyEnabled() {
    return this.#bleProxyEnabled;
  }
  async start() {
    if (this.#started) {
      return;
    }
    this.#started = true;
    await this.#controller.start();
    logger.notice(`Matter Controller started`);
    this.#peers = this.#controller.node.env.get(PeerSet);
    if (this.#otaEnabled) {
      await this.#setupOtaEventHandlers();
    }
    await this.events.started.emit();
    await this.#setupWebRtcCallbackBridge();
  }
  /**
   * Set up event handlers for OTA update notifications from the SoftwareUpdateManager.
   */
  async #setupOtaEventHandlers() {
    if (!this.#otaEnabled) {
      return;
    }
    try {
      const otaProvider = this.#controller.otaProvider;
      if (!otaProvider) {
        logger.info("OTA provider not available");
        return;
      }
      const softwareUpdateManagerEvents = await otaProvider.act((agent) => agent.get(SoftwareUpdateManager).events);
      if (softwareUpdateManagerEvents === void 0) {
        logger.info("SoftwareUpdateManager not available");
        return;
      }
      softwareUpdateManagerEvents.updateAvailable.on(
        (peerAddress, updateDetails) => {
          logger.notice(`Update available for node ${this.formatNode(peerAddress.nodeId)}:`, updateDetails);
          this.#availableUpdates.set(peerAddress.nodeId, updateDetails);
        }
      );
      softwareUpdateManagerEvents.updateDone.on((peerAddress) => {
        logger.notice(`Update done for node ${this.formatNode(peerAddress.nodeId)}`);
        this.#availableUpdates.delete(peerAddress.nodeId);
      });
      logger.info("OTA event handlers registered");
    } catch (error) {
      logger.warn("Failed to setup OTA event handlers:", error);
    }
  }
  async #setupWebRtcCallbackBridge() {
    try {
      await this.#cameraControllerEndpoint().act((agent) => {
        attachWebRtcCallbackBridge(
          agent.get(WebRtcTransportRequestorServer).events,
          (data) => this.events.webRtcCallback.emit(data)
        );
      });
      logger.info("WebRTC callback bridge wired");
    } catch (error) {
      logger.warn("Failed to setup WebRTC callback bridge:", error);
    }
  }
  #cameraControllerEndpoint() {
    return this.#controller.node.endpoints.for("camera-controller");
  }
  /** `originatingEndpointId` is server-injected; any client-supplied value in `payload` is overwritten. */
  async sendWebRtcProviderCommand(args) {
    const { nodeId, endpointId, commandName, payload } = args;
    if (commandName !== "ProvideOffer" && commandName !== "SolicitOffer") {
      throw ServerError.invalidArguments(
        `Unsupported WebRTC provider command "${commandName}"; expected ProvideOffer or SolicitOffer`
      );
    }
    const requestorEndpoint = this.#cameraControllerEndpoint();
    const originatingEndpointId = EndpointNumber(requestorEndpoint.number);
    const fabricIndex = this.#controller.fabric.fabricIndex;
    const node = this.#nodes.get(nodeId);
    const fields = {
      ...payload,
      originatingEndpointId
    };
    const command = commandName === "ProvideOffer" ? "provideOffer" : "solicitOffer";
    const response = await this.#invokeCommand(node.node, {
      endpoint: endpointId,
      cluster: WebRtcTransportProvider,
      command,
      fields
    });
    if (response === void 0 || typeof response.webRtcSessionId !== "number") {
      throw ServerError.sdkStackError(
        `${commandName} did not return a WebRTCSessionID for node ${this.formatNode(nodeId)}`
      );
    }
    const streamUsage = payload.streamUsage;
    const metadataEnabled = payload.metadataEnabled ?? false;
    const videoStreams = response.videoStreamId != null ? [response.videoStreamId] : new Array();
    const audioStreams = response.audioStreamId != null ? [response.audioStreamId] : new Array();
    const session = {
      id: response.webRtcSessionId,
      peerNodeId: nodeId,
      peerEndpointId: endpointId,
      streamUsage,
      metadataEnabled,
      videoStreams,
      audioStreams,
      fabricIndex
    };
    logger.info(
      `upserting WebRTC session id=${session.id} peerNodeId=${nodeId} peerEndpointId=${endpointId} fabricIndex=${fabricIndex} streamUsage=${streamUsage} originatingEndpointId=${originatingEndpointId}`
    );
    await requestorEndpoint.act((agent) => {
      agent.get(WebRtcTransportRequestorServer).upsertSession(session);
    });
    return response;
  }
  async close() {
    for (const timer of this.#reconnectTimers.values()) {
      timer.stop();
    }
    this.#reconnectTimers.clear();
    await this.#customClusterPoller.stop();
    if (!this.#started) {
      return;
    }
    return this.#controller.close();
  }
  async #registerNode(nodeId) {
    const node = await this.#controller.getNode(nodeId);
    const attributeCache = this.#nodes.attributeCache;
    let basicInfoChangedInBatch = false;
    node.events.attributeChanged.on((data) => {
      attributeCache.updateAttribute(nodeId, data);
      this.events.attributeChanged.emit(nodeId, data);
      if (FULL_UPDATE_CLUSTER_IDS.has(data.path.clusterId)) {
        basicInfoChangedInBatch = true;
      }
    });
    node.events.connectionAlive.on(() => {
      if (basicInfoChangedInBatch) {
        basicInfoChangedInBatch = false;
        logger.info(`Node ${this.formatNode(nodeId)} basic information changed, sending full node_updated`);
        this.events.nodeStructureChanged.emit(nodeId);
      }
    });
    node.events.eventTriggered.on((data) => this.events.eventChanged.emit(nodeId, data));
    node.events.stateChanged.on((state) => {
      if (state === NodeStates.Connected) {
        attributeCache.update(node);
        const attributes = attributeCache.get(nodeId);
        if (attributes) {
          this.#customClusterPoller.registerNode(this.#peerOf(nodeId), attributes);
        }
      }
      if (state === NodeStates.Connected) {
        this.#reconnectTimers.get(nodeId)?.stop();
        this.#reconnectTimers.delete(nodeId);
      } else if (state === NodeStates.Reconnecting && !this.#reconnectTimers.has(nodeId) && this.#nodes.isAvailable(nodeId)) {
        const timer = Time.getTimer(`reconnect-timeout-${nodeId}`, RECONNECT_TIMEOUT, () => {
          this.#reconnectTimers.delete(nodeId);
          if (this.#nodes.forceUnavailable(nodeId)) {
            logger.warn(
              `Node ${this.formatNode(nodeId)} offline grace period expired, marking unavailable`
            );
            this.events.nodeAvailabilityChanged.emit(nodeId, false);
          }
        });
        timer.utility = true;
        timer.start();
        this.#reconnectTimers.set(nodeId, timer);
      }
      const debouncePending = this.#reconnectTimers.has(nodeId);
      const result = this.#nodes.processStateChange(nodeId, state, debouncePending);
      this.events.nodeStateChanged.emit(nodeId, state);
      if (result.availabilityChanged) {
        const availabilityMessage = `Node ${this.formatNode(nodeId)} availability changed to ${result.available} (state: ${NodeStates[state]})`;
        if (result.available) {
          logger.notice(availabilityMessage);
        } else {
          logger.warn(availabilityMessage);
        }
        this.events.nodeAvailabilityChanged.emit(nodeId, result.available);
      }
    });
    node.events.structureChanged.on(() => {
      if (node.isConnected) {
        attributeCache.update(node);
      }
      basicInfoChangedInBatch = false;
      this.events.nodeStructureChanged.emit(nodeId);
      for (const endpointId of this.#nodes.drainPendingEndpointAdds(nodeId)) {
        this.events.nodeEndpointAdded.emit(nodeId, endpointId);
      }
    });
    node.events.decommissioned.on(() => {
      this.#cleanupNodeAfterRemoval(nodeId);
      this.events.nodeDecommissioned.emit(nodeId);
    });
    node.events.nodeEndpointAdded.on((endpointId) => this.#nodes.queueEndpointAdded(nodeId, endpointId));
    node.events.nodeEndpointRemoved.on((endpointId) => this.events.nodeEndpointRemoved.emit(nodeId, endpointId));
    this.#nodes.set(nodeId, node);
    this.#nodes.seedState(nodeId, node.connectionState);
    if (node.initialized) {
      attributeCache.add(node);
      const attributes = attributeCache.get(nodeId);
      if (attributes) {
        this.#customClusterPoller.registerNode(this.#peerOf(nodeId), attributes);
      }
    }
    return node;
  }
  /**
   * Initialize the controller, register all commissioned nodes (populates attribute caches),
   * and start connecting them to the network.
   *
   * Guarded by #connected so it runs exactly once, even if called multiple times
   * (e.g. when WebServer.start() registers handlers for multiple listen addresses).
   */
  async initializeNodes() {
    if (this.#connected) {
      return;
    }
    this.#connected = true;
    await this.start();
    const nodes = this.#controller.getCommissionedNodes();
    logger.info(`Found ${nodes.length} nodes: ${nodes.map((nodeId) => this.formatNode(nodeId)).join(", ")}`);
    for (const nodeId of nodes) {
      try {
        logger.info(`Initializing node "${this.formatNode(nodeId)}" ...`);
        await this.#registerNode(nodeId);
      } catch (error) {
        logger.warn(`Failed to initialize node "${this.formatNode(nodeId)}":`, error);
      }
    }
    logger.info(`All ${nodes.length} nodes initialized, starting connections`);
    for (const nodeId of this.#nodes.getIds()) {
      try {
        this.#nodes.get(nodeId).connect({
          subscribeMinIntervalFloorSeconds: 1,
          subscribeMaxIntervalCeilingSeconds: void 0
        });
      } catch (error) {
        logger.warn(`Failed to connect node "${this.formatNode(nodeId)}":`, error);
      }
    }
  }
  getNodeIds() {
    return this.#nodes.getIds();
  }
  hasNode(nodeId) {
    return this.#nodes.has(nodeId);
  }
  /**
   * Whether a node id is already reserved on the fabric, either by a commissioned peer or the commissioner itself.
   * Authoritative against matter.js rather than the locally tracked node set, which can drift from the fabric.
   */
  isNodeIdInUse(nodeId) {
    return nodeId === this.#controller.nodeId || this.#controller.isNodeCommissioned(nodeId);
  }
  /**
   * Alias for decommissionNode to match NodeCommandHandler interface.
   */
  removeNode(nodeId) {
    return this.decommissionNode(nodeId);
  }
  async interviewNode(nodeId) {
    const node = this.#nodes.get(nodeId);
    logger.info(`Interview requested for node ${this.formatNode(nodeId)} - do a complete read`);
    const read = {
      ...Read({
        fabricFilter: true,
        attributes: [{}]
      }),
      includeKnownVersions: true
      // do not send DataVersionFilters, so we do a new clean read
    };
    for await (const _chunk of node.node.interaction.read(read)) ;
  }
  /**
   * Get full node details in WebSocket API format.
   * @param nodeId The node ID
   * @param lastInterviewDate Optional last interview date (tracked externally)
   */
  getNodeDetails(nodeId, lastInterviewDate) {
    const node = this.#nodes.get(nodeId);
    const attributeCache = this.#nodes.attributeCache;
    let isBridge = false;
    if (!attributeCache.has(nodeId)) {
      attributeCache.add(node);
    }
    const attributes = attributeCache.get(nodeId) ?? {};
    const endpoint1DeviceTypes = attributes["1/29/0"];
    if (Array.isArray(endpoint1DeviceTypes)) {
      isBridge = endpoint1DeviceTypes.some((entry) => entry["0"] === 14);
    }
    return {
      node_id: node.nodeId,
      date_commissioned: getDateAsString(new Date(node.state.commissioning.commissionedAt ?? Date.now())),
      last_interview: getDateAsString(lastInterviewDate ?? /* @__PURE__ */ new Date()),
      interview_version: 6,
      available: this.#nodes.isAvailable(nodeId),
      is_bridge: isBridge,
      attributes,
      attribute_subscriptions: [],
      matter_version: determineMatterVersion(attributes)
    };
  }
  /**
   * Read multiple attributes from a node by path strings.
   * Supports wildcards in paths. Batches up to 9 paths per read call.
   */
  async handleReadAttributes(nodeId, attributePaths, fabricFiltered = false) {
    const result = {};
    const node = this.#nodes.get(nodeId);
    const batchSize = 9;
    const parsedPaths = attributePaths.map((path) => splitAttributePath(path));
    for (let i = 0; i < parsedPaths.length; i += batchSize) {
      const batch = parsedPaths.slice(i, i + batchSize);
      const readRequest = {
        ...Read({
          fabricFilter: fabricFiltered,
          attributes: batch.map(({ endpointId, clusterId, attributeId }) => ({
            endpointId: endpointId !== void 0 ? EndpointNumber(endpointId) : void 0,
            clusterId: clusterId !== void 0 ? ClusterId(clusterId) : void 0,
            attributeId: attributeId !== void 0 ? AttributeId(attributeId) : void 0
          }))
        }),
        includeKnownVersions: true
      };
      for await (const chunk of node.node.interaction.read(readRequest)) {
        for await (const entry of chunk) {
          if (entry.kind === "attr-value") {
            const { pathStr, value: wsValue } = this.#convertAttributeToWebSocket(
              {
                endpointId: EndpointNumber(entry.path.endpointId),
                clusterId: ClusterId(entry.path.clusterId),
                attributeId: entry.path.attributeId
              },
              entry.value
            );
            result[pathStr] = wsValue;
          } else if (entry.kind === "attr-status") {
            const pathStr = buildAttributePath(
              entry.path.endpointId,
              entry.path.clusterId,
              entry.path.attributeId
            );
            logger.warn(`Failed to read attribute ${pathStr}: status=${entry.status}`);
          }
        }
      }
    }
    return result;
  }
  /**
   * Convert attribute data to WebSocket tag-based format.
   */
  #convertAttributeToWebSocket(path, value, clusterData) {
    const { endpointId, clusterId, attributeId } = path;
    if (!clusterData) {
      clusterData = ClusterMap[clusterId];
    }
    return {
      pathStr: buildAttributePath(endpointId, clusterId, attributeId),
      value: convertMatterToWebSocketTagBased(
        value,
        clusterData?.attributes[attributeId] ?? GlobalAttributes[attributeId],
        clusterData?.model
      )
    };
  }
  /**
   * Write a single attribute on a remote node. Uses `setStateOf(string, ...)` (not `set({...})`)
   * because peer cluster behaviors are dynamically registered and aren't on the agent's cached property getters.
   */
  async #writeAttribute(nodeId, endpointId, clusterId, attributeName, value) {
    const node = this.#nodes.get(nodeId);
    const clusterEntry = ClusterMap[clusterId];
    if (!clusterEntry) {
      throw ServerError.invalidArguments(`Cluster Id "${clusterId}" unknown`);
    }
    const clusterProperty = clusterEntry.model.propertyName;
    try {
      await node.node.endpoints.for(endpointId).setStateOf(clusterProperty, { [attributeName]: value });
      return { status: 0 };
    } catch (error) {
      if (error instanceof MatterAggregateError) {
        const first = error.errors.find((e) => e instanceof StatusResponseError);
        if (first !== void 0) {
          const dropped = error.errors.filter((e) => e !== first);
          if (dropped.length > 0) {
            logger.info(
              `Write aggregate: reporting first error, dropping ${dropped.length} additional`,
              dropped
            );
          }
          return { status: first.code, clusterStatus: first.clusterCode };
        }
        throw error;
      }
      StatusResponseError.accept(error);
      return { status: error.code, clusterStatus: error.clusterCode };
    }
  }
  /**
   * Set the fabric label. Pass null or empty string to reset to "Home".
   * Note: matter.js requires non-empty labels (1-32 chars), so null/empty resets to default.
   */
  async setFabricLabel(label) {
    await this.#controller.updateFabricLabel(label);
  }
  async handleWriteAttribute(data) {
    const { nodeId, endpointId, clusterId, attributeId } = data;
    let { value } = data;
    const clusterEntry = ClusterMap[clusterId];
    const attributeModel = clusterEntry?.attributes[attributeId];
    if (!clusterEntry || !attributeModel) {
      throw ServerError.invalidArguments(`Attribute ${attributeId} on cluster ${clusterId} unknown`);
    }
    value = convertWebSocketTagBasedToMatter(value, attributeModel, clusterEntry.model);
    const attributeName = attributeModel.propertyName;
    logger.info(`Writing attribute ${clusterId}.${attributeName} (${clusterId}.${attributeId}) with value`, value);
    const { status, clusterStatus } = await this.#writeAttribute(
      nodeId,
      endpointId,
      clusterId,
      attributeModel.propertyName,
      value
    );
    return { attributeId, clusterId, endpointId, status, clusterStatus };
  }
  async #invokeCommand(node, request, options = {}) {
    const invoke = Invoke({
      commands: [request],
      ...options
    });
    for await (const data of node.interaction.invoke(invoke)) {
      for (const entry of data) {
        switch (entry.kind) {
          case "cmd-status":
            if (entry.status !== Status.Success) {
              throw StatusResponseError.create(entry.status, void 0, entry.clusterStatus);
            }
            return;
          case "cmd-response":
            return entry.data;
        }
      }
    }
  }
  async handleInvoke(data) {
    const {
      nodeId,
      endpointId,
      clusterId,
      timedInteractionTimeoutMs: timedRequestTimeoutMs,
      interactionTimeoutMs
    } = data;
    let { data: commandData } = data;
    const clusterEntry = ClusterMap[clusterId];
    if (!clusterEntry) {
      throw ServerError.invalidArguments(`Cluster Id "${clusterId}" unknown`);
    }
    const clusterName = clusterEntry.model.propertyName;
    const commandName = camelize(data.commandName);
    const commands = this.#nodes.get(nodeId).node.endpoints.for(endpointId).commands[clusterName];
    if (!commands[commandName]) {
      throw ServerError.invalidArguments(`Command "${commandName}" does not exist on cluster "${clusterName}"`);
    }
    if (isObject(commandData)) {
      if (Object.keys(commandData).length === 0) {
        commandData = void 0;
      } else {
        const model = clusterEntry.commands[commandName.toLowerCase()];
        if (model) {
          commandData = convertCommandDataToMatter(commandData, model, clusterEntry.model);
        }
      }
    }
    const serializedData = commandData !== void 0 ? toBigIntAwareJson(commandData) : "";
    const dedupKey = `${nodeId}:${endpointId}:${clusterId}:${commandName}:${serializedData}`;
    const existing = this.#inFlightInvokes.get(dedupKey);
    if (existing !== void 0) {
      logger.warn(
        `Duplicate command detected for ${this.formatNode(nodeId)}/${endpointId}/${clusterName}/${commandName} - coalescing with in-flight request`
      );
      return existing;
    }
    const cluster = ClusterType(clusterEntry.model);
    const invokePromise = this.#invokeCommand(
      this.#nodes.get(nodeId).node,
      {
        endpoint: endpointId,
        cluster,
        command: commandName,
        fields: commandData
      },
      {
        timeout: timedRequestTimeoutMs !== void 0 ? Millis(timedRequestTimeoutMs) : void 0,
        expectedProcessingTime: interactionTimeoutMs !== void 0 ? Millis(interactionTimeoutMs) : void 0
      }
    );
    this.#inFlightInvokes.set(dedupKey, invokePromise);
    return invokePromise.finally(() => {
      this.#inFlightInvokes.delete(dedupKey);
    });
  }
  #determineCommissionOptions(data) {
    let passcode = void 0;
    let shortDiscriminator = void 0;
    let longDiscriminator = void 0;
    let productId = void 0;
    let vendorId = void 0;
    let knownAddress = void 0;
    if ("manualCode" in data && data.manualCode.length > 0) {
      const pairingCodeCodec = ManualPairingCodeCodec.decode(data.manualCode);
      shortDiscriminator = pairingCodeCodec.shortDiscriminator;
      longDiscriminator = void 0;
      passcode = pairingCodeCodec.passcode;
    } else if ("qrCode" in data && data.qrCode.length > 0) {
      const pairingCodeCodec = QrPairingCodeCodec.decode(data.qrCode);
      longDiscriminator = pairingCodeCodec[0].discriminator;
      shortDiscriminator = void 0;
      passcode = pairingCodeCodec[0].passcode;
    } else if ("passcode" in data) {
      passcode = data.passcode;
      if ("shortDiscriminator" in data) {
        shortDiscriminator = data.shortDiscriminator;
      } else if ("longDiscriminator" in data) {
        longDiscriminator = data.longDiscriminator;
      } else if ("vendorId" in data && "productId" in data) {
        vendorId = VendorId(data.vendorId);
        productId = data.productId;
      }
    } else {
      throw ServerError.invalidArguments("No pairing code provided");
    }
    if (data.knownAddress !== void 0) {
      const { ip, port } = data.knownAddress;
      knownAddress = {
        type: "udp",
        ip,
        port
      };
    }
    if (passcode == void 0) {
      throw ServerError.invalidArguments("No passcode provided");
    }
    const { onNetworkOnly, wifiCredentials: wifiNetwork, threadCredentials: threadNetwork } = data;
    return {
      commissioning: {
        nodeId: data.nodeId,
        regulatoryLocation: GeneralCommissioning.RegulatoryLocationType.IndoorOutdoor,
        regulatoryCountryCode: "XX",
        wifiNetwork,
        threadNetwork,
        onAttestationFailure: (findings) => {
          let testCertReason;
          let hardError = false;
          for (const f of findings) {
            if (f.type === DeviceAttestationCheck.TrustedAsTestCertificate) {
              testCertReason = 'Device uses a test/development certificate. Enable the "Test Net DCL" option (--enable-test-net-dcl) to commission test or development devices';
            } else if (f.level === "error") {
              hardError = true;
            }
            logger.info(`Attestation finding (${f.level}):`, f.type, f.message);
          }
          if (testCertReason !== void 0) {
            logger.notice(`Attestation rejected: ${testCertReason}`);
            return testCertReason;
          }
          logger.info(`Attestation ${hardError ? "rejected" : "accepted"}`);
          return !hardError;
        }
      },
      discovery: {
        knownAddress,
        identifierData: longDiscriminator !== void 0 ? { longDiscriminator } : shortDiscriminator !== void 0 ? { shortDiscriminator } : vendorId !== void 0 ? { vendorId, productId } : {},
        discoveryCapabilities: {
          ble: this.bleEnabled && !onNetworkOnly,
          onIpNetwork: true
        }
      },
      passcode
    };
  }
  async commissionNode(data) {
    let nodeId;
    try {
      nodeId = await this.#controller.commissionNode(this.#determineCommissionOptions(data), {
        connectNodeAfterCommissioning: true
      });
    } catch (error) {
      const originalMessage = error instanceof Error ? error.message : String(error);
      throw ServerError.nodeCommissionFailed(
        `Commission failed: ${originalMessage}`,
        error instanceof Error ? error : void 0
      );
    }
    await this.#registerNode(nodeId);
    this.events.nodeAdded.emit(nodeId);
    return { nodeId };
  }
  getCommissionerNodeId() {
    return this.#controller.nodeId;
  }
  async getCommissionerFabricData() {
    const { fabricId, globalId, fabricIndex } = this.#controller.fabric;
    return {
      fabricId,
      compressedFabricId: globalId,
      fabricIndex
    };
  }
  /** Discover commissionable devices */
  async handleDiscovery({ findBy }) {
    const result = await this.#controller.discoverCommissionableDevices(
      findBy ?? {},
      { onIpNetwork: true },
      void 0,
      Seconds(3)
      // Just check for 3 sec
    );
    logger.info("Discovered result", result);
    const latestDiscovery = result[result.length - 1];
    if (latestDiscovery === void 0) {
      return [];
    }
    return [latestDiscovery].map(({ DT, DN, CM, D, RI, PH, PI, T, VP, deviceIdentifier, addresses, SII, SAI }) => {
      const supportsTcpClient = T?.tcpClient ?? false;
      const supportsTcpServer = T?.tcpServer ?? false;
      const vendorId = VP === void 0 ? -1 : VP.includes("+") ? parseInt(VP.split("+")[0]) : parseInt(VP);
      const productId = VP === void 0 ? -1 : VP.includes("+") ? parseInt(VP.split("+")[1]) : -1;
      const firstAddress = addresses[0];
      const port = firstAddress && ServerAddress.isIp(firstAddress) ? firstAddress.port : 0;
      const numIPs = addresses.length;
      return {
        commissioningMode: CM,
        deviceName: DN ?? "",
        deviceType: DT ?? 0,
        hostName: "000000000000",
        // Right now we do not return real hostname, only used internally
        instanceName: deviceIdentifier,
        longDiscriminator: D,
        numIPs,
        pairingHint: PH ?? -1,
        pairingInstruction: PI ?? "",
        port,
        productId,
        rotatingId: RI ?? "",
        rotatingIdLen: RI?.length ?? 0,
        shortDiscriminator: D >> 8 & 15,
        vendorId,
        supportsTcpServer,
        supportsTcpClient,
        addresses: addresses.filter(ServerAddress.isIp).map(({ ip }) => ip),
        mrpSessionIdleInterval: SII,
        mrpSessionActiveInterval: SAI
      };
    });
  }
  async getNodeIpAddresses(nodeId, preferCache = true) {
    const addresses = /* @__PURE__ */ new Set();
    const peer = this.#peers?.for(this.#controller.fabric.addressOf(nodeId));
    if (peer) {
      for (const address of peer.service.addresses) {
        addresses.add(address.ip);
      }
    }
    if (!preferCache || !addresses.size) {
      for (const address of await this.#discoverNodeAddressesViaMdns(nodeId)) {
        addresses.add(address);
      }
    }
    if (peer) {
      const sessionIp = peer.newestSession()?.channel.networkAddress?.ip;
      if (sessionIp) {
        addresses.add(sessionIp);
      }
    }
    const node = this.#nodes.get(nodeId);
    const commissioningAddresses = node.node.maybeStateOf(CommissioningClient)?.addresses;
    if (commissioningAddresses !== void 0 && commissioningAddresses.length > 0) {
      const fallbackAddresses = commissioningAddresses.filter(ServerAddress.isIp).map((addr) => addr.ip);
      for (const address of fallbackAddresses) {
        addresses.add(address);
      }
    }
    return [...addresses.values()];
  }
  /**
   * Discover node IP addresses via mDNS (like Python matter server).
   * Uses 3-second timeout matching Python implementation.
   */
  async #discoverNodeAddressesViaMdns(nodeId) {
    try {
      if (this.#peers === void 0) {
        logger.debug(`Node ${this.formatNode(nodeId)}: No PeerSet available, Controller started?`);
        return [];
      }
      const peer = this.#peers.for(this.#controller.fabric.addressOf(nodeId));
      const abort = new Abort({ timeout: Seconds(3) });
      const names = peer.service.names;
      await names.solicitor.discover({
        abort,
        name: peer.service.name,
        recordTypes: [DnsRecordType.SRV]
      });
      const addresses = new Array();
      for (const address of peer.service.addresses) {
        addresses.push(address.ip);
      }
      return addresses;
    } catch (error) {
      logger.info(`Node ${this.formatNode(nodeId)}: mDNS discovery failed`, error);
      return [];
    }
  }
  /**
   * Ping a node on all its known IP addresses.
   * @param nodeId The node ID to ping
   * @param attempts Number of ping attempts per IP (default: 1)
   * @returns A record of IP addresses to ping success status
   */
  async pingNode(nodeId, attempts = 1) {
    const node = this.#nodes.get(nodeId);
    const result = {};
    const ipAddresses = await this.getNodeIpAddresses(nodeId, false);
    if (ipAddresses.length === 0) {
      logger.info(`No IP addresses found for node ${this.formatNode(nodeId)}`);
      return result;
    }
    logger.info(`Pinging node ${this.formatNode(nodeId)} on ${ipAddresses.length} addresses:`, ipAddresses);
    const pingPromises = ipAddresses.map(async (ip) => {
      const cleanIp = ip.includes("%") ? ip.split("%")[0] : ip;
      logger.debug(`Pinging ${cleanIp}`);
      const success = await pingIp(ip, 10, attempts);
      result[ip] = success;
      logger.debug(`Ping result for ${cleanIp}: ${success}`);
    });
    await Promise.all(pingPromises);
    if (node.isConnected) {
      const anySuccess = Object.values(result).some((v) => v);
      if (!anySuccess && ipAddresses.length > 0) {
        logger.info(`Node ${this.formatNode(nodeId)} is connected but no pings succeeded`);
      }
    }
    return result;
  }
  async decommissionNode(nodeId) {
    const node = this.#nodes.has(nodeId) ? this.#nodes.get(nodeId) : void 0;
    if (node === void 0) {
      throw ServerError.nodeNotExists(nodeId);
    }
    await this.#controller.removeNode(nodeId, !!node?.isConnected);
    this.#cleanupNodeAfterRemoval(nodeId);
  }
  /**
   * Drop all references to a removed node so subsequent reads don't reach a
   * destroyed PairedNode. Idempotent — both the `decommissioned` listener
   * (external fabric leave) and `decommissionNode` invoke it, and the
   * listener may have run first.
   */
  #cleanupNodeAfterRemoval(nodeId) {
    this.#reconnectTimers.get(nodeId)?.stop();
    this.#reconnectTimers.delete(nodeId);
    this.#nodes.delete(nodeId);
    this.#customClusterPoller.unregisterNode(this.#peerOf(nodeId));
    this.#availableUpdates.delete(nodeId);
  }
  async openCommissioningWindow(data) {
    const { nodeId, timeout } = data;
    const node = this.#nodes.get(nodeId);
    const { manualPairingCode, qrPairingCode } = await node.openEnhancedCommissioningWindow(timeout);
    return { manualCode: manualPairingCode, qrCode: qrPairingCode };
  }
  async getFabrics(nodeId) {
    const node = this.#nodes.get(nodeId);
    const read = {
      ...Read(
        {
          fabricFilter: false
        },
        Read.Attribute({
          endpoint: EndpointNumber(0),
          cluster: OperationalCredentials,
          attributes: "fabrics"
        })
      ),
      includeKnownVersions: true
      // we want to read from device
    };
    for await (const chunk of node.node.interaction.read(read)) {
      for await (const attr of chunk) {
        if (attr.kind === "attr-value" && Array.isArray(attr.value)) {
          return attr.value.map(({ fabricId, fabricIndex, vendorId, label }) => ({
            fabricId,
            vendorId,
            fabricIndex,
            label
          }));
        }
        logger.warn("Unexpected response from fabrics read", attr);
      }
    }
    throw ServerError.sdkStackError("No or invalid response received while querying fabrics");
  }
  removeFabric(nodeId, fabricIndex) {
    return this.#nodes.get(nodeId).node.commandsOf(OperationalCredentialsClient).removeFabric({ fabricIndex });
  }
  /**
   * Fabric index assigned to this controller on the peer.
   * Some peers reject the NO_FABRIC (0) sentinel inside fabric-scoped list entries
   * even though the spec allows server-side auto-fill, so we send the resolved index.
   */
  #currentFabricIndex(nodeId) {
    const state = this.#nodes.get(nodeId).node.endpoints.for(EndpointNumber(0)).maybeStateOf(OperationalCredentialsClient);
    return state?.currentFabricIndex ?? FabricIndex.NO_FABRIC;
  }
  /**
   * Set Access Control List entries on a node.
   * Writes to the ACL attribute on the AccessControl cluster (endpoint 0).
   */
  async setAclEntry(nodeId, entries) {
    const fabricIndex = this.#currentFabricIndex(nodeId);
    const aclEntries = entries.map((entry) => ({
      privilege: entry.privilege,
      authMode: entry.auth_mode,
      subjects: entry.subjects?.map((s) => NodeId(BigInt(s))) ?? null,
      targets: entry.targets?.map((t) => ({
        cluster: t.cluster !== null ? ClusterId(t.cluster) : null,
        endpoint: t.endpoint !== null ? EndpointNumber(t.endpoint) : null,
        deviceType: t.device_type !== null ? DeviceTypeId(t.device_type) : null
      })) ?? null,
      fabricIndex
    }));
    logger.info("Setting ACL entries", aclEntries);
    const { status } = await this.#writeAttribute(nodeId, EndpointNumber(0), AccessControl.id, "acl", aclEntries);
    return [
      {
        path: { endpoint_id: 0, cluster_id: AccessControl.id, attribute_id: 0 },
        status
      }
    ];
  }
  /**
   * Set bindings on a specific endpoint of a node.
   * Writes to the Binding attribute on the Binding cluster.
   */
  async setNodeBinding(nodeId, endpointId, bindings) {
    const fabricIndex = this.#currentFabricIndex(nodeId);
    const bindingEntries = bindings.map((binding) => ({
      node: binding.node !== null ? NodeId(binding.node) : void 0,
      group: binding.group !== null ? GroupId(binding.group) : void 0,
      endpoint: binding.endpoint !== null ? EndpointNumber(binding.endpoint) : void 0,
      cluster: binding.cluster !== null ? ClusterId(binding.cluster) : void 0,
      fabricIndex
    }));
    logger.info("Setting bindings on endpoint", endpointId, bindingEntries);
    const { status } = await this.#writeAttribute(nodeId, endpointId, Binding.id, "binding", bindingEntries);
    return [
      {
        path: { endpoint_id: endpointId, cluster_id: Binding.id, attribute_id: 0 },
        status
      }
    ];
  }
  /**
   * Check if a software update is available for a node.
   * First checks the cached updates from OTA events, then queries the DCL if not found.
   */
  async checkNodeUpdate(nodeId) {
    if (!this.#otaEnabled) {
      throw ServerError.updateCheckError("OTA is disabled");
    }
    const cachedUpdate = this.#availableUpdates.get(nodeId);
    if (cachedUpdate) {
      return this.#convertToMatterSoftwareVersion(cachedUpdate);
    }
    const node = this.#nodes.get(nodeId);
    try {
      const otaProvider = this.#controller.otaProvider;
      if (!otaProvider) {
        logger.info("OTA provider not available");
        return null;
      }
      const updatesAvailable = await otaProvider.act(
        (agent) => agent.get(SoftwareUpdateManager).queryUpdates({
          peerToCheck: node.node,
          includeStoredUpdates: true
        })
      );
      const peerAddress = this.#controller.fabric.addressOf(nodeId);
      const nodeUpdate = updatesAvailable.find(
        ({ peerAddress: updateAddress }) => PeerAddress.is(peerAddress, updateAddress)
      );
      if (nodeUpdate) {
        const { info } = nodeUpdate;
        this.#availableUpdates.set(nodeId, info);
        return this.#convertToMatterSoftwareVersion(info);
      }
      return null;
    } catch (error) {
      logger.warn(`Failed to check for updates for node ${this.formatNode(nodeId)}:`, error);
      return null;
    }
  }
  /**
   * Trigger a software update for a node.
   * @param nodeId The node to update
   * @param softwareVersion The target software version to update to
   */
  async updateNode(nodeId, softwareVersion) {
    if (!this.#otaEnabled) {
      throw ServerError.updateError("OTA is disabled");
    }
    if (!this.#nodes.has(nodeId)) {
      throw ServerError.nodeNotExists(nodeId);
    }
    if (!this.#nodes.isAvailable(nodeId)) {
      throw ServerError.updateError(`Node ${this.formatNode(nodeId)} is not connected. Retry later`);
    }
    const cachedAttributes = this.#nodes.attributeCache.get(nodeId);
    const updateState = cachedAttributes?.["0/42/2"];
    if (updateState !== void 0 && updateState !== 1) {
      throw ServerError.updateError(
        `Node ${this.formatNode(nodeId)} is already in the process of updating (state: ${updateState})`
      );
    }
    const otaProvider = this.#controller.otaProvider;
    if (!otaProvider) {
      throw ServerError.updateError("OTA provider not available");
    }
    let updateInfo = this.#availableUpdates.get(nodeId);
    if (!updateInfo) {
      const result = await this.checkNodeUpdate(nodeId);
      if (!result) {
        throw ServerError.updateError("No update available for this node");
      }
      updateInfo = this.#availableUpdates.get(nodeId);
      if (!updateInfo) {
        throw ServerError.updateError("Failed to get update info");
      }
    }
    logger.info(`Starting update for node ${this.formatNode(nodeId)} to version ${softwareVersion}`);
    await otaProvider.act(
      (agent) => agent.get(SoftwareUpdateManager).forceUpdate(
        this.#controller.fabric.addressOf(nodeId),
        updateInfo.vendorId,
        updateInfo.productId,
        softwareVersion
      )
    );
    return this.#convertToMatterSoftwareVersion(updateInfo);
  }
  /**
   * Convert SoftwareUpdateInfo to MatterSoftwareVersion format for WebSocket API.
   */
  #convertToMatterSoftwareVersion(updateInfo) {
    const { vendorId, productId, softwareVersion, softwareVersionString, releaseNotesUrl, source } = updateInfo;
    return {
      vid: vendorId,
      pid: productId,
      software_version: softwareVersion,
      software_version_string: softwareVersionString,
      min_applicable_software_version: 0,
      // Not available from SoftwareUpdateInfo
      max_applicable_software_version: softwareVersion - 1,
      release_notes_url: releaseNotesUrl,
      update_source: source === "dcl-prod" ? UpdateSource.MAIN_NET_DCL : source === "dcl-test" ? UpdateSource.TEST_NET_DCL : UpdateSource.LOCAL
    };
  }
}
export {
  ControllerCommandHandler
};
//# sourceMappingURL=ControllerCommandHandler.js.map
