/**
 * @license
 * Copyright 2025-2026 Open Home Foundation
 * SPDX-License-Identifier: Apache-2.0
 */
import {
  Abort,
  AsyncObservable,
  camelize,
  CommissioningClient,
  FabricIndex,
  isObject,
  Logger,
  Millis,
  NodeId,
  Observable,
  Seconds,
  SoftwareUpdateManager,
  DnsRecordType
} from "@matter/main";
import { OperationalCredentialsClient } from "@matter/main/behaviors";
import {
  AccessControl,
  BasicInformation,
  Binding,
  BridgedDeviceBasicInformation,
  GeneralCommissioning,
  OperationalCredentials
} from "@matter/main/clusters";
import {
  Invoke,
  PeerAddress,
  Read,
  SupportedTransportsSchema,
  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 { CustomClusterPoller } from "./CustomClusterPoller.js";
import { Nodes } from "./Nodes.js";
const logger = Logger.get("ControllerCommandHandler");
const FULL_UPDATE_CLUSTER_IDS = /* @__PURE__ */ new Set([
  BasicInformation.Cluster.id,
  BridgedDeviceBasicInformation.Cluster.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 = false;
  #otaEnabled = false;
  /** 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;
  /** Track the last known availability for each node to detect changes */
  #lastAvailability = /* @__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()
  };
  #peers;
  constructor(controllerInstance, bleEnabled, otaEnabled) {
    this.#controller = controllerInstance;
    this.#bleEnabled = bleEnabled;
    logger.info(`BLE is ${bleEnabled ? "enabled" : "disabled"}`);
    this.#otaEnabled = otaEnabled;
    this.#customClusterPoller = new CustomClusterPoller({
      nodeConnected: (nodeId) => !!(this.#nodes.has(nodeId) && this.#nodes.get(nodeId).isConnected),
      handleReadAttributes: (nodeId, paths, fabricFiltered) => this.handleReadAttributes(nodeId, paths, fabricFiltered)
    });
  }
  /**
   * 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;
  }
  async start() {
    if (this.#started) {
      return;
    }
    this.#started = true;
    await this.#controller.start();
    logger.info(`Controller started`);
    this.#peers = this.#controller.node.env.get(PeerSet);
    if (this.#otaEnabled) {
      await this.#setupOtaEventHandlers();
    }
    await this.events.started.emit();
  }
  /**
   * 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.info(`Update available for node ${peerAddress.nodeId}:`, updateDetails);
          this.#availableUpdates.set(peerAddress.nodeId, updateDetails);
        }
      );
      softwareUpdateManagerEvents.updateDone.on((peerAddress) => {
        logger.info(`Update done for node ${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 close() {
    if (!this.#started) {
      return;
    }
    await this.#customClusterPoller.stop();
    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.Disconnected) {
        return;
      }
      const previousState = this.#nodes.getPreviousState(nodeId);
      const wasAvailable = this.#lastAvailability.get(nodeId) ?? false;
      const isAvailable = this.#nodes.isNodeAvailable(state, previousState);
      if (state === NodeStates.Connected) {
        attributeCache.update(node);
        const attributes = attributeCache.get(nodeId);
        if (attributes) {
          this.#customClusterPoller.registerNode(nodeId, attributes);
        }
      }
      this.#nodes.setPreviousState(nodeId, state);
      this.#lastAvailability.set(nodeId, isAvailable);
      this.events.nodeStateChanged.emit(nodeId, state);
      if (wasAvailable !== isAvailable) {
        logger.info(
          `Node ${this.formatNode(nodeId)} availability changed: ${wasAvailable} -> ${isAvailable} (state: ${NodeStates[previousState ?? -1]} -> ${NodeStates[state]})`
        );
        this.events.nodeAvailabilityChanged.emit(nodeId, isAvailable);
      }
    });
    node.events.structureChanged.on(() => {
      if (node.isConnected) {
        attributeCache.update(node);
      }
      basicInfoChangedInBatch = false;
      this.events.nodeStructureChanged.emit(nodeId);
    });
    node.events.decommissioned.on(() => this.events.nodeDecommissioned.emit(nodeId));
    node.events.nodeEndpointAdded.on((endpointId) => this.events.nodeEndpointAdded.emit(nodeId, endpointId));
    node.events.nodeEndpointRemoved.on((endpointId) => this.events.nodeEndpointRemoved.emit(nodeId, endpointId));
    this.#nodes.set(nodeId, node);
    const initialState = node.connectionState;
    this.#nodes.setPreviousState(nodeId, initialState);
    if (initialState === NodeStates.Connected) {
      this.#lastAvailability.set(nodeId, true);
    }
    if (node.initialized) {
      attributeCache.add(node);
      const attributes = attributeCache.get(nodeId);
      if (attributes) {
        this.#customClusterPoller.registerNode(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);
  }
  /**
   * 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 client = this.#nodes.interactionClientFor(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 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
      }));
      const { attributeData, attributeStatus } = await client.getMultipleAttributesAndStatus({
        attributes,
        isFabricFiltered: fabricFiltered
      });
      for (const { path: attrPath, value } of attributeData) {
        const { pathStr, value: wsValue } = this.#convertAttributeToWebSocket(
          {
            endpointId: EndpointNumber(attrPath.endpointId),
            clusterId: ClusterId(attrPath.clusterId),
            attributeId: attrPath.attributeId
          },
          value
        );
        result[pathStr] = wsValue;
      }
      if (attributeStatus && attributeStatus.length > 0) {
        for (const { path: attrPath, status } of attributeStatus) {
          const pathStr = buildAttributePath(attrPath.endpointId, attrPath.clusterId, attrPath.attributeId);
          logger.warn(`Failed to read attribute ${pathStr}: status=${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
      )
    };
  }
  /**
   * 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);
  }
  disconnectNode(nodeId) {
    return this.#controller.disconnectNode(nodeId, true);
  }
  async handleReadEvent(data) {
    const { nodeId, endpointId, clusterId, eventId, eventMin } = data;
    const client = this.#nodes.interactionClientFor(nodeId);
    const { eventData, eventStatus } = await client.getMultipleEventsAndStatus({
      events: [
        {
          endpointId,
          clusterId,
          eventId
        }
      ],
      eventFilters: eventMin ? [{ eventMin }] : void 0
    });
    return {
      values: eventData.flatMap(
        ({ path: { endpointId: endpointId2, clusterId: clusterId2, eventId: eventId2 }, events }) => events.map(({ eventNumber, data: data2 }) => ({
          eventId: eventId2,
          clusterId: clusterId2,
          endpointId: endpointId2,
          eventNumber,
          value: data2
        }))
      ),
      status: eventStatus?.map(({ path: { endpointId: endpointId2, clusterId: clusterId2, eventId: eventId2 }, status, clusterStatus }) => ({
        clusterId: clusterId2,
        endpointId: endpointId2,
        eventId: eventId2,
        status,
        clusterStatus
      }))
    };
  }
  async handleSubscribeAttribute(data) {
    const { nodeId, endpointId, clusterId, attributeId, minInterval, maxInterval, changeListener } = data;
    const client = this.#nodes.interactionClientFor(nodeId);
    const updated = Observable();
    let ignoreData = true;
    const { attributeReports = [] } = await client.subscribeMultipleAttributesAndEvents({
      attributes: [
        {
          endpointId,
          clusterId,
          attributeId
        }
      ],
      minIntervalFloorSeconds: minInterval,
      maxIntervalCeilingSeconds: maxInterval,
      attributeListener: (data2) => {
        if (ignoreData) return;
        changeListener({
          attributeId: data2.path.attributeId,
          clusterId: data2.path.clusterId,
          endpointId: data2.path.endpointId,
          dataVersion: data2.version,
          value: data2.value
        });
      },
      updateReceived: () => {
        updated.emit();
      },
      keepSubscriptions: false
    });
    ignoreData = false;
    return {
      values: attributeReports.map(
        ({ path: { endpointId: endpointId2, clusterId: clusterId2, attributeId: attributeId2 }, value, version: dataVersion }) => ({
          attributeId: attributeId2,
          clusterId: clusterId2,
          endpointId: endpointId2,
          dataVersion,
          value
        })
      ),
      updated
    };
  }
  async handleSubscribeEvent(data) {
    const { nodeId, endpointId, clusterId, eventId, minInterval, maxInterval, changeListener } = data;
    const client = this.#nodes.interactionClientFor(nodeId);
    const updated = Observable();
    let ignoreData = true;
    const { eventReports = [] } = await client.subscribeMultipleAttributesAndEvents({
      events: [
        {
          endpointId,
          clusterId,
          eventId
        }
      ],
      minIntervalFloorSeconds: minInterval,
      maxIntervalCeilingSeconds: maxInterval,
      eventListener: (data2) => {
        if (ignoreData) return;
        data2.events.forEach(
          (event) => changeListener({
            eventId: data2.path.eventId,
            clusterId: data2.path.clusterId,
            endpointId: data2.path.endpointId,
            eventNumber: event.eventNumber,
            value: event.data
          })
        );
      },
      updateReceived: () => {
        updated.emit();
      },
      keepSubscriptions: false
    });
    ignoreData = false;
    return {
      values: eventReports.flatMap(
        ({ path: { endpointId: endpointId2, clusterId: clusterId2, eventId: eventId2 }, events }) => events.map(({ eventNumber, data: data2 }) => ({
          eventId: eventId2,
          clusterId: clusterId2,
          endpointId: endpointId2,
          eventNumber,
          value: data2
        }))
      ),
      updated
    };
  }
  async handleWriteAttribute(data) {
    const { nodeId, endpointId, clusterId, attributeId } = data;
    let { value } = data;
    const client = this.#nodes.clusterClientByIdFor(nodeId, endpointId, clusterId);
    const clusterEntry = ClusterMap[clusterId];
    const model = clusterEntry?.attributes[attributeId];
    if (model && clusterEntry) {
      value = convertWebSocketTagBasedToMatter(value, model, clusterEntry.model);
    }
    logger.info("Writing attribute", attributeId, "with value", value);
    try {
      await client.attributes[attributeId].set(value);
      return {
        attributeId,
        clusterId,
        endpointId,
        status: 0
      };
    } catch (error) {
      StatusResponseError.accept(error);
      return {
        attributeId,
        clusterId,
        endpointId,
        status: error.code,
        clusterStatus: error.clusterCode
      };
    }
  }
  // TODO improve response typing
  async #invokeCommand(node, request, options = {}) {
    for await (const data of node.interaction.invoke(
      Invoke({
        commands: [request],
        ...options
      })
    )) {
      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;
    const options = {
      commissioning: {
        nodeId: data.nodeId,
        regulatoryLocation: GeneralCommissioning.RegulatoryLocationType.IndoorOutdoor,
        regulatoryCountryCode: "XX",
        wifiNetwork,
        threadNetwork
      },
      discovery: {
        knownAddress,
        identifierData: longDiscriminator !== void 0 ? { longDiscriminator } : shortDiscriminator !== void 0 ? { shortDiscriminator } : vendorId !== void 0 ? { vendorId, productId } : {},
        discoveryCapabilities: {
          ble: this.bleEnabled && !onNetworkOnly,
          onIpNetwork: true
        }
      },
      passcode
    };
    return options;
  }
  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 { tcpClient: supportsTcpClient, tcpServer: supportsTcpServer } = SupportedTransportsSchema.decode(
        T ?? 0
      );
      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 port = addresses.length ? addresses[0].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(({ type }) => type === "udp").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((addr) => addr.type === "udp").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.#nodes.delete(nodeId);
    this.#customClusterPoller.unregisterNode(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.Complete,
          attributes: "fabrics"
        })
      ),
      includeKnownVersions: true
      // we want to read from device
    };
    for await (const chunk of node.node.interaction.read(read)) {
      for (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 });
  }
  /**
   * Set Access Control List entries on a node.
   * Writes to the ACL attribute on the AccessControl cluster (endpoint 0).
   * TODO Migrate to new Node API
   */
  async setAclEntry(nodeId, entries) {
    const client = this.#nodes.clusterClientFor(nodeId, EndpointNumber(0), AccessControl.Cluster);
    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: FabricIndex.OMIT_FABRIC
    }));
    logger.info("Setting ACL entries", aclEntries);
    try {
      await client.setAclAttribute(aclEntries);
      return [
        {
          path: {
            endpoint_id: 0,
            cluster_id: AccessControl.Cluster.id,
            attribute_id: 0
            // ACL attribute ID
          },
          status: 0
        }
      ];
    } catch (error) {
      StatusResponseError.accept(error);
      return [
        {
          path: {
            endpoint_id: 0,
            cluster_id: AccessControl.Cluster.id,
            attribute_id: 0
          },
          status: error.code
        }
      ];
    }
  }
  /**
   * Set bindings on a specific endpoint of a node.
   * Writes to the Binding attribute on the Binding cluster.
   * TODO Migrate to new Node API
   */
  async setNodeBinding(nodeId, endpointId, bindings) {
    const client = this.#nodes.clusterClientFor(nodeId, endpointId, Binding.Cluster);
    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: FabricIndex.OMIT_FABRIC
    }));
    logger.info("Setting bindings on endpoint", endpointId, bindingEntries);
    try {
      await client.attributes.binding.set(bindingEntries);
      return [
        {
          path: {
            endpoint_id: endpointId,
            cluster_id: Binding.Cluster.id,
            attribute_id: 0
            // Binding attribute ID
          },
          status: 0
        }
      ];
    } catch (error) {
      StatusResponseError.accept(error);
      return [
        {
          path: {
            endpoint_id: endpointId,
            cluster_id: Binding.Cluster.id,
            attribute_id: 0
          },
          status: error.code
        }
      ];
    }
  }
  /**
   * 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})`
      );
    }
    try {
      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);
    } catch (error) {
      logger.error(`Failed to update node ${this.formatNode(nodeId)}:`, error);
      throw error;
    }
  }
  /**
   * 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
