/**
 * @license
 * Copyright 2025-2026 Open Home Foundation
 * SPDX-License-Identifier: Apache-2.0
 */
import { Connection } from "./connection.js";
import { CommandTimeoutError, ConnectionClosedError, InvalidServerVersion } from "./exceptions.js";
import { MatterNode } from "./models/node.js";
function toNodeKey(nodeId) {
  return String(nodeId);
}
const DEFAULT_COMMAND_TIMEOUT = 5 * 60 * 1e3;
class MatterClient {
  /**
   * Create a new MatterClient.
   * @param url WebSocket URL to connect to
   * @param wsFactory Optional factory function to create WebSocket instances.
   *                  For Node.js, pass: (url) => new WebSocket(url) from the 'ws' package.
   *                  For browser, leave undefined to use native WebSocket.
   */
  constructor(url, wsFactory) {
    this.url = url;
    this.url = url;
    this.connection = new Connection(this.url, wsFactory);
    this.serverBaseAddress = this.url.split("://")[1].split(":")[0] ?? "";
  }
  url;
  connection;
  nodes = {};
  serverBaseAddress;
  /** Whether this client is connected to a production server (optional, for UI purposes) */
  isProduction = false;
  /** Default timeout for commands in milliseconds. Set to 0 to disable timeouts. */
  commandTimeout = DEFAULT_COMMAND_TIMEOUT;
  // Using 'unknown' for resolve since the actual types vary by command
  result_futures = {};
  // Start with random offset for defense-in-depth and easier debugging across sessions
  msgId = Math.floor(Math.random() * 2147483647);
  eventListeners = {};
  webrtcCallbackListeners = [];
  nodeEventListeners = [];
  get serverInfo() {
    return this.connection.serverInfo;
  }
  addEventListener(event, listener) {
    if (!this.eventListeners[event]) {
      this.eventListeners[event] = [];
    }
    this.eventListeners[event].push(listener);
    return () => {
      this.eventListeners[event] = this.eventListeners[event].filter((l) => l !== listener);
    };
  }
  /**
   * Subscribe to webrtc_callback events with the typed payload.
   * Returns an unsubscribe function.
   */
  addWebRtcCallbackListener(listener) {
    this.webrtcCallbackListeners.push(listener);
    return () => {
      this.webrtcCallbackListeners = this.webrtcCallbackListeners.filter((l) => l !== listener);
    };
  }
  addNodeEventListener(listener) {
    this.nodeEventListeners.push(listener);
    return () => {
      this.nodeEventListeners = this.nodeEventListeners.filter((l) => l !== listener);
    };
  }
  async commissionWithCode(code, networkOnly = true, timeout) {
    const data = await this.sendCommand(
      "commission_with_code",
      0,
      {
        code,
        network_only: networkOnly
      },
      timeout
    );
    return new MatterNode(data);
  }
  async setWifiCredentials(ssid, credentials, timeout) {
    await this.sendCommand("set_wifi_credentials", 0, { ssid, credentials }, timeout);
  }
  async setThreadOperationalDataset(dataset, timeout) {
    await this.sendCommand("set_thread_dataset", 0, { dataset }, timeout);
  }
  async removeWifiCredentials(timeout) {
    await this.sendCommand("remove_wifi_credentials", 0, {}, timeout);
  }
  async removeThreadDataset(timeout) {
    await this.sendCommand("remove_thread_dataset", 0, {}, timeout);
  }
  async openCommissioningWindow(nodeId, windowTimeout, iteration, option, discriminator, timeout) {
    return await this.sendCommand(
      "open_commissioning_window",
      0,
      {
        node_id: nodeId,
        timeout: windowTimeout,
        iteration,
        option,
        discriminator
      },
      timeout
    );
  }
  async discoverCommissionableNodes(timeout) {
    return await this.sendCommand("discover_commissionable_nodes", 0, {}, timeout);
  }
  async getMatterFabrics(nodeId, timeout) {
    return await this.sendCommand("get_matter_fabrics", 3, { node_id: nodeId }, timeout);
  }
  async removeMatterFabric(nodeId, fabricIndex, timeout) {
    await this.sendCommand("remove_matter_fabric", 3, { node_id: nodeId, fabric_index: fabricIndex }, timeout);
  }
  async pingNode(nodeId, attempts = 1, timeout) {
    return await this.sendCommand("ping_node", 0, { node_id: nodeId, attempts }, timeout);
  }
  async getNodeIPAddresses(nodeId, preferCache, scoped, timeout) {
    return await this.sendCommand(
      "get_node_ip_addresses",
      8,
      {
        node_id: nodeId,
        prefer_cache: preferCache,
        scoped
      },
      timeout
    );
  }
  async removeNode(nodeId, timeout) {
    await this.sendCommand("remove_node", 0, { node_id: nodeId }, timeout);
  }
  async interviewNode(nodeId, timeout) {
    await this.sendCommand("interview_node", 0, { node_id: nodeId }, timeout);
  }
  async importTestNode(dump, timeout) {
    await this.sendCommand("import_test_node", 0, { dump }, timeout);
  }
  async readAttribute(nodeId, attributePath, timeout) {
    return await this.sendCommand("read_attribute", 0, { node_id: nodeId, attribute_path: attributePath }, timeout);
  }
  async writeAttribute(nodeId, attributePath, value, timeout) {
    return await this.sendCommand(
      "write_attribute",
      0,
      {
        node_id: nodeId,
        attribute_path: attributePath,
        value
      },
      timeout
    );
  }
  async checkNodeUpdate(nodeId, timeout) {
    return await this.sendCommand("check_node_update", 10, { node_id: nodeId }, timeout);
  }
  async updateNode(nodeId, softwareVersion, timeout) {
    await this.sendCommand("update_node", 10, { node_id: nodeId, software_version: softwareVersion }, timeout);
  }
  async setACLEntry(nodeId, entry, timeout) {
    return await this.sendCommand(
      "set_acl_entry",
      0,
      {
        node_id: nodeId,
        entry
      },
      timeout
    );
  }
  async setNodeBinding(nodeId, endpoint, bindings, timeout) {
    return await this.sendCommand(
      "set_node_binding",
      0,
      {
        node_id: nodeId,
        endpoint,
        bindings
      },
      timeout
    );
  }
  async deviceCommand(nodeId, endpointId, clusterId, commandName, payload = {}, timeout) {
    return await this.sendCommand(
      "device_command",
      0,
      {
        node_id: nodeId,
        endpoint_id: endpointId,
        cluster_id: clusterId,
        command_name: commandName,
        payload,
        response_type: null
      },
      timeout
    );
  }
  async sendWebRtcProviderCommand(nodeId, endpointId, commandName, payload, timeout) {
    return await this.sendCommand(
      "send_webrtc_provider_command",
      0,
      { node_id: nodeId, endpoint_id: endpointId, command_name: commandName, payload },
      timeout
    );
  }
  async getNodes(onlyAvailable = false, timeout) {
    const data = await this.sendCommand("get_nodes", 0, { only_available: onlyAvailable }, timeout);
    return data.map((n) => new MatterNode(n));
  }
  async getNode(nodeId, timeout) {
    const data = await this.sendCommand("get_node", 0, { node_id: nodeId }, timeout);
    return new MatterNode(data);
  }
  async getVendorNames(filterVendors, timeout) {
    return await this.sendCommand("get_vendor_names", 0, { filter_vendors: filterVendors }, timeout);
  }
  async fetchServerInfo(timeout) {
    return await this.sendCommand("server_info", 0, {}, timeout);
  }
  async setDefaultFabricLabel(label, timeout) {
    await this.sendCommand("set_default_fabric_label", 0, { label }, timeout);
  }
  /**
   * Get the current log levels for console and file logging.
   * @param timeout Optional command timeout in milliseconds
   * @returns The current log level configuration
   */
  async getLogLevel(timeout) {
    return await this.sendCommand("get_loglevel", 0, {}, timeout);
  }
  /**
   * Set the log level for console and/or file logging.
   * Changes are temporary and will be reset when the server restarts.
   * @param consoleLoglevel Console log level to set (optional)
   * @param fileLoglevel File log level to set, only applied if file logging is enabled (optional)
   * @param timeout Optional command timeout in milliseconds
   * @returns The log level configuration after the change
   */
  async setLogLevel(consoleLoglevel, fileLoglevel, timeout) {
    return await this.sendCommand(
      "set_loglevel",
      0,
      {
        console_loglevel: consoleLoglevel,
        file_loglevel: fileLoglevel
      },
      timeout
    );
  }
  /**
   * Send a command to the Matter server.
   * @param command The command name
   * @param require_schema Minimum schema version required (0 for any version)
   * @param args Command arguments
   * @param timeout Optional timeout in milliseconds. Defaults to `commandTimeout`. Set to 0 to disable.
   * @returns Promise that resolves with the command result
   * @throws Error if the command times out or fails
   */
  sendCommand(command, require_schema = void 0, args, timeout = this.commandTimeout) {
    if (require_schema && this.serverInfo.schema_version < require_schema) {
      throw new InvalidServerVersion(
        `Command not available due to incompatible server version. Update the Matter Server to a version that supports at least api schema ${require_schema}.`
      );
    }
    if (this.msgId >= Number.MAX_SAFE_INTEGER) {
      this.msgId = 0;
    }
    const messageId = String(++this.msgId);
    const message = {
      message_id: messageId,
      command,
      args
    };
    return new Promise((resolve, reject) => {
      let timeoutId;
      if (timeout > 0) {
        timeoutId = setTimeout(() => {
          const pending = this.result_futures[messageId];
          if (pending) {
            if (pending.timeoutId) {
              clearTimeout(pending.timeoutId);
            }
            delete this.result_futures[messageId];
            reject(new CommandTimeoutError(command, timeout));
          }
        }, timeout);
      }
      this.result_futures[messageId] = {
        resolve,
        reject,
        timeoutId
      };
      this.connection.sendMessage(message);
    });
  }
  /**
   * Safely resolve a pending command, ensuring it's only resolved once.
   * Clears timeout and removes from pending futures before resolving.
   */
  _resolvePendingCommand(messageId, result) {
    const pending = this.result_futures[messageId];
    if (pending) {
      if (pending.timeoutId) {
        clearTimeout(pending.timeoutId);
      }
      delete this.result_futures[messageId];
      pending.resolve(result);
    }
  }
  /**
   * Safely reject a pending command, ensuring it's only rejected once.
   * Clears timeout and removes from pending futures before rejecting.
   */
  _rejectPendingCommand(messageId, error) {
    const pending = this.result_futures[messageId];
    if (pending) {
      if (pending.timeoutId) {
        clearTimeout(pending.timeoutId);
      }
      delete this.result_futures[messageId];
      pending.reject(error);
    }
  }
  /**
   * Reject all pending commands with a ConnectionClosedError.
   * Called when the connection is closed or lost.
   */
  _rejectAllPendingCommands() {
    const error = new ConnectionClosedError();
    const pendingIds = Object.keys(this.result_futures);
    for (const messageId of pendingIds) {
      this._rejectPendingCommand(messageId, error);
    }
  }
  async connect() {
    if (this.connection.connected) {
      return;
    }
    await this.connection.connect(
      (msg) => this._handleIncomingMessage(msg),
      () => {
        this._rejectAllPendingCommands();
        this.fireEvent("connection_lost");
      }
    );
  }
  disconnect(clearStorage = false) {
    this._rejectAllPendingCommands();
    if (this.connection && this.connection.connected) {
      this.connection.disconnect();
    }
    if (clearStorage && typeof localStorage !== "undefined") {
      localStorage.removeItem("matterURL");
      location.reload();
    }
  }
  async startListening() {
    await this.connect();
    const nodesArray = await this.sendCommand("start_listening", 0, {});
    const nodes = {};
    for (const node of nodesArray) {
      nodes[toNodeKey(node.node_id)] = new MatterNode(node);
    }
    this.nodes = nodes;
  }
  _handleIncomingMessage(msg) {
    if ("event" in msg) {
      this._handleEventMessage(msg);
      return;
    }
    if ("error_code" in msg) {
      this._rejectPendingCommand(msg.message_id, new Error(msg.details));
      return;
    }
    if ("result" in msg) {
      this._resolvePendingCommand(msg.message_id, msg.result);
      return;
    }
    console.warn("Received message with unknown format", msg);
  }
  _handleEventMessage(event) {
    console.debug("Incoming event", event);
    this.onRawEvent(event);
    if (event.event === "node_added") {
      const node = new MatterNode(event.data);
      this.nodes = { ...this.nodes, [toNodeKey(node.node_id)]: node };
      this.fireEvent("nodes_changed");
      return;
    }
    if (event.event === "node_removed") {
      delete this.nodes[toNodeKey(event.data)];
      this.nodes = { ...this.nodes };
      this.fireEvent("nodes_changed");
      return;
    }
    if (event.event === "node_updated") {
      const node = new MatterNode(event.data);
      this.nodes = { ...this.nodes, [toNodeKey(node.node_id)]: node };
      this.fireEvent("nodes_changed");
      return;
    }
    if (event.event === "attribute_updated") {
      const [nodeId, attributeKey, attributeValue] = event.data;
      const nodeKey = toNodeKey(nodeId);
      const existingNode = this.nodes[nodeKey];
      if (existingNode) {
        const node = new MatterNode(existingNode.data);
        node.attributes[attributeKey] = attributeValue;
        this.nodes = { ...this.nodes, [nodeKey]: node };
        this.fireEvent("nodes_changed");
      }
      return;
    }
    if (event.event === "server_info_updated") {
      this.connection.serverInfo = event.data;
      this.fireEvent("server_info_updated");
      return;
    }
    if (event.event === "server_shutdown") {
      this.fireEvent("server_shutdown");
      this.disconnect();
      return;
    }
    if (event.event === "webrtc_callback") {
      for (const listener of this.webrtcCallbackListeners) {
        listener(event.data);
      }
      return;
    }
    if (event.event === "node_event") {
      for (const listener of this.nodeEventListeners) {
        listener(event.data);
      }
      return;
    }
  }
  fireEvent(event) {
    const listeners = this.eventListeners[event];
    if (listeners) {
      for (const listener of listeners) {
        listener();
      }
    }
  }
  /**
   * Hook for subclasses to receive raw events.
   * Override this method to intercept all incoming events.
   * @param event The raw event message
   */
  onRawEvent(_event) {
  }
}
export {
  DEFAULT_COMMAND_TIMEOUT,
  MatterClient
};
//# sourceMappingURL=client.js.map
