// diagnostic-normalizer.js — Normalizes vendor-specific diagnostic data into a unified model
// Supports: TP-Link XX230v ($.dm), Raisecom BOA (ASP), Raisecom PHP
'use strict'

// TP-Link MAC OUI prefixes (first 3 octets, uppercase with colons)
const TPLINK_OUIS = new Set([
  '30:DE:4B', '98:25:4A', 'E8:48:B8', '50:C7:BF', 'A8:42:A1', '60:32:B1',
  'B0:BE:76', 'C0:06:C3', 'F4:F2:6D', 'D8:07:B6', '6C:5A:B0', '14:EB:B6',
  'A8:29:48', '78:20:51', '3C:64:CF', '0C:EF:15', 'E4:FA:C4', 'E8:9C:25',
  '5C:A6:E6', '54:AF:97', '1C:3B:F3', 'B4:B0:24', 'EC:41:18', '68:FF:7B',
  'AC:15:A2', '88:C3:97', '34:60:F9', '98:DA:C4', '40:ED:00', 'CC:32:E5',
])

const MESH_HOSTNAME_PATTERNS = [
  { re: /deco\s*m4/i, model: 'Deco M4' },
  { re: /deco\s*m5/i, model: 'Deco M5' },
  { re: /deco\s*x20/i, model: 'Deco X20' },
  { re: /deco\s*x50/i, model: 'Deco X50' },
  { re: /deco/i, model: 'Deco' },
  { re: /hx220/i, model: 'HX220' },
  { re: /hx230/i, model: 'HX230' },
  { re: /tp-?link/i, model: 'TP-Link' },
  { re: /extender/i, model: 'Extender' },
  { re: /repeater/i, model: 'Repeater' },
]

function macOui(mac) {
  if (!mac) return ''
  return mac.replace(/[-]/g, ':').toUpperCase().substring(0, 8)
}

/**
 * Detect wired mesh repeaters from DHCP leases + WiFi client list.
 * A host is wired if it's NOT in the wifiClients list (by MAC).
 */
function detectWiredEquipment(dhcpLeases, wifiClientMacs) {
  const wifiSet = new Set((wifiClientMacs || []).map(m => m.toUpperCase().replace(/[-]/g, ':')))
  const equipment = []

  for (const lease of dhcpLeases) {
    const mac = (lease.mac || '').toUpperCase().replace(/[-]/g, ':')
    const hostname = (lease.hostname || '').toLowerCase()
    const oui = macOui(mac)
    const isWired = !wifiSet.has(mac)

    lease.isWired = isWired

    let match = null
    let matchReason = null

    // Check hostname patterns (most reliable)
    for (const p of MESH_HOSTNAME_PATTERNS) {
      if (p.re.test(hostname) || p.re.test(lease.hostname || '')) {
        match = { model: p.model, type: 'mesh_repeater' }
        matchReason = `hostname:${p.re.source}`
        break
      }
    }

    // Fallback: TP-Link OUI on a wired connection (probable mesh node)
    if (!match && isWired && TPLINK_OUIS.has(oui)) {
      match = { model: 'TP-Link device', type: 'possible_mesh_repeater' }
      matchReason = `oui:${oui}`
    }

    if (match) {
      lease.isMeshRepeater = true
      equipment.push({
        hostname: lease.hostname || '', mac: lease.mac, ip: lease.ip,
        port: lease.interface || null,
        model: match.model, type: match.type, matchReason,
      })
    } else {
      lease.isMeshRepeater = false
    }
  }
  return equipment
}

// --- Raisecom BOA normalizer ---

/**
 * Parse Raisecom BOA JavaScript data arrays from page HTML.
 * Raisecom BOA embeds data as: var arr = []; arr.push(new it_nr("idx", new it("key", val), ...))
 * Returns an array of objects with key-value pairs.
 */
function parseBoaArray(html, varName) {
  const items = []
  // Match full push line: varName.push(new it_nr("idx", new it("k1", v1), new it("k2", v2)));
  // Use line-based matching to avoid nested parenthesis issues
  const pushRe = new RegExp(varName + '\\.push\\(new it_nr\\((.+?)\\)\\);', 'g')
  let m
  while ((m = pushRe.exec(html)) !== null) {
    const line = m[1]
    const obj = {}
    // Extract new it("key", value) pairs from the line
    const itRe = /new it\("(\w+)",\s*("(?:[^"\\]|\\.)*"|[^,)]+)\)/g
    let im
    while ((im = itRe.exec(line)) !== null) {
      let val = im[2].trim()
      if (val.startsWith('"') && val.endsWith('"')) val = val.slice(1, -1)
      else if (!isNaN(val) && val !== '') val = Number(val)
      obj[im[1]] = val
    }
    if (Object.keys(obj).length > 0) items.push(obj)
  }
  return items
}

