/**
 * @license
 * Copyright 2022-2026 Matter.js Authors
 * SPDX-License-Identifier: Apache-2.0
 */
import {
  Abort,
  AbortedError,
  ChannelType,
  Diagnostic,
  DnsRecordType,
  Hours,
  IpService,
  Logger,
  MatterAggregateError,
  Minutes,
  ObserverGroup,
  Seconds,
  Time
} from "@matter/general";
import { VendorId } from "@matter/types";
import { DiscoveryData } from "../common/Scanner.js";
import {
  MATTER_COMMISSION_SERVICE_QNAME,
  getCommissionableDeviceQname,
  getCommissioningModeQname,
  getDeviceTypeQname,
  getLongDiscriminatorQname,
  getShortDiscriminatorQname,
  getVendorQname
} from "./MdnsConsts.js";
const logger = Logger.get("CommissionableMdnsScanner");
const SPECULATIVE_TARGET_TTL = Hours(1);
const SPECULATIVE_CLEANUP_INTERVAL = Minutes(30);
const SPECULATIVE_TARGET_MAX = 50;
const COMMISSIONING_RETRY_INTERVAL = Seconds(30);
class CommissionableMdnsScanner {
  type = ChannelType.UDP;
  #names;
  #filter;
  #observers = new ObserverGroup();
  #cache = /* @__PURE__ */ new Map();
  #waiters = /* @__PURE__ */ new Set();
  // Targets materialized by PTR-follow that never received SRV/TXT.  Each target gets a child abort driving
  // its own SRV/TXT discover() loop and a null observer keeping the name alive until we decide to drop it.
  #speculativeTargets = /* @__PURE__ */ new Map();
  #speculativeObserver = () => void 0;
  #speculativeCleanupTimer;
  #scanAbort = new Abort();
  constructor(names) {
    this.#names = names;
    const suffix1 = `._matterc._udp.local`;
    const base1 = `_matterc._udp.local`;
    const suffix2 = `._matterd._udp.local`;
    const base2 = `_matterd._udp.local`;
    this.#filter = (record) => {
      const lower = record.name.toLowerCase();
      return lower === base1 || lower.endsWith(suffix1) || lower === base2 || lower.endsWith(suffix2);
    };
    names.filters.add(this.#filter, [base1, base2]);
    this.#observers.on(names.discovered, this.#onDiscovered.bind(this));
    this.#speculativeCleanupTimer = Time.getPeriodicTimer(
      "Speculative PTR target cleanup",
      SPECULATIVE_CLEANUP_INTERVAL,
      this.#pruneSpeculativeTargets.bind(this)
    );
    this.#speculativeCleanupTimer.utility = true;
    this.#speculativeCleanupTimer.start();
  }
  async close() {
    for (const waiter of this.#waiters) {
      waiter.cancel();
    }
    this.#speculativeCleanupTimer.stop();
    this.#scanAbort();
    for (const qname of this.#speculativeTargets.keys()) {
      this.#names.maybeGet(qname)?.off(this.#speculativeObserver);
    }
    this.#speculativeTargets.clear();
    this.#names.filters.delete(this.#filter);
    this.#observers.close();
    await MatterAggregateError.allSettled(
      [...this.#cache.values()].map(({ ipService }) => ipService.close()),
      "Error closing cached IpServices"
    );
    this.#cache.clear();
  }
  #pruneSpeculativeTargets() {
    if (this.#speculativeTargets.size === 0) {
      return;
    }
    const cutoff = Time.nowMs - SPECULATIVE_TARGET_TTL;
    for (const [qname, { abort, createdAt }] of this.#speculativeTargets) {
      if (createdAt > cutoff) {
        continue;
      }
      this.#speculativeTargets.delete(qname);
      abort();
      this.#names.maybeGet(qname)?.off(this.#speculativeObserver);
    }
  }
  #clearSpeculativeTarget(qname, name) {
    const tracking = this.#speculativeTargets.get(qname);
    if (tracking === void 0) {
      return;
    }
    this.#speculativeTargets.delete(qname);
    tracking.abort();
    name.off(this.#speculativeObserver);
  }
  async findCommissionableDevicesContinuously(identifier, callback, timeout, cancelSignal) {
    for (const name of this.#names.discoveredNames) {
      this.#onDiscovered(name);
    }
    const seen = /* @__PURE__ */ new Set();
    const result = [];
    let cancelResolve;
    const internalCancel = new Promise((resolve) => cancelResolve = resolve);
    const waiter = {
      identifier,
      cancel: cancelResolve,
      notify: (device) => {
        if (matchesIdentifier(device, identifier) && !seen.has(device.deviceIdentifier)) {
          seen.add(device.deviceIdentifier);
          result.push(device);
          callback(device);
        }
      }
    };
    this.#waiters.add(waiter);
    let callbackInvoked = false;
    for (const cached of this.#cache.values()) {
      const device = refreshAddresses(cached);
      if (!matchesIdentifier(device, identifier)) {
        continue;
      }
      if (device.addresses.length > 0) {
        seen.add(device.deviceIdentifier);
        result.push(device);
        callbackInvoked = true;
        callback(device);
      } else {
        this.#solicitAndArmAddresses(cached);
      }
    }
    const sleepTimer = timeout !== void 0 ? Time.sleep("commissionable scanner timeout", timeout) : void 0;
    const signals = [internalCancel];
    if (sleepTimer) signals.push(sleepTimer);
    if (cancelSignal) signals.push(cancelSignal);
    if (callbackInvoked) {
      const proceed = /* @__PURE__ */ Symbol();
      const settle = Time.sleep("cancel settlement", 0).then(() => proceed);
      const winner = await Promise.race([
        ...signals.map(
          (s) => s.then(
            () => void 0,
            () => void 0
          )
        ),
        settle
      ]);
      if (winner !== proceed) {
        sleepTimer?.cancel();
        this.#waiters.delete(waiter);
        return result;
      }
    }
    const queryAbort = new Abort();
    const discoveries = this.#startDiscovery(identifier, queryAbort);
    try {
      await Promise.race([...signals, discoveries]);
      sleepTimer?.cancel();
    } finally {
      queryAbort();
      await discoveries;
      this.#waiters.delete(waiter);
    }
    return result;
  }
  getDiscoveredCommissionableDevices(identifier) {
    return [...this.#cache.values()].map((cached) => refreshAddresses(cached)).filter((device) => matchesIdentifier(device, identifier) && device.addresses.length > 0);
  }
  cancelCommissionableDeviceDiscovery(identifier) {
    for (const waiter of this.#waiters) {
      if (matchesWaiterIdentifier(waiter.identifier, identifier)) {
        waiter.cancel();
      }
    }
  }
  #onDiscovered(name) {
    const lower = name.qname.toLowerCase();
    const matterc = lower === "_matterc._udp.local" || lower.endsWith("._matterc._udp.local");
    const matterd = lower === "_matterd._udp.local" || lower.endsWith("._matterd._udp.local");
    if (!matterc && !matterd) {
      return;
    }
    if (lower.startsWith("_")) {
      for (const record of name.records) {
        if (record.recordType !== DnsRecordType.PTR) {
          continue;
        }
        if (record.value.startsWith("_")) {
          continue;
        }
        const target = this.#names.get(record.value);
        let hasSrv2 = false;
        let hasTxt = false;
        for (const r of target.records) {
          if (r.recordType === DnsRecordType.SRV) {
            hasSrv2 = true;
          } else if (r.recordType === DnsRecordType.TXT) {
            hasTxt = true;
          }
          if (hasSrv2 && hasTxt) {
            break;
          }
        }
        const recordTypes2 = new Array();
        if (!hasSrv2) {
          recordTypes2.push(DnsRecordType.SRV);
        }
        if (!hasTxt) {
          recordTypes2.push(DnsRecordType.TXT);
        }
        if (recordTypes2.length) {
          const targetKey = record.value.toLowerCase();
          if (!this.#speculativeTargets.has(targetKey) && this.#speculativeTargets.size < SPECULATIVE_TARGET_MAX) {
            target.on(this.#speculativeObserver);
            const abort = new Abort({ abort: this.#scanAbort });
            this.#speculativeTargets.set(targetKey, { abort, createdAt: Time.nowMs });
            void this.#names.solicitor.discover({
              name: target,
              recordTypes: recordTypes2,
              abort: abort.signal,
              // Match the main PTR discovery cap so retries stay dense within commissioning windows
              retries: { maximumInterval: COMMISSIONING_RETRY_INTERVAL }
            }).catch((error) => {
              this.#speculativeTargets.delete(targetKey);
              target.off(this.#speculativeObserver);
              if (!(error instanceof AbortedError)) {
                logger.warn(`Speculative discovery for ${target.qname} failed:`, error);
              }
            });
          }
        }
      }
      return;
    }
    this.#clearSpeculativeTarget(lower, name);
    if (this.#cache.has(lower)) {
      return;
    }
    const ipService = new IpService(name.qname, Diagnostic.via("commissionable-scanner"), this.#names);
    const device = buildCommissionableDevice(name);
    if (device !== void 0) {
      this.#cacheDevice(lower, device, ipService, name);
      return;
    }
    const hasSrv = [...name.records].some((r) => r.recordType === DnsRecordType.SRV);
    const recordTypes = hasSrv ? [DnsRecordType.TXT] : [DnsRecordType.SRV, DnsRecordType.TXT];
    this.#names.solicitor.solicit({ name, recordTypes });
    const pendingObserver = ({ name: changedName }) => {
      if (!changedName.isDiscovered) {
        this.#observers.off(name, pendingObserver);
        void ipService.close();
        return;
      }
      const built = buildCommissionableDevice(changedName);
      if (built !== void 0) {
        this.#observers.off(name, pendingObserver);
        this.#cacheDevice(lower, built, ipService, name);
      }
    };
    this.#observers.on(name, pendingObserver);
  }
  #cacheDevice(lower, device, ipService, name) {
    const observer = ({ name: changedName }) => {
      if (!changedName.isDiscovered) {
        const cached2 = this.#cache.get(lower);
        if (cached2) {
          this.#cache.delete(lower);
          this.#observers.off(name, cached2.observer);
          if (cached2.onAddresses) {
            this.#observers.off(ipService.changed, cached2.onAddresses);
            cached2.onAddresses = void 0;
          }
          void cached2.ipService.close();
        }
      }
    };
    const cached = { device, ipService, name, observer };
    this.#cache.set(lower, cached);
    this.#observers.on(name, observer);
    if (!this.#deliverDeviceIfResolved(cached)) {
      this.#solicitAndArmAddresses(cached);
    }
  }
  /**
   * Solicit A/AAAA records for the cached device's SRV target hostnames and arm an onAddresses observer
   * that delivers via {@link #deliverDeviceIfResolved} once addresses resolve.  Used both when a device is
   * first cached without addresses AND when an active discovery encounters a cached entry whose A/AAAA
   * records have since expired (matter commissionable A/AAAA TTL is short and may lapse between the device's
   * unsolicited broadcasts).  Idempotent: skips re-arming if onAddresses observer already attached.
   */
  #solicitAndArmAddresses(cached) {
    for (const record of cached.name.records) {
      if (record.recordType !== DnsRecordType.SRV) {
        continue;
      }
      const hostname = this.#names.get(record.value.target);
      this.#names.solicitor.solicit({
        name: hostname,
        recordTypes: [DnsRecordType.A, DnsRecordType.AAAA]
      });
    }
    if (cached.onAddresses !== void 0) {
      return;
    }
    const onAddresses = () => {
      if (this.#deliverDeviceIfResolved(cached)) {
        this.#observers.off(cached.ipService.changed, onAddresses);
        cached.onAddresses = void 0;
      }
    };
    cached.onAddresses = onAddresses;
    this.#observers.on(cached.ipService.changed, onAddresses);
  }
  #deliverDeviceIfResolved(cached) {
    const device = refreshAddresses(cached);
    if (device.addresses.length === 0) {
      return false;
    }
    for (const waiter of this.#waiters) {
      waiter.notify(device);
    }
    return true;
  }
  async #startDiscovery(identifier, abort) {
    const solicitor = this.#names.solicitor;
    const qnames = getQueryQnames(identifier);
    const discoveries = qnames.map(
      (qname) => solicitor.discover({
        name: this.#names.get(qname),
        recordTypes: [DnsRecordType.PTR],
        abort,
        // Cap backoff for commissioning: short-lived discovery shouldn't drift toward the 1h default
        retries: { maximumInterval: COMMISSIONING_RETRY_INTERVAL }
      })
    );
    await MatterAggregateError.allSettled(discoveries);
  }
}
function buildCommissionableDevice(name) {
  const params = name.parameters;
  const D = Number(params.get("D"));
  const CM = Number(params.get("CM"));
  if (!isFinite(D) || !isFinite(CM)) {
    return void 0;
  }
  const instanceId = name.qname.split(".")[0];
  const dd = DiscoveryData(params);
  dd.T ??= { tcpClient: false, tcpServer: false };
  dd.ICD ??= 0;
  return {
    ...dd,
    deviceIdentifier: instanceId,
    D,
    CM,
    addresses: []
  };
}
function refreshAddresses(cached) {
  cached.device.addresses = [...cached.ipService.addresses].map((a) => ({ ...a, type: "udp" }));
  return cached.device;
}
function matchesIdentifier(device, identifier) {
  if ("instanceId" in identifier) {
    return device.deviceIdentifier === identifier.instanceId;
  }
  if ("longDiscriminator" in identifier) {
    return device.D === identifier.longDiscriminator;
  }
  if ("shortDiscriminator" in identifier) {
    return (device.D >> 8 & 15) === identifier.shortDiscriminator;
  }
  if ("vendorId" in identifier) {
    const vp = device.VP?.split("+");
    if (!vp) return false;
    const vendorMatch = VendorId(Number(vp[0])) === identifier.vendorId;
    if ("productId" in identifier && identifier.productId !== void 0) {
      return vendorMatch && Number(vp[1]) === identifier.productId;
    }
    return vendorMatch;
  }
  if ("deviceType" in identifier) {
    return device.DT === identifier.deviceType;
  }
  if ("productId" in identifier) {
    const vp = device.VP?.split("+");
    return vp ? Number(vp[1]) === identifier.productId : false;
  }
  return device.CM === 1 || device.CM === 2;
}
function matchesWaiterIdentifier(a, b) {
  const aKeys = Object.keys(a);
  const bKeys = Object.keys(b);
  if (aKeys.length !== bKeys.length) return false;
  for (const key of aKeys) {
    if (a[key] !== b[key]) return false;
  }
  return true;
}
function getQueryQnames(identifier) {
  const base = [MATTER_COMMISSION_SERVICE_QNAME];
  if ("instanceId" in identifier) {
    return [...base, getCommissionableDeviceQname(identifier.instanceId)];
  }
  if ("longDiscriminator" in identifier) {
    return [...base, getLongDiscriminatorQname(identifier.longDiscriminator)];
  }
  if ("shortDiscriminator" in identifier) {
    return [...base, getShortDiscriminatorQname(identifier.shortDiscriminator)];
  }
  if ("vendorId" in identifier) {
    return [...base, getVendorQname(identifier.vendorId)];
  }
  if ("deviceType" in identifier) {
    return [...base, getDeviceTypeQname(identifier.deviceType)];
  }
  return [...base, getCommissioningModeQname()];
}
export {
  CommissionableMdnsScanner
};
//# sourceMappingURL=CommissionableMdnsScanner.js.map