/**
 * Parse Raisecom BOA key-value tables from HTML.
 * Structure: <tr><td>Label</td><td>Value</td></tr>
 */
function parseBoaKvTable(html) {
  const kv = {}
  // Collect KV pairs from ALL <tr><td>label</td><td>value</td></tr> patterns across the page
  const rowRe = /<tr[^>]*>([\s\S]*?)<\/tr>/gi
  let rm
  while ((rm = rowRe.exec(html)) !== null) {
    const cells = []
    const cellRe = /<td[^>]*>([\s\S]*?)<\/td>/gi
    let cm
    while ((cm = cellRe.exec(rm[1])) !== null) {
      cells.push(cm[1].replace(/<[^>]+>/g, '').replace(/&nbsp;/g, ' ').trim())
    }
    if (cells.length >= 2 && cells[0] && cells[1]) {
      kv[cells[0]] = cells[1]
    }
  }
  return kv
}

function normalizeRaisecomBoa(pages) {
  const result = {
    modemType: 'raisecom_boa',
    online: null,
    wanIPs: [],
    ethernetPorts: [],
    dhcpLeases: [],
    wiredEquipment: [],
    radios: [],
    meshNodes: [], // Raisecom has no EasyMesh
    wifiClients: [],
    issues: [],
    device: {},
    gpon: {},
  }

  // --- Device basic info ---
  if (pages.deviceInfo) {
    const kv = parseBoaKvTable(pages.deviceInfo)
    result.device = {
      model: kv['Device Model'] || '',
      serial: kv['Device Serial Number'] || '',
      gponSn: kv['GPON SN'] || '',
      hardware: kv['Hardware Version'] || '',
      firmware: kv['Software Version'] || '',
      versionDate: kv['Version Date'] || '',
      cpu: kv['CPU Usage'] || '',
      memory: kv['Memory Usage'] || '',
      uptime: kv['UP Times'] || '',
    }
  }

  // --- GPON optical info ---
  if (pages.gpon) {
    const kv = parseBoaKvTable(pages.gpon)
    result.gpon = {
      linkState: kv['Link State'] || '',
      sn: kv['GPONSN'] || '',
      txPower: kv['Tx Power'] || '',
      rxPower: kv['Rx Power'] || '',
      temperature: kv['Temperature'] || '',
      voltage: kv['Voltage'] || '',
      biasCurrent: kv['Bias Current'] || '',
    }
    // Check link state
    const linkOk = result.gpon.linkState && result.gpon.linkState.includes('O5')
    result.online = { ipv4: linkOk, ipv6: false, uptimeV4: 0 }
  }

  // --- WAN interfaces ---
  if (pages.wan) {
    const links = parseBoaArray(pages.wan, 'links')
    for (const l of links) {
      const ip = l.ipAddr || ''
      const role = ip.startsWith('172.17.') || ip.startsWith('172.16.') ? 'management'
        : ip.startsWith('10.') ? 'service'
        : ip.startsWith('192.168.') ? 'lan'
        : (!ip.startsWith('169.254.') && ip) ? 'internet'
        : 'unknown'
      result.wanIPs.push({
        ip, mask: '', type: l.protocol || 'IPoE', role,
        vlanId: l.vlanId, name: l.servName || '', status: l.strStatus || '',
        gateway: l.gw || '', dns: l.DNS || '',
      })
    }
    // Set online based on WAN status
    const hasUpWan = links.some(l => l.strStatus === 'up' && l.ipAddr && !l.ipAddr.startsWith('10.') && !l.ipAddr.startsWith('172.'))
    if (result.online) result.online.ipv4 = hasUpWan || result.online.ipv4
    else result.online = { ipv4: hasUpWan, ipv6: false, uptimeV4: 0 }
  }

  // --- Ethernet ports + connected clients ---
  if (pages.ethernet) {
    const ethers = parseBoaArray(pages.ethernet, 'ethers')
    const clients = parseBoaArray(pages.ethernet, 'clts')

    for (const e of ethers) {
      const portNum = parseInt(e.ifname?.replace(/\D/g, '') || '0')
      const hasTraffic = (e.rx_packets || 0) > 0 || (e.tx_packets || 0) > 0
      result.ethernetPorts.push({
        port: portNum, label: e.ifname || `Port ${portNum}`,
        status: hasTraffic ? 'Up' : 'Down',
        speed: null, duplex: null,
        stats: {
          rxPackets: e.rx_packets || 0, rxBytes: e.rx_bytes || 0,
          rxErrors: e.rx_errors || 0, rxDropped: e.rx_dropped || 0,
          txPackets: e.tx_packets || 0, txBytes: e.tx_bytes || 0,
          txErrors: e.tx_errors || 0, txDropped: e.tx_dropped || 0,
        },
        connectedDevice: null, // Will be cross-referenced with DHCP
      })
    }

    // DHCP clients from Ethernet info page
    for (const c of clients) {
      result.dhcpLeases.push({
        hostname: c.devname || '',
        ip: c.ipAddr || '',
        mac: c.macAddr || '',
        expiry: c.liveTime ? parseInt(c.liveTime) : null,
        interface: null, isWired: false, isMeshRepeater: false,
      })
    }
  }

  // --- WiFi clients ---
  if (pages.wlan) {
    const wlanClients = parseBoaArray(pages.wlan, 'clts') // Same var name
    // Also check for other var names
    const wlanClients2 = parseBoaArray(pages.wlan, 'clients')
    const allWlan = [...wlanClients, ...wlanClients2]

    for (const c of allWlan) {
      result.wifiClients.push({
        mac: c.macAddr || c.MACAddress || '',
        hostname: '',
        ip: '',
        signal: c.rssi ? parseInt(c.rssi) : null,
        band: c.bandWidth ? `${c.bandWidth}MHz` : '',
        standard: '',
        active: c.linkState === 'Assoc' || c.linkState === 'associated' || true,
        linkDown: c.currRxRate ? parseInt(c.currRxRate) * 1000 : 0,
        linkUp: c.currTxRate ? parseInt(c.currTxRate) * 1000 : 0,
        lossPercent: 0,
        meshNode: '',
      })
    }

    // Extract radio info from the wlan page too
    const radios = parseBoaArray(pages.wlan, 'ethers') // Sometimes reused var name
    // Fallback: parse KV from the wlan basic config if available
    if (pages.wlanConfig) {
      const kv = parseBoaKvTable(pages.wlanConfig)
      if (kv['Channel'] || kv['Band']) {
        result.radios.push({
          band: kv['Band'] || '2.4GHz',
          channel: parseInt(kv['Channel']) || 0,
          bandwidth: kv['Channel Width'] || kv['Bandwidth'] || '',
          standard: kv['Mode'] || '',
          txPower: 0, autoChannel: false, status: 'Up',
        })
      }
    }
  }

  // Detect wired equipment (mesh repeaters)
  const wifiMacs = result.wifiClients.map(c => c.mac)
  result.wiredEquipment = detectWiredEquipment(result.dhcpLeases, wifiMacs)

  // Cross-reference Ethernet ports with DHCP clients
  // (BOA doesn't tell us which port each client is on, but we flag wired devices)

  return result
}

// --- Raisecom PHP (WS2) normalizer ---
// WS2 has a single dashboard page with HTML tables (not JS arrays like BOA)

function parseHtmlTables(html) {
  const tables = []
  const tableRe = /<table[^>]*>([\s\S]*?)<\/table>/gi
  let m
  while ((m = tableRe.exec(html)) !== null) {
    const rows = []
    const rowRe = /<tr[^>]*>([\s\S]*?)<\/tr>/gi
    let rm
    while ((rm = rowRe.exec(m[1])) !== null) {
      const cells = []
      const cellRe = /<t[dh][^>]*>([\s\S]*?)<\/t[dh]>/gi
      let cm
      while ((cm = cellRe.exec(rm[1])) !== null) {
        cells.push(cm[1].replace(/<[^>]+>/g, '').replace(/&nbsp;/g, ' ').replace(/\s+/g, ' ').trim())
      }
      if (cells.length >= 2) rows.push(cells)
    }
    if (rows.length > 0) tables.push(rows)
  }
  return tables
}

function normalizeRaisecomPhp(pages) {
  const result = {
    modemType: 'raisecom_php',
    online: null,
    wanIPs: [],
    ethernetPorts: [],
    dhcpLeases: [],
    wiredEquipment: [],
    radios: [],
    meshNodes: [],
    wifiClients: [],
    issues: [],
    device: {},
    gpon: {},
  }

  // WS2 returns the same dashboard for all ?parts= params
  // Use deviceInfo page (or any, they're the same)
  const html = pages.deviceInfo || pages.wan || pages.ethernet || ''
  if (!html) return result

  const tables = parseHtmlTables(html)

  for (const rows of tables) {
    // Device info table: 2-column KV pairs (label | value)
    if (rows.length >= 5 && rows.some(r => r[0] === 'Device Model')) {
      for (const [k, v] of rows) {
        if (k === 'Device Model') result.device.model = v
        if (k === 'Device Serial Number') result.device.serial = v
        if (k === 'Hardware Version') result.device.hardware = v
        if (k === 'Software Version') result.device.firmware = v
        if (k === 'Sub Version') result.device.subVersion = v
        if (k === 'System Up Time') result.device.uptime = v
        if (k === 'System Name') result.device.name = v
      }
    }

    // CPU table: "CPU Usage:28% | Time:..."
    if (rows.length === 1 && rows[0][0]?.includes('CPU Usage')) {
      const cpuMatch = rows[0][0].match(/CPU Usage:\s*([\d.]+)%/)
      if (cpuMatch) result.device.cpu = cpuMatch[1] + '%'
    }

    // Memory table
    if (rows.length === 1 && rows[0][0]?.includes('Memory Usage')) {
      const memMatch = rows[0][0].match(/Memory Usage:\s*([\d.]+)%/)
      if (memMatch) result.device.memory = memMatch[1] + '%'
    }

    // WAN connections table: header row + data rows
    if (rows.length >= 2 && rows[0].some(h => h === 'Connection Name')) {
      const headers = rows[0]
      for (let i = 1; i < rows.length; i++) {
        const row = rows[i]
        const obj = {}
        headers.forEach((h, idx) => { if (row[idx]) obj[h] = row[idx] })

        const ip = (obj['IP Address/Net Mask Length'] || '').split('/')[0] || ''
        const mask = (obj['IP Address/Net Mask Length'] || '').split('/')[1] || ''
        const status = obj['Link Status'] || ''
        const name = obj['Connection Name'] || ''

        if (ip && ip !== '---') {
          const role = ip.startsWith('172.17.') || ip.startsWith('172.16.') ? 'management'
            : ip.startsWith('10.') ? 'service'
            : ip.startsWith('192.168.') ? 'lan'
            : !ip.startsWith('169.254.') ? 'internet' : 'unknown'
          result.wanIPs.push({
            ip, mask, type: obj['Address Assign Mode'] || '', role,
            name, status, gateway: obj['Gate Address'] || '',
            dns: obj['DNS'] || '',
            rxRate: obj['Receiving Rate(Kbits/s)'] || '',
            txRate: obj['Sending Rate(Kbits/s)'] || '',
          })
        }

        if (status === 'UP') {
          result.online = result.online || { ipv4: false, ipv6: false, uptimeV4: 0 }
          if (name.includes('Internet')) result.online.ipv4 = true
        }
      }
    }

    // WiFi table: "SSID | Service Status | connected count (下挂PC数量)"
    // Note: WS2 tables sometimes have header in <thead><tr> but data rows without <tr> opening tags
    if (rows.length >= 1 && rows[0].some(h => h === 'SSID(Wireless Network Name)' || h === 'SSID')) {
      for (let i = 1; i < rows.length; i++) {
        if (rows[i][0] && rows[i][1]) {
          const ssid = rows[i][0]
          const statusRaw = rows[i][1]
          const isOn = /^(Enable|UP|ON)$/i.test(statusRaw)
          const band = ssid.match(/-5G$/i) ? '5GHz' : '2.4GHz'
          result.radios.push({
            band, channel: 0, bandwidth: '', standard: '',
            txPower: 0, autoChannel: false,
            status: isOn ? 'Up' : 'Down',
            ssid,
          })
        }
      }
    }

    // Ethernet port table: rows of [LANx, UP/DOWN] — either one row with all ports or multiple rows
    if (rows.length >= 1 && rows.some(r => /^LAN\d$/i.test(r[0]))) {
      for (const row of rows) {
        // Each row is either [LANx, UP/DOWN] or a flat row [LAN1, UP, LAN2, DOWN, ...]
        if (/^LAN\d$/i.test(row[0]) && row.length === 2) {
          result.ethernetPorts.push({
            port: parseInt(row[0].replace(/\D/g, '')),
            label: row[0],
            status: row[1] === 'UP' ? 'Up' : 'Down',
            speed: null, duplex: null, stats: null, connectedDevice: null,
          })
        } else {
          // Flat format: LAN1 UP LAN2 DOWN ...
          for (let i = 0; i < row.length - 1; i += 2) {
            if (/^LAN\d$/i.test(row[i])) {
              result.ethernetPorts.push({
                port: parseInt(row[i].replace(/\D/g, '')),
                label: row[i],
                status: row[i + 1] === 'UP' ? 'Up' : 'Down',
                speed: null, duplex: null, stats: null, connectedDevice: null,
              })
            }
          }
        }
      }
    }
  }

  // --- Fallback: direct HTML regex for WS2 malformed tables ---
  // WS2 <tbody> rows often lack <tr> opening tags, so parseHtmlTables misses them

  // WiFi SSIDs: extract from <span class="content_td">SSID</span> + <span class="content_td">ON/OFF</span> pairs
  if (result.radios.length === 0 && html.includes('SSID(Wireless Network Name)')) {
    const ssidRe = /<td[^>]*>\s*<span[^>]*>([^<]+)<\/span>\s*<\/td>\s*<td[^>]*>\s*<span[^>]*>(ON|OFF|UP|DOWN|Enable|Disable)<\/span>/gi
    let sm
    while ((sm = ssidRe.exec(html)) !== null) {
      const ssid = sm[1].trim()
      const isOn = /^(ON|UP|Enable)$/i.test(sm[2])
      const band = ssid.match(/-5G$/i) ? '5GHz' : '2.4GHz'
      result.radios.push({
        band, channel: 0, bandwidth: '', standard: '',
        txPower: 0, autoChannel: false,
        status: isOn ? 'Up' : 'Down',
        ssid,
      })
    }
  }

  // Ethernet ports: extract LAN1-4 status from <span>LANx</span>...<span>UP/DOWN</span> pairs
  if (result.ethernetPorts.length === 0 && html.includes('LAN1')) {
    const lanRe = /<span[^>]*>(LAN\d)<\/span>[\s\S]*?<span[^>]*>(UP|DOWN)<\/span>/gi
    let lm
    while ((lm = lanRe.exec(html)) !== null) {
      result.ethernetPorts.push({
        port: parseInt(lm[1].replace(/\D/g, '')),
        label: lm[1],
        status: lm[2] === 'UP' ? 'Up' : 'Down',
        speed: null, duplex: null, stats: null, connectedDevice: null,
      })
    }
  }

  // If no online status determined, check if any WAN is UP
  if (!result.online && result.wanIPs.some(w => w.status === 'UP')) {
    result.online = { ipv4: true, ipv6: false, uptimeV4: 0 }
  }

  return result
}

// --- TP-Link normalizer ---

function normalizeTplink(raw) {
  // Raw data from fullDiagnostic() — keys match the Promise.allSettled order
  const result = {
    modemType: 'tplink_xx230v',
    online: null,
    wanIPs: [],
    ethernetPorts: [],
    dhcpLeases: [],
    wiredEquipment: [],
    radios: [],
    meshNodes: [],
    wifiClients: [],
    issues: [],
  }

  // Online status
  if (raw.onlineStatus && !raw.onlineStatus.error) {
    result.online = {
      ipv4: raw.onlineStatus.onlineStatusV4 === 'online',
      ipv6: raw.onlineStatus.onlineStatusV6 === 'online',
      uptimeV4: parseInt(raw.onlineStatus.onlineTimeV4) || 0,
    }
  }

  // WAN IPs
  if (Array.isArray(raw.wanIPs)) {
    for (const w of raw.wanIPs) {
      if (!w.IPAddress || w.IPAddress === '0.0.0.0') continue
      if (w.status === 'Disabled') continue
      const ip = w.IPAddress
      const role = ip.startsWith('192.168.') ? 'lan'
        : ip.startsWith('172.17.') || ip.startsWith('172.16.') ? 'management'
        : ip.startsWith('10.') ? 'service'
        : !ip.startsWith('169.254.') ? 'internet' : 'unknown'
      result.wanIPs.push({
        ip, mask: w.subnetMask || '', type: w.addressingType || '', role,
      })
    }
  }

  // Radios
  if (Array.isArray(raw.radios)) {
    for (const r of raw.radios) {
      result.radios.push({
        band: r.operatingFrequencyBand || '',
        channel: parseInt(r.channel) || 0,
        bandwidth: r.currentOperatingChannelBandwidth || '',
        standard: r.operatingStandards || '',
        txPower: parseInt(r.transmitPower) || 0,
        autoChannel: r.autoChannelEnable === '1',
        status: r.status || '',
      })
    }
  }

  // Mesh nodes
  if (Array.isArray(raw.meshNodes)) {
    for (const n of raw.meshNodes) {
      result.meshNodes.push({
        hostname: n.X_TP_HostName || 'unknown',
        model: n.X_TP_ModelName || '',
        mac: n.MACAddress || '',
        ip: n.X_TP_IPAddress || '',
        active: n.X_TP_Active === '1',
        isController: n.X_TP_IsController === '1',
        cpu: parseInt(n.X_TP_CPUUsage) || 0,
        uptime: parseInt(n.X_TP_UpTime) || 0,
        firmware: n.softwareVersion || '',
        backhaul: {
          type: n.backhaulLinkType || 'Ethernet',
          signal: parseInt(n.backhaulSignalStrength) || 0,
          utilization: parseInt(n.backhaulLinkUtilization) || 0,
          linkRate: parseInt(n.X_TP_LinkRate) || 0,
        },
        speedUp: parseInt(n.X_TP_UpSpeed) || 0,
        speedDown: parseInt(n.X_TP_DownSpeed) || 0,
      })
    }
  }

  // WiFi clients
  const clientMap = new Map()
  if (Array.isArray(raw.clients)) {
    for (const c of raw.clients) {
      clientMap.set(c.MACAddress, {
        mac: c.MACAddress || '', hostname: c.X_TP_HostName || '',
        ip: c.X_TP_IPAddress || '',
        signal: parseInt(c.signalStrength) || 0,
        band: '', standard: c.operatingStandard || '',
        active: c.active === '1',
        linkDown: parseInt(c.lastDataDownlinkRate) || 0,
        linkUp: parseInt(c.lastDataUplinkRate) || 0,
        lossPercent: 0, meshNode: '',
        apMac: c.X_TP_ApDeviceMac || '',
        radioMac: c.X_TP_RadioMac || '',
      })
    }
  }
  if (Array.isArray(raw.clientStats)) {
    for (const s of raw.clientStats) {
      const existing = clientMap.get(s.MACAddress)
      if (existing) {
        existing.retrans = parseInt(s.retransCount) || 0
        existing.packetsSent = parseInt(s.packetsSent) || 0
      }
    }
  }
  // Compute loss
  for (const c of clientMap.values()) {
    if (c.packetsSent > 100 && c.retrans > 0) {
      c.lossPercent = Math.round((c.retrans / c.packetsSent) * 1000) / 10
    }
  }
  result.wifiClients = [...clientMap.values()]

  // Ethernet ports (from DEV2_ETHERNET_IF if available)
  if (Array.isArray(raw.ethernetIfs)) {
    for (let i = 0; i < raw.ethernetIfs.length; i++) {
      const e = raw.ethernetIfs[i]
      result.ethernetPorts.push({
        port: i + 1, label: e.alias || e.name || `Port ${i + 1}`,
        status: e.status || 'Down',
        speed: parseInt(e.maxBitRate || e.currentBitRate) || null,
        duplex: e.duplexMode || null,
        stats: null, connectedDevice: null,
      })
    }
  }

  // DHCP hosts (from DEV2_HOSTS_HOST if available)
  if (Array.isArray(raw.hosts)) {
    const wifiMacs = new Set([...clientMap.keys()].map(m => m.toUpperCase()))
    for (const h of raw.hosts) {
      const mac = (h.PhysAddress || h.physAddress || '').toUpperCase()
      result.dhcpLeases.push({
        hostname: h.HostName || h.hostName || '',
        ip: h.IPAddress || h.ipAddress || '',
        mac: h.PhysAddress || h.physAddress || '',
        expiry: h.LeaseTimeRemaining != null ? parseInt(h.LeaseTimeRemaining) : null,
        interface: h.Layer1Interface || '',
        isWired: !wifiMacs.has(mac),
        isMeshRepeater: false,
      })
    }
    result.wiredEquipment = detectWiredEquipment(result.dhcpLeases, [...clientMap.keys()])
  }

  return result
}

module.exports = { normalizeRaisecomBoa, normalizeRaisecomPhp, normalizeTplink, detectWiredEquipment, parseBoaArray, parseBoaKvTable }
