helpers/cisco-wlc/Build-Wireless-Summaries.ps1

#requires -Version 5.1

[CmdletBinding()]
param(
    [string]$InputDirectory,
    [string]$OutputDirectory,
    [string]$MibDirectory = (Join-Path $PSScriptRoot 'mibs'),
    [string]$ManifestPath,
    [bool]$IncludeRogues = $true,
    [bool]$IncludeRfHealth = $true,
    [bool]$IncludeJoinTime = $true
)

Set-StrictMode -Version Latest
$ErrorActionPreference = 'Stop'

# Set dynamic defaults if not provided
$discoveryOutputBase = Join-Path $env:LOCALAPPDATA 'WhatsUpGoldPS\DiscoveryHelpers\Output'
if (-not $InputDirectory) {
    $InputDirectory = Join-Path $discoveryOutputBase 'full'
}
if (-not $OutputDirectory) {
    $OutputDirectory = Join-Path $discoveryOutputBase 'summary'
}
if (-not $ManifestPath) {
    $ManifestPath = Join-Path $OutputDirectory 'wireless-summary-manifest.json'
}

# Ensure directories exist
foreach ($dir in @($InputDirectory, $OutputDirectory)) {
    if (-not (Test-Path -LiteralPath $dir)) {
        New-Item -ItemType Directory -Path $dir -Force | Out-Null
    }
}

# Load pre-baked MIB translation cache (no MIB file parsing required)
$mibCachePath = Join-Path $PSScriptRoot 'MibTranslationCache.ps1'
if (Test-Path -LiteralPath $mibCachePath) {
    . $mibCachePath
}

function Ensure-Directory {
    param([Parameter(Mandatory)] [string]$Path)
    if (-not (Test-Path -LiteralPath $Path)) {
        New-Item -ItemType Directory -Path $Path -Force | Out-Null
    }
}

function Convert-IndexToMac {
    param([AllowNull()] [string]$Index)

    if ([string]::IsNullOrWhiteSpace($Index)) { return '' }
    $parts = $Index.Split('.') | Where-Object { $_ -ne '' } | ForEach-Object { [int]$_ }
    if ($parts.Count -eq 0) { return '' }
    return ($parts | ForEach-Object { '{0:X2}' -f $_ }) -join ':'
}

function Convert-OctetTextToBytes {
    param([AllowNull()] [string]$RawText)

    $bytes = New-Object System.Collections.Generic.List[byte]
    if ([string]::IsNullOrEmpty($RawText)) {
        return ,([byte[]]$bytes.ToArray())
    }

    foreach ($ch in $RawText.ToCharArray()) {
        $bytes.Add([byte][int]$ch)
    }

    return ,([byte[]]$bytes.ToArray())
}

function Convert-OctetTextToMac {
    param([AllowNull()] [string]$RawText)

    $bytes = Convert-OctetTextToBytes -RawText $RawText
    $byteArray = @($bytes)
    if ($byteArray.Count -eq 0) { return '' }
    return ($byteArray | ForEach-Object { '{0:X2}' -f $_ }) -join ':'
}

function Convert-HexStringToMac {
    param([AllowNull()] [string]$Hex)

    if ([string]::IsNullOrWhiteSpace($Hex)) { return '' }
    $normalized = ($Hex -replace '[^0-9A-Fa-f]', '').ToUpperInvariant()
    if ($normalized.Length -ne 12) { return '' }

    $parts = @()
    for ($i = 0; $i -lt 12; $i += 2) {
        $parts += $normalized.Substring($i, 2)
    }
    return ($parts -join ':')
}

function Convert-HexToAscii {
    param([AllowNull()] [string]$Hex)

    if ([string]::IsNullOrWhiteSpace($Hex)) { return '' }
    $normalized = ($Hex -replace '[^0-9A-Fa-f]', '')
    if ($normalized.Length -eq 0 -or $normalized.Length % 2 -ne 0) { return '' }

    $chars = @()
    for ($i = 0; $i -lt $normalized.Length; $i += 2) {
        $chars += [char][Convert]::ToByte($normalized.Substring($i, 2), 16)
    }
    return (-join $chars)
}

function Normalize-OctetDecodedText {
    param([AllowNull()] [string]$Value)

    if ([string]::IsNullOrEmpty($Value)) { return '' }

    $clean = [regex]::Replace($Value, '[\x00-\x08\x0B\x0C\x0E-\x1F\x7F]', '')
    return $clean.Trim()
}

function Resolve-OctetStringText {
    param(
        [AllowNull()] [string]$RawText,
        [AllowNull()] [string]$HexText
    )

    if (-not [string]::IsNullOrWhiteSpace($HexText)) {
        $decoded = Normalize-OctetDecodedText -Value (Convert-HexToAscii -Hex $HexText)
        if (-not [string]::IsNullOrWhiteSpace($decoded)) {
            return $decoded
        }
    }

    return (Normalize-OctetDecodedText -Value $RawText)
}

function Convert-InetAddressToFriendly {
    param(
        [AllowNull()] [string]$RawText,
        [string]$AddressType
    )

    if ([string]::IsNullOrEmpty($RawText)) {
        return ''
    }

    if ($RawText -match '^[0-9a-fA-F:.]+$') {
        return $RawText
    }

    $bytes = Convert-OctetTextToBytes -RawText $RawText
    $byteArray = @($bytes)
    if ($byteArray.Count -eq 0) {
        return ''
    }

    if ($AddressType -eq 'ipv4' -and $byteArray.Count -ge 4) {
        return ($byteArray[0..3] | ForEach-Object { [int]$_ }) -join '.'
    }

    if ($AddressType -eq 'ipv6' -and $byteArray.Count -ge 16) {
        return ([System.Net.IPAddress]::new($byteArray[0..15])).ToString()
    }

    if ($AddressType -eq 'dns') {
        $dnsPrintable = -join ($RawText.ToCharArray() | Where-Object {
            $code = [int]$_
            ($code -ge 32 -and $code -le 126)
        })
        return $dnsPrintable.Trim()
    }

    return ($byteArray | ForEach-Object { '{0:X2}' -f $_ }) -join ':'
}

function Get-InetAddressTypeLabel {
    param([string]$Code)
    switch ($Code) {
        '0' { 'unknown' }
        '1' { 'ipv4' }
        '2' { 'ipv6' }
        '3' { 'ipv4z' }
        '4' { 'ipv6z' }
        '16' { 'dns' }
        default { if ([string]::IsNullOrWhiteSpace($Code)) { '' } else { "inetAddrType-$Code" } }
    }
}

function Get-ClientStatusLabel {
    # CISCO-LWAPP-TC-MIB CLDot11ClientStatus (1-based)
    param([string]$Code)
    switch ($Code) {
        '1' { 'Idle' }           '2' { 'AAA Pending' }
        '3' { 'Authenticated' }  '4' { 'Associated' }
        '5' { 'Power Save' }     '6' { 'Disassociated' }
        '7' { 'To Be Deleted' }  '8' { 'Probing' }
        '9' { 'Excluded' }
        default { if ([string]::IsNullOrWhiteSpace($Code)) { '' } else { "Status-$Code" } }
    }
}

function Get-AirespaceClientStatusLabel {
    # AIRESPACE-WIRELESS-MIB bsnMobileStationStatus (0-based)
    param([string]$Code)
    switch ($Code) {
        '0' { 'Idle' }           '1' { 'AAA Pending' }
        '2' { 'Authenticated' }  '3' { 'Associated' }
        '4' { 'Power Save' }     '5' { 'Disassociated' }
        '6' { 'To Be Deleted' }  '7' { 'Probing' }
        '8' { 'Blacklisted' }
        default { if ([string]::IsNullOrWhiteSpace($Code)) { '' } else { "Status-$Code" } }
    }
}

function Get-Dot11ProtocolLabel {
    param([string]$Code)
    if (Get-Command -Name 'Get-CachedEnumLabel' -ErrorAction SilentlyContinue) {
        $label = Get-CachedEnumLabel -Entry 'cldcClientEntry' -Column 'cldcClientProtocol' -Value $Code
        if (-not [string]::IsNullOrWhiteSpace($label) -and $label -ne $Code) { return $label }
    }
    return $Code
}

function Get-ClientBestState {
    param([AllowNull()] [string]$Status)
    switch (([string]$Status).ToLowerInvariant()) {
        'authenticated' { 'healthy' }
        'associated' { 'healthy' }
        'idle' { 'warning' }
        'aaa-pending' { 'warning' }
        'powersave' { 'warning' }
        default { 'unknown' }
    }
}

function Resolve-MibPath {
    param(
        [Parameter(Mandatory)] [string]$MibDir,
        [Parameter(Mandatory)] [string]$MibName
    )

    $candidates = @(
        (Join-Path $MibDir "$MibName.my.txt"),
        (Join-Path $MibDir "$MibName.mib"),
        (Join-Path $MibDir "$MibName.my"),
        (Join-Path $MibDir $MibName)
    )
    foreach ($p in $candidates) {
        if (Test-Path -LiteralPath $p) { return $p }
    }
    return $null
}

function Get-EntryColumnMap {
    param(
        [AllowNull()] [string]$MibPath,
        [Parameter(Mandatory)] [string]$EntryName
    )

    # Try pre-loaded cache first (no MIB file needed)
    if (Get-Command -Name 'Get-CachedColumnMap' -ErrorAction SilentlyContinue) {
        $cached = Get-CachedColumnMap -Entry $EntryName
        if ($null -ne $cached -and $cached.Count -gt 0) {
            return $cached
        }
    }

    # Fall back to MIB file parsing
    $map = @{}
    if ([string]::IsNullOrWhiteSpace($MibPath) -or -not (Test-Path -LiteralPath $MibPath)) {
        return $map
    }

    $content = Get-Content -LiteralPath $MibPath -Raw
    $pattern = "(?ms)^(?<name>[A-Za-z0-9-]+)\s+OBJECT-TYPE\s+.*?::=\s*\{\s*" + [regex]::Escape($EntryName) + "\s+(?<col>\d+)\s*\}"
    $matches = [regex]::Matches($content, $pattern)
    foreach ($m in $matches) {
        $col = [int]$m.Groups['col'].Value
        if (-not $map.ContainsKey($col)) {
            $map[$col] = $m.Groups['name'].Value
        }
    }
    return $map
}

function Add-RowField {
    param(
        [Parameter(Mandatory)] [hashtable]$Row,
        [Parameter(Mandatory)] [string]$Key,
        [AllowNull()] [string]$Value
    )
    $Row[$Key] = if ($null -eq $Value) { '' } else { [string]$Value }
}

function Build-TableRows {
    param(
        [Parameter(Mandatory)] [string]$JsonlPath,
        [Parameter(Mandatory)] [string]$EntryPrefix,
        [hashtable]$ColumnNameMap
    )

    $rowMap = @{}
    $reader = [System.IO.StreamReader]::new($JsonlPath)
    try {
        while (($line = $reader.ReadLine()) -ne $null) {
            if ([string]::IsNullOrWhiteSpace($line)) { continue }
            $item = $line | ConvertFrom-Json
            $oid = [string]$item.OID
            if (-not $oid.StartsWith($EntryPrefix + '.')) { continue }

            $suffix = $oid.Substring($EntryPrefix.Length + 1)
            $parts = $suffix.Split('.')
            if ($parts.Count -lt 2) { continue }

            $col = [int]$parts[0]
            $idx = ($parts[1..($parts.Count - 1)] -join '.')

            if (-not $rowMap.ContainsKey($idx)) {
                $rowMap[$idx] = @{}
            }

            $row = $rowMap[$idx]
            Add-RowField -Row $row -Key ("Col$col") -Value ([string]$item.Value)

            $hexValue = ''
            if ($item.PSObject.Properties.Name -contains 'HexValue') {
                $hexValue = [string]$item.HexValue
            }
            if (-not [string]::IsNullOrWhiteSpace($hexValue)) {
                Add-RowField -Row $row -Key ("Col${col}__Hex") -Value $hexValue
            }

            if ($ColumnNameMap -and $ColumnNameMap.ContainsKey($col)) {
                $columnName = [string]$ColumnNameMap[$col]
                Add-RowField -Row $row -Key $columnName -Value ([string]$item.Value)
                if (-not [string]::IsNullOrWhiteSpace($hexValue)) {
                    Add-RowField -Row $row -Key ("${columnName}__Hex") -Value $hexValue
                }
            }
        }
    }
    finally {
        $reader.Dispose()
    }

    return $rowMap
}

function Build-RootStatsSummary {
    param(
        [Parameter(Mandatory)] [string]$JsonlPath,
        [Parameter(Mandatory)] [string]$BaseOid,
        [Parameter(Mandatory)] [string]$RootName
    )

    $stats = @{}
    $reader = [System.IO.StreamReader]::new($JsonlPath)
    try {
        while (($line = $reader.ReadLine()) -ne $null) {
            if ([string]::IsNullOrWhiteSpace($line)) { continue }
            $item = $line | ConvertFrom-Json
            $oid = [string]$item.OID
            if (-not $oid.StartsWith($BaseOid + '.')) { continue }

            $suffix = $oid.Substring($BaseOid.Length + 1)
            $parts = $suffix.Split('.')
            if ($parts.Count -eq 0) { continue }

            $tableKeyPartCount = [Math]::Min(4, $parts.Count)
            $tableKey = ($parts[0..($tableKeyPartCount - 1)] -join '.')
            if (-not $stats.ContainsKey($tableKey)) {
                $stats[$tableKey] = [PSCustomObject]@{
                    Root = $RootName
                    BaseOid = $BaseOid
                    TableRelativePath = $tableKey
                    TableOidPrefix = "$BaseOid.$tableKey"
                    VariableCount = 0
                    DistinctColumns = New-Object System.Collections.Generic.HashSet[string]
                    DistinctIndexes = New-Object System.Collections.Generic.HashSet[string]
                    Types = @{}
                }
            }

            $s = $stats[$tableKey]
            $s.VariableCount++

            $col = if ($parts.Count -ge 5) { $parts[4] } else { $parts[0] }
            $idx = if ($parts.Count -ge 6) { ($parts[5..($parts.Count - 1)] -join '.') } elseif ($parts.Count -gt 1) { ($parts[1..($parts.Count - 1)] -join '.') } else { '0' }

            $null = $s.DistinctColumns.Add([string]$col)
            $null = $s.DistinctIndexes.Add([string]$idx)

            $type = [string]$item.Type
            if (-not $s.Types.ContainsKey($type)) {
                $s.Types[$type] = 0
            }
            $s.Types[$type]++
        }
    }
    finally {
        $reader.Dispose()
    }

    $rows = foreach ($key in $stats.Keys) {
        $s = $stats[$key]
        [PSCustomObject]@{
            Root = $s.Root
            BaseOid = $s.BaseOid
            TableRelativePath = $s.TableRelativePath
            TableOidPrefix = $s.TableOidPrefix
            VariableCount = $s.VariableCount
            DistinctColumnCount = $s.DistinctColumns.Count
            DistinctIndexCount = $s.DistinctIndexes.Count
            TypeBreakdown = (($s.Types.GetEnumerator() | Sort-Object Name | ForEach-Object { "{0}:{1}" -f $_.Name, $_.Value }) -join '; ')
        }
    }

    return $rows | Sort-Object VariableCount -Descending
}

Ensure-Directory -Path $OutputDirectory

# Resolve MIB files from local /mibs directory (no downloads).
# These are optional when MibTranslationCache.ps1 is loaded (provides Get-CachedColumnMap).
$hasMibCache = [bool](Get-Command -Name 'Get-CachedColumnMap' -ErrorAction SilentlyContinue)
$apMib = Resolve-MibPath -MibDir $MibDirectory -MibName 'CISCO-LWAPP-AP-MIB'
$clientMib = Resolve-MibPath -MibDir $MibDirectory -MibName 'CISCO-LWAPP-DOT11-CLIENT-MIB'
$wlanMib = Resolve-MibPath -MibDir $MibDirectory -MibName 'CISCO-LWAPP-WLAN-MIB'

if (-not $hasMibCache) {
    if (-not $apMib) { Write-Warning "MIB not found: CISCO-LWAPP-AP-MIB in $MibDirectory" }
    if (-not $clientMib) { Write-Warning "MIB not found: CISCO-LWAPP-DOT11-CLIENT-MIB in $MibDirectory" }
    if (-not $wlanMib) { Write-Warning "MIB not found: CISCO-LWAPP-WLAN-MIB in $MibDirectory" }
}

$apColumnMap = Get-EntryColumnMap -MibPath $apMib -EntryName 'cLApEntry'
$clientColumnMap = Get-EntryColumnMap -MibPath $clientMib -EntryName 'cldcClientEntry'
$wlanColumnMap = Get-EntryColumnMap -MibPath $wlanMib -EntryName 'cLWlanConfigEntry'

$rootFiles = @(
    [PSCustomObject]@{ Root = 'ap'; BaseOid = '1.3.6.1.4.1.9.9.513'; Path = Join-Path $InputDirectory 'wireless-ap.jsonl' }
    [PSCustomObject]@{ Root = 'dot11-client'; BaseOid = '1.3.6.1.4.1.9.9.599'; Path = Join-Path $InputDirectory 'wireless-dot11-client.jsonl' }
    [PSCustomObject]@{ Root = 'wlan'; BaseOid = '1.3.6.1.4.1.9.9.512'; Path = Join-Path $InputDirectory 'wireless-wlan.jsonl' }
    [PSCustomObject]@{ Root = 'dot11'; BaseOid = '1.3.6.1.4.1.9.9.612'; Path = Join-Path $InputDirectory 'wireless-dot11.jsonl' }
    [PSCustomObject]@{ Root = 'rogue'; BaseOid = '1.3.6.1.4.1.9.9.610'; Path = Join-Path $InputDirectory 'wireless-rogue.jsonl' }
    [PSCustomObject]@{ Root = 'rf'; BaseOid = '1.3.6.1.4.1.9.9.778'; Path = Join-Path $InputDirectory 'wireless-rf.jsonl' }
    [PSCustomObject]@{ Root = 'mobility-ext'; BaseOid = '1.3.6.1.4.1.9.9.846'; Path = Join-Path $InputDirectory 'wireless-mobility-ext.jsonl' }
    [PSCustomObject]@{ Root = 'sys'; BaseOid = '1.3.6.1.4.1.9.9.618'; Path = Join-Path $InputDirectory 'wireless-sys.jsonl' }
    [PSCustomObject]@{ Root = 'airespace-client'; BaseOid = '1.3.6.1.4.1.14179.2.1.4'; Path = Join-Path $InputDirectory 'wireless-airespace-client.jsonl' }
    [PSCustomObject]@{ Root = 'airespace-ap'; BaseOid = '1.3.6.1.4.1.14179.2.2.1'; Path = Join-Path $InputDirectory 'wireless-airespace-ap.jsonl' }
)

$summaryArtifacts = @()
$apInventory = @()
$clientInventory = @()
$wlanInventory = @()

$airespaceInput = Join-Path $InputDirectory 'wireless-airespace-client.jsonl'
$clientInput = Join-Path $InputDirectory 'wireless-dot11-client.jsonl'

$dot11ClientByMac = @{}
if (Test-Path -LiteralPath $clientInput) {
    $clientEntryPrefix = '1.3.6.1.4.1.9.9.599.1.3.1.1'
    $dot11RowsForEnrichment = Build-TableRows -JsonlPath $clientInput -EntryPrefix $clientEntryPrefix -ColumnNameMap $clientColumnMap

    foreach ($idx in $dot11RowsForEnrichment.Keys) {
        $row = $dot11RowsForEnrichment[$idx]
        $clientMac = Convert-IndexToMac -Index $idx
        if ([string]::IsNullOrWhiteSpace($clientMac)) { continue }

        $ssidRaw = if ($row.ContainsKey('cldcClientSSID')) { $row['cldcClientSSID'] } else { '' }
        $ssidHex = if ($row.ContainsKey('cldcClientSSID__Hex')) { $row['cldcClientSSID__Hex'] } else { '' }
        $ssid = Resolve-OctetStringText -RawText $ssidRaw -HexText $ssidHex

        $profileRaw = if ($row.ContainsKey('cldcClientWlanProfileName')) { $row['cldcClientWlanProfileName'] } else { '' }
        $profileHex = if ($row.ContainsKey('cldcClientWlanProfileName__Hex')) { $row['cldcClientWlanProfileName__Hex'] } else { '' }
        $wlanProfile = Resolve-OctetStringText -RawText $profileRaw -HexText $profileHex

        $usernameRaw = if ($row.ContainsKey('cldcClientUsername')) { $row['cldcClientUsername'] } else { '' }
        $usernameHex = if ($row.ContainsKey('cldcClientUsername__Hex')) { $row['cldcClientUsername__Hex'] } else { '' }
        $username = Resolve-OctetStringText -RawText $usernameRaw -HexText $usernameHex
        $dot11StatusCode = if ($row.ContainsKey('cldcClientStatus')) { [string]$row['cldcClientStatus'] } else { '' }
        $dot11AuthModeCode = if ($row.ContainsKey('cldcClientAuthMode')) { [string]$row['cldcClientAuthMode'] } else { '' }
        $dot11ProtocolCode = if ($row.ContainsKey('cldcClientProtocol')) { [string]$row['cldcClientProtocol'] } else { '' }

        $dot11ClientByMac[$clientMac.ToUpperInvariant()] = [PSCustomObject]@{
            StatusCode = $dot11StatusCode
            Status = Get-ClientStatusLabel -Code $dot11StatusCode
            WlanProfileName = $wlanProfile
            SSID = $ssid
            Username = $username
            AccessVLAN = if ($row.ContainsKey('cldcClientAccessVLAN')) { [string]$row['cldcClientAccessVLAN'] } else { '' }
            Channel = if ($row.ContainsKey('cldcClientChannel')) { [string]$row['cldcClientChannel'] } else { '' }
            AuthModeCode = $dot11AuthModeCode
            AuthMode = Get-CachedEnumLabel -Entry 'cldcClientEntry' -Column 'cldcClientAuthMode' -Value $dot11AuthModeCode
            ProtocolCode = $dot11ProtocolCode
            Protocol = Get-Dot11ProtocolLabel -Code $dot11ProtocolCode
            DeviceType = if ($row.ContainsKey('cldcClientDeviceType')) { [string]$row['cldcClientDeviceType'] } else { '' }
        }
    }

    Write-Host "Built dot11-client enrichment map: $($dot11ClientByMac.Count) rows" -ForegroundColor Cyan
}

foreach ($rf in $rootFiles) {
    if (-not (Test-Path -LiteralPath $rf.Path)) {
        Write-Warning "Skipping $($rf.Root): missing input $($rf.Path)"
        continue
    }

    $stats = Build-RootStatsSummary -JsonlPath $rf.Path -BaseOid $rf.BaseOid -RootName $rf.Root
    $csv = Join-Path $OutputDirectory ("wireless-$($rf.Root)-summary.csv")
    $json = Join-Path $OutputDirectory ("wireless-$($rf.Root)-summary.json")

    $stats | Export-Csv -LiteralPath $csv -NoTypeInformation -Encoding UTF8
    ($stats | ConvertTo-Json -Depth 10) | Out-File -LiteralPath $json -Encoding UTF8

    $summaryArtifacts += [PSCustomObject]@{
        Name = "root-summary-$($rf.Root)"
        Root = $rf.Root
        Csv = $csv
        Json = $json
        Count = @($stats).Count
    }

    Write-Host "Built root summary for $($rf.Root): $(@($stats).Count) table groups" -ForegroundColor Green
}

# Build AP IP address lookup from bsnAPTable (AIRESPACE-WIRELESS-MIB)
# bsnAPEntry: 1.3.6.1.4.1.14179.2.2.1.1, Col 19 = bsnAPIpAddress
$bsnApInput = Join-Path $InputDirectory 'wireless-airespace-ap.jsonl'
$apIpByIndex = @{}  # OID index suffix -> IP address
if (Test-Path -LiteralPath $bsnApInput) {
    $bsnApEntryPrefix = '1.3.6.1.4.1.14179.2.2.1.1'
    $bsnApRows = Build-TableRows -JsonlPath $bsnApInput -EntryPrefix $bsnApEntryPrefix -ColumnNameMap @{}
    foreach ($idx in $bsnApRows.Keys) {
        $bsnRow = $bsnApRows[$idx]
        $ipVal = if ($bsnRow.ContainsKey('Col19')) { $bsnRow['Col19'] } else { '' }
        if (-not [string]::IsNullOrWhiteSpace($ipVal) -and $ipVal -ne '0.0.0.0') {
            $apIpByIndex[$idx] = $ipVal
        }
    }
    Write-Host "Built AP IP lookup from bsnAPTable: $($apIpByIndex.Count) entries" -ForegroundColor Green
}

# AP inventory summary (translated with cLApEntry labels).
$apInput = Join-Path $InputDirectory 'wireless-ap.jsonl'
if (Test-Path -LiteralPath $apInput) {
    $apEntryPrefix = '1.3.6.1.4.1.9.9.513.1.1.1.1'
    $apRows = Build-TableRows -JsonlPath $apInput -EntryPrefix $apEntryPrefix -ColumnNameMap $apColumnMap

    $apInventory = foreach ($idx in $apRows.Keys) {
        $row = $apRows[$idx]
        $type1 = if ($row.ContainsKey('cLApPrimaryControllerAddressType')) { $row['cLApPrimaryControllerAddressType'] } else { '' }
        $type2 = if ($row.ContainsKey('cLApSecondaryControllerAddressType')) { $row['cLApSecondaryControllerAddressType'] } else { '' }
        $type3 = if ($row.ContainsKey('cLApTertiaryControllerAddressType')) { $row['cLApTertiaryControllerAddressType'] } else { '' }
        $label1 = Get-InetAddressTypeLabel -Code $type1
        $label2 = Get-InetAddressTypeLabel -Code $type2
        $label3 = Get-InetAddressTypeLabel -Code $type3

        $domainRaw = if ($row.ContainsKey('cLApDomainName')) { $row['cLApDomainName'] } else { '' }
        $domainHex = if ($row.ContainsKey('cLApDomainName__Hex')) { $row['cLApDomainName__Hex'] } else { '' }
        $siteTagRaw = if ($row.ContainsKey('cLApSiteTagName')) { $row['cLApSiteTagName'] } else { '' }
        $siteTagHex = if ($row.ContainsKey('cLApSiteTagName__Hex')) { $row['cLApSiteTagName__Hex'] } else { '' }
        $rfTagRaw = if ($row.ContainsKey('cLApRfTagName')) { $row['cLApRfTagName'] } else { '' }
        $rfTagHex = if ($row.ContainsKey('cLApRfTagName__Hex')) { $row['cLApRfTagName__Hex'] } else { '' }
        $policyTagRaw = if ($row.ContainsKey('cLApPolicyTagName')) { $row['cLApPolicyTagName'] } else { '' }
        $policyTagHex = if ($row.ContainsKey('cLApPolicyTagName__Hex')) { $row['cLApPolicyTagName__Hex'] } else { '' }
        $filterNameRaw = if ($row.ContainsKey('cLApFilterName')) { $row['cLApFilterName'] } else { '' }
        $filterNameHex = if ($row.ContainsKey('cLApFilterName__Hex')) { $row['cLApFilterName__Hex'] } else { '' }
        $usbSerialRaw = if ($row.ContainsKey('cLApUsbSerialNumber')) { $row['cLApUsbSerialNumber'] } else { '' }
        $usbSerialHex = if ($row.ContainsKey('cLApUsbSerialNumber__Hex')) { $row['cLApUsbSerialNumber__Hex'] } else { '' }

        [PSCustomObject]@{
            APName = if ($row.ContainsKey('cLApName')) { $row['cLApName'] } else { '' }
            APMac = Convert-IndexToMac -Index $idx
            IndexSuffix = $idx
            APIPAddress = if ($apIpByIndex.ContainsKey($idx)) { $apIpByIndex[$idx] } else { '' }
            Dot11Slots = if ($row.ContainsKey('cLApMaxNumberOfDot11Slots')) { $row['cLApMaxNumberOfDot11Slots'] } else { '' }
            ApUptime = if ($row.ContainsKey('cLApUpTime')) { $row['cLApUpTime'] } else { '' }
            ControllerUptimeSeen = if ($row.ContainsKey('cLLwappUpTime')) { $row['cLLwappUpTime'] } else { '' }
            LastChange = if ($row.ContainsKey('cLLwappJoinTakenTime')) { $row['cLLwappJoinTakenTime'] } else { '' }
            PrimaryControllerAddressType = $label1
            PrimaryControllerAddress = if ($row.ContainsKey('cLApPrimaryControllerAddress')) { Convert-InetAddressToFriendly -RawText $row['cLApPrimaryControllerAddress'] -AddressType $label1 } else { '' }
            SecondaryControllerAddressType = $label2
            SecondaryControllerAddress = if ($row.ContainsKey('cLApSecondaryControllerAddress')) { Convert-InetAddressToFriendly -RawText $row['cLApSecondaryControllerAddress'] -AddressType $label2 } else { '' }
            TertiaryControllerAddressType = $label3
            TertiaryControllerAddress = if ($row.ContainsKey('cLApTertiaryControllerAddress')) { Convert-InetAddressToFriendly -RawText $row['cLApTertiaryControllerAddress'] -AddressType $label3 } else { '' }
            DomainName = Resolve-OctetStringText -RawText $domainRaw -HexText $domainHex
            SiteTagName = Resolve-OctetStringText -RawText $siteTagRaw -HexText $siteTagHex
            RfTagName = Resolve-OctetStringText -RawText $rfTagRaw -HexText $rfTagHex
            PolicyTagName = Resolve-OctetStringText -RawText $policyTagRaw -HexText $policyTagHex
            FilterName = Resolve-OctetStringText -RawText $filterNameRaw -HexText $filterNameHex
            UsbSerialNumber = Resolve-OctetStringText -RawText $usbSerialRaw -HexText $usbSerialHex
            AdminStatus = if ($row.ContainsKey('cLApAdminStatus')) { if ($row['cLApAdminStatus'] -eq '1') { 'Up' } elseif ($row['cLApAdminStatus'] -eq '2') { 'Down' } else { [string]$row['cLApAdminStatus'] } } else { '' }
            PowerStatus = Get-CachedEnumLabel -Entry 'cLApEntry' -Column 'cLApPowerStatus' -Value $(if ($row.ContainsKey('cLApPowerStatus')) { $row['cLApPowerStatus'] } else { '' })
            CpuCurrentPct = if ($row.ContainsKey('cLApCpuCurrentUsage')) { $row['cLApCpuCurrentUsage'] } else { '' }
            CpuAvgPct = if ($row.ContainsKey('cLApCpuAverageUsage')) { $row['cLApCpuAverageUsage'] } else { '' }
            MemCurrentPct = if ($row.ContainsKey('cLApMemoryCurrentUsage')) { $row['cLApMemoryCurrentUsage'] } else { '' }
            MemAvgPct = if ($row.ContainsKey('cLApMemoryAverageUsage')) { $row['cLApMemoryAverageUsage'] } else { '' }
            FailoverPriority = Get-CachedEnumLabel -Entry 'cLApEntry' -Column 'cLApFailoverPriority' -Value $(if ($row.ContainsKey('cLApFailoverPriority')) { $row['cLApFailoverPriority'] } else { '' })
            LastRebootReason = Get-CachedEnumLabel -Entry 'cLApEntry' -Column 'cLApLastRebootReason' -Value $(if ($row.ContainsKey('cLApLastRebootReason')) { $row['cLApLastRebootReason'] } else { '' })
            SubMode = Get-CachedEnumLabel -Entry 'cLApEntry' -Column 'cLApSubMode' -Value $(if ($row.ContainsKey('cLApSubMode')) { $row['cLApSubMode'] } else { '' })
            AntennaBandMode = Get-CachedEnumLabel -Entry 'cLApEntry' -Column 'cLApAntennaBandMode' -Value $(if ($row.ContainsKey('cLApAntennaBandMode')) { $row['cLApAntennaBandMode'] } else { '' })
            AssocClientCount = if ($row.ContainsKey('cLApAssociatedClientCount')) { $row['cLApAssociatedClientCount'] } else { '' }
            ActiveClientCount = if ($row.ContainsKey('cLApActiveClientCount')) { $row['cLApActiveClientCount'] } else { '' }
            JoinTime = if ($IncludeJoinTime -and $row.ContainsKey('cLLwappJoinTakenTime')) { $row['cLLwappJoinTakenTime'] } else { '' }
        }
    }

    $apInventory = $apInventory | Sort-Object APName, APMac
    $apCsv = Join-Path $OutputDirectory 'wireless-ap-inventory-summary.csv'
    $apJson = Join-Path $OutputDirectory 'wireless-ap-inventory-summary.json'
    $apInventory | Export-Csv -LiteralPath $apCsv -NoTypeInformation -Encoding UTF8
    ($apInventory | ConvertTo-Json -Depth 10) | Out-File -LiteralPath $apJson -Encoding UTF8

    $summaryArtifacts += [PSCustomObject]@{ Name = 'ap-inventory'; Root = 'ap'; Csv = $apCsv; Json = $apJson; Count = @($apInventory).Count }
    Write-Host "Built AP inventory summary: $(@($apInventory).Count) rows" -ForegroundColor Green
}

# Client inventory summary - prefer AIRESPACE bsnMobileStationTable for direct AP base MAC + SSID joins.
if (Test-Path -LiteralPath $airespaceInput) {
    # AIRESPACE bsnMobileStationTable: indexed by client MAC (6 bytes in OID suffix)
    # Col 2=IP, 3=username, 4=AP base MAC, 5=slot, 7=SSID, 9=status, 11=mobilityStatus,
    # 25=protocol, 27=interface, 29=vlanId, 30=policyType, 31=encryption, 32=eapType
    $bsnEntryPrefix = '1.3.6.1.4.1.14179.2.1.4.1'
    $bsnRows = Build-TableRows -JsonlPath $airespaceInput -EntryPrefix $bsnEntryPrefix -ColumnNameMap @{}

    $clientInventory = foreach ($idx in $bsnRows.Keys) {
        $row = $bsnRows[$idx]
        $clientMac = Convert-IndexToMac -Index $idx
        $enriched = $null
        $enrichedKey = $clientMac.ToUpperInvariant()
        if (-not [string]::IsNullOrWhiteSpace($enrichedKey) -and $dot11ClientByMac.ContainsKey($enrichedKey)) {
            $enriched = $dot11ClientByMac[$enrichedKey]
        }

        # Col 4 = AP base MAC (OctetString, 6 bytes) - use hex field for reliable decoding
        $apMacHex = if ($row.ContainsKey('Col4__Hex')) { $row['Col4__Hex'] } else { '' }
        $apMacText = if ($row.ContainsKey('Col4')) { $row['Col4'] } else { '' }
        $resolvedApMac = if (-not [string]::IsNullOrWhiteSpace($apMacHex)) { Convert-HexStringToMac -Hex $apMacHex } else { Convert-OctetTextToMac -RawText $apMacText }

        # Col 6 = WLAN Index (bsnMobileStationEssIndex, INTEGER) - for WLAN profile lookup
        $wlanIndex = if ($row.ContainsKey('Col6')) { $row['Col6'] } else { '' }

        # Col 7 = SSID (OctetString, ASCII text)
        $ssidHex = if ($row.ContainsKey('Col7__Hex')) { $row['Col7__Hex'] } else { '' }
        $ssidRaw = if ($row.ContainsKey('Col7')) { $row['Col7'] } else { '' }
        $ssid = Resolve-OctetStringText -RawText $ssidRaw -HexText $ssidHex

        # Col 27 = WLAN Profile name (from bsnMobileStationInterface column)
        $profileHex = if ($row.ContainsKey('Col27__Hex')) { $row['Col27__Hex'] } else { '' }
        $profileRaw = if ($row.ContainsKey('Col27')) { $row['Col27'] } else { '' }
        $wlanProfile = Resolve-OctetStringText -RawText $profileRaw -HexText $profileHex

        # Col 2 = IP, Col 3 = Username, Col 5 = slot, Col 23 = status text
        $ipRaw = if ($row.ContainsKey('Col2')) { $row['Col2'] } else { '' }
        $usernameRaw = if ($row.ContainsKey('Col3')) { $row['Col3'] } else { '' }
        $usernameHex = if ($row.ContainsKey('Col3__Hex')) { $row['Col3__Hex'] } else { '' }
        $username = Resolve-OctetStringText -RawText $usernameRaw -HexText $usernameHex
        $slot = if ($row.ContainsKey('Col5')) { $row['Col5'] } else { '' }
        $statusText = if ($row.ContainsKey('Col23')) { $row['Col23'] } else { '' }
        $statusHex = if ($row.ContainsKey('Col23__Hex')) { $row['Col23__Hex'] } else { '' }
        $statusDecoded = Resolve-OctetStringText -RawText $statusText -HexText $statusHex

        # Col 9 = bsnMobileStationStatus (0-based INTEGER enum), Col 25 = protocol, Col 30 = policy type, Col 31 = encryption
        $airespaceStatusCode  = if ($row.ContainsKey('Col9'))  { $row['Col9'] }  else { '' }
        $airespaceProtocol    = if ($row.ContainsKey('Col25')) { $row['Col25'] } else { '' }
        $airespasePolicyType  = if ($row.ContainsKey('Col30')) { $row['Col30'] } else { '' }
        $airespaceEncryption  = if ($row.ContainsKey('Col31')) { $row['Col31'] } else { '' }
        $airespaseMobility    = if ($row.ContainsKey('Col11')) { $row['Col11'] } else { '' }

        [PSCustomObject]@{
            ClientMac = $clientMac
            IndexSuffix = $idx
            WlanIndex = $wlanIndex
            StatusCode = if ($null -ne $enriched -and -not [string]::IsNullOrWhiteSpace($enriched.StatusCode)) { $enriched.StatusCode } else { $airespaceStatusCode }
            Status = if ($null -ne $enriched -and -not [string]::IsNullOrWhiteSpace($enriched.Status)) {
                $enriched.Status
            } elseif (-not [string]::IsNullOrWhiteSpace($statusDecoded)) {
                $statusDecoded
            } else {
                Get-AirespaceClientStatusLabel -Code $airespaceStatusCode
            }
            WlanProfileName = if ($null -ne $enriched -and -not [string]::IsNullOrWhiteSpace($enriched.WlanProfileName)) { $enriched.WlanProfileName } else { $wlanProfile }
            SSID = if ($null -ne $enriched -and -not [string]::IsNullOrWhiteSpace($enriched.SSID)) { $enriched.SSID } else { $ssid }
            Username = if ($null -ne $enriched -and -not [string]::IsNullOrWhiteSpace($enriched.Username)) { $enriched.Username } else { $username }
            APMac = $resolvedApMac
            APSlot = $slot
            ClientIP = $ipRaw
            AccessVLAN = if ($null -ne $enriched) { [string]$enriched.AccessVLAN } else { '' }
            Channel = if ($null -ne $enriched) { [string]$enriched.Channel } else { '' }
            AuthMode = if ($null -ne $enriched -and -not [string]::IsNullOrWhiteSpace($enriched.AuthMode)) { $enriched.AuthMode } else { '' }
            Protocol = if ($null -ne $enriched -and -not [string]::IsNullOrWhiteSpace($enriched.Protocol)) {
                $enriched.Protocol
            } else {
                Get-Dot11ProtocolLabel -Code $airespaceProtocol
            }
            MobilityStatus = Get-CachedEnumLabel -Entry 'bsnMobileStationEntry' -Column 'bsnMobileStationMobilityStatus' -Value $airespaseMobility
            PolicyType = Get-CachedEnumLabel -Entry 'bsnMobileStationEntry' -Column 'bsnMobileStationPolicyType' -Value $airespasePolicyType
            Encryption = Get-CachedEnumLabel -Entry 'bsnMobileStationEntry' -Column 'bsnMobileStationEncryptionCypher' -Value $airespaceEncryption
            DeviceType = if ($null -ne $enriched) { [string]$enriched.DeviceType } else { '' }
        }
    }

    $clientInventory = $clientInventory | Sort-Object SSID, WlanProfileName, ClientMac
    $clientCsv = Join-Path $OutputDirectory 'wireless-client-inventory-summary.csv'
    $clientJson = Join-Path $OutputDirectory 'wireless-client-inventory-summary.json'
    $clientInventory | Export-Csv -LiteralPath $clientCsv -NoTypeInformation -Encoding UTF8
    ($clientInventory | ConvertTo-Json -Depth 12) | Out-File -LiteralPath $clientJson -Encoding UTF8

    $summaryArtifacts += [PSCustomObject]@{ Name = 'client-inventory'; Root = 'airespace-client'; Csv = $clientCsv; Json = $clientJson; Count = @($clientInventory).Count }
    Write-Host "Built client inventory (AIRESPACE bsnMobileStation, direct AP base MAC joins): $(@($clientInventory).Count) rows" -ForegroundColor Green

} elseif (Test-Path -LiteralPath $clientInput) {
    # Fallback: CISCO-LWAPP-DOT11-CLIENT-MIB (gives BSSID, not AP base MAC)
    $clientEntryPrefix = '1.3.6.1.4.1.9.9.599.1.3.1.1'
    $clientRows = Build-TableRows -JsonlPath $clientInput -EntryPrefix $clientEntryPrefix -ColumnNameMap $clientColumnMap

    $clientInventory = foreach ($idx in $clientRows.Keys) {
        $row = $clientRows[$idx]
        $statusCode = if ($row.ContainsKey('cldcClientStatus')) { $row['cldcClientStatus'] } else { '' }
        $apMacHex = if ($row.ContainsKey('cldcApMacAddress__Hex')) { $row['cldcApMacAddress__Hex'] } else { '' }
        $apMacText = if ($row.ContainsKey('cldcApMacAddress')) { $row['cldcApMacAddress'] } else { '' }
        $resolvedApMac = if (-not [string]::IsNullOrWhiteSpace($apMacHex)) { Convert-HexStringToMac -Hex $apMacHex } else { Convert-OctetTextToMac -RawText $apMacText }

        $ssidRaw = if ($row.ContainsKey('cldcClientSSID')) { $row['cldcClientSSID'] } else { '' }
        $ssidHex = if ($row.ContainsKey('cldcClientSSID__Hex')) { $row['cldcClientSSID__Hex'] } else { '' }
        $ssid = Resolve-OctetStringText -RawText $ssidRaw -HexText $ssidHex

        $profileRaw = if ($row.ContainsKey('cldcClientWlanProfileName')) { $row['cldcClientWlanProfileName'] } else { '' }
        $profileHex = if ($row.ContainsKey('cldcClientWlanProfileName__Hex')) { $row['cldcClientWlanProfileName__Hex'] } else { '' }
        $wlanProfile = Resolve-OctetStringText -RawText $profileRaw -HexText $profileHex

        $usernameRaw = if ($row.ContainsKey('cldcClientUsername')) { $row['cldcClientUsername'] } else { '' }
        $usernameHex = if ($row.ContainsKey('cldcClientUsername__Hex')) { $row['cldcClientUsername__Hex'] } else { '' }
        $username = Resolve-OctetStringText -RawText $usernameRaw -HexText $usernameHex

        [PSCustomObject]@{
            ClientMac = Convert-IndexToMac -Index $idx
            IndexSuffix = $idx
            StatusCode = $statusCode
            Status = Get-ClientStatusLabel -Code $statusCode
            WlanProfileName = $wlanProfile
            SSID = $ssid
            Username = $username
            APMac = $resolvedApMac
            APSlot = ''
            ClientIP = if ($row.ContainsKey('cldcClientIPAddress')) { Convert-InetAddressToFriendly -RawText $row['cldcClientIPAddress'] -AddressType 'ipv4' } else { '' }
            AccessVLAN = if ($row.ContainsKey('cldcClientAccessVLAN')) { $row['cldcClientAccessVLAN'] } else { '' }
            Channel = if ($row.ContainsKey('cldcClientChannel')) { $row['cldcClientChannel'] } else { '' }
            AuthMode = if ($row.ContainsKey('cldcClientAuthMode')) { $row['cldcClientAuthMode'] } else { '' }
            DeviceType = if ($row.ContainsKey('cldcClientDeviceType')) { $row['cldcClientDeviceType'] } else { '' }
        }
    }

    $clientInventory = $clientInventory | Sort-Object SSID, WlanProfileName, ClientMac
    $clientCsv = Join-Path $OutputDirectory 'wireless-client-inventory-summary.csv'
    $clientJson = Join-Path $OutputDirectory 'wireless-client-inventory-summary.json'
    $clientInventory | Export-Csv -LiteralPath $clientCsv -NoTypeInformation -Encoding UTF8
    ($clientInventory | ConvertTo-Json -Depth 12) | Out-File -LiteralPath $clientJson -Encoding UTF8

    $summaryArtifacts += [PSCustomObject]@{ Name = 'client-inventory'; Root = 'dot11-client'; Csv = $clientCsv; Json = $clientJson; Count = @($clientInventory).Count }
    Write-Host "Built client inventory (LWAPP fallback, BSSID-based AP MAC): $(@($clientInventory).Count) rows" -ForegroundColor Green
}

# WLAN inventory summary (translated with cLWlanConfigEntry labels).
$wlanInput = Join-Path $InputDirectory 'wireless-wlan.jsonl'
if (Test-Path -LiteralPath $wlanInput) {
    $wlanEntryPrefix = '1.3.6.1.4.1.9.9.512.1.1.1.1'
    $wlanRows = Build-TableRows -JsonlPath $wlanInput -EntryPrefix $wlanEntryPrefix -ColumnNameMap $wlanColumnMap

    $wlanInventory = foreach ($idx in $wlanRows.Keys) {
        $row = $wlanRows[$idx]
        [PSCustomObject]@{
            WlanIndex = if ($row.ContainsKey('cLWlanIndex')) { $row['cLWlanIndex'] } else { $idx }
            IndexSuffix = $idx
            ProfileName = if ($row.ContainsKey('cLWlanProfileName')) { $row['cLWlanProfileName'] } else { '' }
            SSID = if ($row.ContainsKey('cLWlanSsid')) { $row['cLWlanSsid'] } else { '' }
            RowStatus = if ($row.ContainsKey('cLWlanRowStatus')) { $row['cLWlanRowStatus'] } else { '' }
            IsWired = if ($row.ContainsKey('cLWlanIsWired')) { $row['cLWlanIsWired'] } else { '' }
            NACSupport = if ($row.ContainsKey('cLWlanNACSupport')) { $row['cLWlanNACSupport'] } else { '' }
            MaxClientsAllowedPerRadio = if ($row.ContainsKey('cLWlanMaxClientsAllowedPerRadio')) { $row['cLWlanMaxClientsAllowedPerRadio'] } else { '' }
            LoadBalancingEnable = if ($row.ContainsKey('cLWlanLoadBalancingEnable')) { $row['cLWlanLoadBalancingEnable'] } else { '' }
            BandSelectEnable = if ($row.ContainsKey('cLWlanBandSelectEnable')) { $row['cLWlanBandSelectEnable'] } else { '' }
        }
    }

    $wlanInventory = $wlanInventory | Sort-Object WlanIndex
    
    # Enrich client inventory with WLAN Profile Names using WLAN Index lookup
    if ($null -ne $clientInventory) {
        $wlanLookup = @{}
        foreach ($wlan in $wlanInventory) {
            $key = [string]$wlan.WlanIndex
            if (-not [string]::IsNullOrWhiteSpace($key)) {
                $wlanLookup[$key] = $wlan.ProfileName
            }
        }
        
        if ($wlanLookup.Count -gt 0) {
            $clientInventory = $clientInventory | ForEach-Object {
                if ([string]::IsNullOrWhiteSpace($_.WlanProfileName) -and -not [string]::IsNullOrWhiteSpace($_.WlanIndex)) {
                    $wlanKey = [string]$_.WlanIndex
                    if ($wlanLookup.ContainsKey($wlanKey)) {
                        $_ | Add-Member -MemberType NoteProperty -Name 'WlanProfileName' -Value $wlanLookup[$wlanKey] -Force
                    }
                }
                $_
            }
        }
    }
    
    $wlanCsv = Join-Path $OutputDirectory 'wireless-wlan-inventory-summary.csv'
    $wlanJson = Join-Path $OutputDirectory 'wireless-wlan-inventory-summary.json'
    $wlanInventory | Export-Csv -LiteralPath $wlanCsv -NoTypeInformation -Encoding UTF8
    ($wlanInventory | ConvertTo-Json -Depth 12) | Out-File -LiteralPath $wlanJson -Encoding UTF8

    $summaryArtifacts += [PSCustomObject]@{ Name = 'wlan-inventory'; Root = 'wlan'; Csv = $wlanCsv; Json = $wlanJson; Count = @($wlanInventory).Count }
    Write-Host "Built WLAN inventory summary: $(@($wlanInventory).Count) rows" -ForegroundColor Green
}

# Rogue AP proximity summary (optional, -IncludeRogues)
$rogueProximity = @()
if ($IncludeRogues -and (Test-Path -LiteralPath (Join-Path $InputDirectory 'wireless-rogue.jsonl'))) {
    $rogueLines = [System.IO.File]::ReadAllLines((Join-Path $InputDirectory 'wireless-rogue.jsonl'))
    $rogues = @{}
    foreach ($line in $rogueLines) {
        $obj = $line | ConvertFrom-Json
        # cLRogueAPDetectingAPEntry = 1.3.6.1.4.1.9.9.610.1.1.8.1.1
        if ($obj.OID.StartsWith('1.3.6.1.4.1.9.9.610.1.1.8.1.1.')) {
            $remainder = $obj.OID.Substring('1.3.6.1.4.1.9.9.610.1.1.8.1.1.'.Length)
            $parts = $remainder -split '\.'
            $col = [int]$parts[0]
            $rogueIndex = ($parts[1..6]) -join '.'
            
            if (-not $rogues.ContainsKey($rogueIndex)) { $rogues[$rogueIndex] = @{ RSSI = @(); SSID = ''; Channel = ''; ClassType = ''; State = ''; DetectCount = 0 } }
            if ($col -eq 1) { $rogues[$rogueIndex]['DetectCount']++ }
            if ($col -eq 3) { $rogues[$rogueIndex]['Channel'] = [string]$obj.Value }
            if ($col -eq 6) { $rogues[$rogueIndex]['SSID'] = [string]$obj.Value }
            if ($col -eq 8) { $rogues[$rogueIndex]['RSSI'] += [int]([string]$obj.Value) }
        }
    }
    
    $rogueProximity = $rogues.GetEnumerator() | ForEach-Object {
        $rssiList = $_.Value['RSSI']
        $bestRSSI = if ($rssiList.Count -gt 0) { ($rssiList | Measure-Object -Maximum).Maximum } else { -999 }
        [PSCustomObject]@{
            RogueMacIndex = $_.Key
            SSID = $_.Value['SSID']
            Channel = [string]$_.Value['Channel']
            BestRSSI = [string]$bestRSSI
            DetectingAPCount = [string]$_.Value['DetectCount']
        }
    } | Sort-Object { [int]$_.BestRSSI } -Descending
    
    if ($rogueProximity.Count -gt 0) {
        $rogueCsv = Join-Path $OutputDirectory 'wireless-rogue-proximity-summary.csv'
        $rogueJson = Join-Path $OutputDirectory 'wireless-rogue-proximity-summary.json'
        $rogueProximity | Export-Csv -LiteralPath $rogueCsv -NoTypeInformation -Encoding UTF8
        ($rogueProximity | ConvertTo-Json -Depth 10) | Out-File -LiteralPath $rogueJson -Encoding UTF8
        $summaryArtifacts += [PSCustomObject]@{ Name = 'rogue-proximity'; Root = 'rogue'; Csv = $rogueCsv; Json = $rogueJson; Count = @($rogueProximity).Count }
        Write-Host "Built rogue proximity summary: $(@($rogueProximity).Count) rows" -ForegroundColor Green
    }
}

# RF group health summary (optional, -IncludeRfHealth)
$rfHealth = @()
if ($IncludeRfHealth -and (Test-Path -LiteralPath (Join-Path $InputDirectory 'wireless-rf.jsonl'))) {
    $rfLines = [System.IO.File]::ReadAllLines((Join-Path $InputDirectory 'wireless-rf.jsonl'))
    $rfGroups = @{}
    # Table 778.1.1.2.1 = RF group stats (64 columns, indexed by group name)
    $rfBase = '1.3.6.1.4.1.9.9.778.1.1.2.1'
    
    foreach ($line in $rfLines) {
        $obj = $line | ConvertFrom-Json
        if ($obj.OID.StartsWith($rfBase + '.')) {
            $remainder = $obj.OID.Substring(($rfBase + '.').Length)
            $parts = $remainder -split '\.'
            $col = [int]$parts[0]
            $groupNameLen = [int]$parts[1]
            $groupNameBytes = $parts[2..($groupNameLen + 1)]
            $groupName = ($groupNameBytes | ForEach-Object { [char][int]$_ }) -join ''
            
            if (-not $rfGroups.ContainsKey($groupName)) {
                $rfGroups[$groupName] = @{ Name = $groupName; Channels = ''; Utilization = ''; NoiseFloor = ''; TxPower = ''; ClientCount = '' }
            }
            
            # Col 2=group name, 43=channel list, others are power/utilization/noise
            if ($col -eq 43) { $rfGroups[$groupName]['Channels'] = [string]$obj.Value }
        }
    }
    
    if ($rfGroups.Count -gt 0) {
        $rfHealth = $rfGroups.Values | Sort-Object Name
        $rfCsv = Join-Path $OutputDirectory 'wireless-rf-health-summary.csv'
        $rfJson = Join-Path $OutputDirectory 'wireless-rf-health-summary.json'
        $rfHealth | Export-Csv -LiteralPath $rfCsv -NoTypeInformation -Encoding UTF8
        ($rfHealth | ConvertTo-Json -Depth 10) | Out-File -LiteralPath $rfJson -Encoding UTF8
        $summaryArtifacts += [PSCustomObject]@{ Name = 'rf-health'; Root = 'rf'; Csv = $rfCsv; Json = $rfJson; Count = @($rfHealth).Count }
        Write-Host "Built RF group health summary: $(@($rfHealth).Count) rows" -ForegroundColor Green
    }
}

$topSsids = @()
if (@($clientInventory).Count -gt 0) {
    $topSsids = $clientInventory |
        Group-Object SSID |
        Where-Object { -not [string]::IsNullOrWhiteSpace($_.Name) } |
        Sort-Object Count -Descending |
        Select-Object -First 25 @{ n = 'SSID'; e = { $_.Name } }, @{ n = 'ClientCount'; e = { $_.Count } }
}

$clientsByAp = @{}
foreach ($client in @($clientInventory)) {
    $apMacKey = ([string]$client.APMac).ToUpperInvariant()
    if ([string]::IsNullOrWhiteSpace($apMacKey)) { continue }

    if (-not $clientsByAp.ContainsKey($apMacKey)) {
        $clientsByAp[$apMacKey] = New-Object System.Collections.Generic.List[object]
    }

    $clientsByAp[$apMacKey].Add($client)
}

$apOperational = foreach ($ap in @($apInventory)) {
    $apMacKey = ([string]$ap.APMac).ToUpperInvariant()
    $apClients = @()
    if (-not [string]::IsNullOrWhiteSpace($apMacKey) -and $clientsByAp.ContainsKey($apMacKey)) {
        $apClients = @($clientsByAp[$apMacKey].ToArray())
    }

    $ssidGroups = @(
        $apClients |
            Group-Object SSID |
            Where-Object { -not [string]::IsNullOrWhiteSpace($_.Name) } |
            Sort-Object Count -Descending
    )
    $statusGroups = @(
        $apClients |
            Group-Object Status |
            Where-Object { -not [string]::IsNullOrWhiteSpace($_.Name) } |
            Sort-Object Count -Descending
    )
    $topSsid = if ($ssidGroups.Count -gt 0) { [string]$ssidGroups[0].Name } else { '' }
    $topClientStatus = if ($statusGroups.Count -gt 0) { [string]$statusGroups[0].Name } else { '' }

    [PSCustomObject]@{
        APName = [string]$ap.APName
        APMac = [string]$ap.APMac
        Dot11Slots = [string]$ap.Dot11Slots
        ClientCount = @($apClients).Count
        DistinctSsidCount = @($ssidGroups).Count
        TopSSID = $topSsid
        TopClientStatus = $topClientStatus
        BestState = if (@($apClients).Count -gt 0) { 'active' } else { 'idle' }
        ClientHealthState = Get-ClientBestState -Status $topClientStatus
        ControllerPrimary = [string]$ap.PrimaryControllerAddress
        ControllerSecondary = [string]$ap.SecondaryControllerAddress
        ControllerTertiary = [string]$ap.TertiaryControllerAddress
        SiteTagName = [string]$ap.SiteTagName
        RfTagName = [string]$ap.RfTagName
        PolicyTagName = [string]$ap.PolicyTagName
        DomainName = [string]$ap.DomainName
        FilterName = [string]$ap.FilterName
        UsbSerialNumber = [string]$ap.UsbSerialNumber
        ApUptime = [string]$ap.ApUptime
        ControllerUptimeSeen = [string]$ap.ControllerUptimeSeen
        LastChange = [string]$ap.LastChange
        AdminStatus = [string]$ap.AdminStatus
        PowerStatus = [string]$ap.PowerStatus
        FailoverPriority = [string]$ap.FailoverPriority
        LastRebootReason = [string]$ap.LastRebootReason
        SubMode = [string]$ap.SubMode
        AntennaBandMode = [string]$ap.AntennaBandMode
        CpuCurrentPct = [string]$ap.CpuCurrentPct
        CpuAvgPct = [string]$ap.CpuAvgPct
        MemCurrentPct = [string]$ap.MemCurrentPct
        MemAvgPct = [string]$ap.MemAvgPct
        AssocClientCount = [string]$ap.AssocClientCount
        ActiveClientCount = [string]$ap.ActiveClientCount
    }
}

$apOperational = @($apOperational | Sort-Object -Property @(
    @{ Expression = 'ClientCount'; Descending = $true },
    @{ Expression = 'APName'; Descending = $false }
))

$dashboardData = [PSCustomObject]@{
    GeneratedAt = (Get-Date).ToString('o')
    Kpis = [PSCustomObject]@{
        APCount = @($apInventory).Count
        ClientCount = @($clientInventory).Count
        WlanCount = @($wlanInventory).Count
        RootSummaryCount = @($summaryArtifacts | Where-Object { $_.Name -like 'root-summary-*' }).Count
    }
    TopSsids = $topSsids
    ApOperational = $apOperational
    ArtifactIndex = $summaryArtifacts
}

$dashboardDataPath = Join-Path $OutputDirectory 'wireless-dashboard-datasets.json'
($dashboardData | ConvertTo-Json -Depth 20) | Out-File -LiteralPath $dashboardDataPath -Encoding UTF8

$summaryArtifacts += [PSCustomObject]@{
    Name = 'dashboard-datasets'
    Root = 'all'
    Count = @($topSsids).Count
    Csv = ''
    Json = $dashboardDataPath
}

$manifest = [PSCustomObject]@{
    InputDirectory = $InputDirectory
    OutputDirectory = $OutputDirectory
    MibDirectory = $MibDirectory
    GeneratedAt = (Get-Date).ToString('o')
    MibFilesUsed = [PSCustomObject]@{
        AP = $apMib
        Dot11Client = $clientMib
        Wlan = $wlanMib
    }
    Artifacts = $summaryArtifacts
}

($manifest | ConvertTo-Json -Depth 20) | Out-File -LiteralPath $ManifestPath -Encoding UTF8

Write-Host ''
Write-Host 'Wireless summary build complete.' -ForegroundColor Green
Write-Host "Manifest: $ManifestPath" -ForegroundColor Green
Write-Host 'Artifacts:' -ForegroundColor Cyan
$summaryArtifacts | Select-Object Name, Root, Count, Csv, Json | Format-Table -AutoSize
# SIG # Begin signature block
# MIIr+wYJKoZIhvcNAQcCoIIr7DCCK+gCAQExDzANBglghkgBZQMEAgEFADB5Bgor
# BgEEAYI3AgEEoGswaTA0BgorBgEEAYI3AgEeMCYCAwEAAAQQH8w7YFlLCE63JNLG
# KX7zUQIBAAIBAAIBAAIBAAIBADAxMA0GCWCGSAFlAwQCAQUABCCu/kUHPeF7s3pj
# JzORGQb4d9XYpUpIu+62oVcjjm+SmqCCJQ0wggVvMIIEV6ADAgECAhBI/JO0YFWU
# jTanyYqJ1pQWMA0GCSqGSIb3DQEBDAUAMHsxCzAJBgNVBAYTAkdCMRswGQYDVQQI
# DBJHcmVhdGVyIE1hbmNoZXN0ZXIxEDAOBgNVBAcMB1NhbGZvcmQxGjAYBgNVBAoM
# EUNvbW9kbyBDQSBMaW1pdGVkMSEwHwYDVQQDDBhBQUEgQ2VydGlmaWNhdGUgU2Vy
# dmljZXMwHhcNMjEwNTI1MDAwMDAwWhcNMjgxMjMxMjM1OTU5WjBWMQswCQYDVQQG
# EwJHQjEYMBYGA1UEChMPU2VjdGlnbyBMaW1pdGVkMS0wKwYDVQQDEyRTZWN0aWdv
# IFB1YmxpYyBDb2RlIFNpZ25pbmcgUm9vdCBSNDYwggIiMA0GCSqGSIb3DQEBAQUA
# A4ICDwAwggIKAoICAQCN55QSIgQkdC7/FiMCkoq2rjaFrEfUI5ErPtx94jGgUW+s
# hJHjUoq14pbe0IdjJImK/+8Skzt9u7aKvb0Ffyeba2XTpQxpsbxJOZrxbW6q5KCD
# J9qaDStQ6Utbs7hkNqR+Sj2pcaths3OzPAsM79szV+W+NDfjlxtd/R8SPYIDdub7
# P2bSlDFp+m2zNKzBenjcklDyZMeqLQSrw2rq4C+np9xu1+j/2iGrQL+57g2extme
# me/G3h+pDHazJyCh1rr9gOcB0u/rgimVcI3/uxXP/tEPNqIuTzKQdEZrRzUTdwUz
# T2MuuC3hv2WnBGsY2HH6zAjybYmZELGt2z4s5KoYsMYHAXVn3m3pY2MeNn9pib6q
# RT5uWl+PoVvLnTCGMOgDs0DGDQ84zWeoU4j6uDBl+m/H5x2xg3RpPqzEaDux5mcz
# mrYI4IAFSEDu9oJkRqj1c7AGlfJsZZ+/VVscnFcax3hGfHCqlBuCF6yH6bbJDoEc
# QNYWFyn8XJwYK+pF9e+91WdPKF4F7pBMeufG9ND8+s0+MkYTIDaKBOq3qgdGnA2T
# OglmmVhcKaO5DKYwODzQRjY1fJy67sPV+Qp2+n4FG0DKkjXp1XrRtX8ArqmQqsV/
# AZwQsRb8zG4Y3G9i/qZQp7h7uJ0VP/4gDHXIIloTlRmQAOka1cKG8eOO7F/05QID
# AQABo4IBEjCCAQ4wHwYDVR0jBBgwFoAUoBEKIz6W8Qfs4q8p74Klf9AwpLQwHQYD
# VR0OBBYEFDLrkpr/NZZILyhAQnAgNpFcF4XmMA4GA1UdDwEB/wQEAwIBhjAPBgNV
# HRMBAf8EBTADAQH/MBMGA1UdJQQMMAoGCCsGAQUFBwMDMBsGA1UdIAQUMBIwBgYE
# VR0gADAIBgZngQwBBAEwQwYDVR0fBDwwOjA4oDagNIYyaHR0cDovL2NybC5jb21v
# ZG9jYS5jb20vQUFBQ2VydGlmaWNhdGVTZXJ2aWNlcy5jcmwwNAYIKwYBBQUHAQEE
# KDAmMCQGCCsGAQUFBzABhhhodHRwOi8vb2NzcC5jb21vZG9jYS5jb20wDQYJKoZI
# hvcNAQEMBQADggEBABK/oe+LdJqYRLhpRrWrJAoMpIpnuDqBv0WKfVIHqI0fTiGF
# OaNrXi0ghr8QuK55O1PNtPvYRL4G2VxjZ9RAFodEhnIq1jIV9RKDwvnhXRFAZ/ZC
# J3LFI+ICOBpMIOLbAffNRk8monxmwFE2tokCVMf8WPtsAO7+mKYulaEMUykfb9gZ
# pk+e96wJ6l2CxouvgKe9gUhShDHaMuwV5KZMPWw5c9QLhTkg4IUaaOGnSDip0TYl
# d8GNGRbFiExmfS9jzpjoad+sPKhdnckcW67Y8y90z7h+9teDnRGWYpquRRPaf9xH
# +9/DUp/mBlXpnYzyOmJRvOwkDynUWICE5EV7WtgwggWNMIIEdaADAgECAhAOmxiO
# +dAt5+/bUOIIQBhaMA0GCSqGSIb3DQEBDAUAMGUxCzAJBgNVBAYTAlVTMRUwEwYD
# VQQKEwxEaWdpQ2VydCBJbmMxGTAXBgNVBAsTEHd3dy5kaWdpY2VydC5jb20xJDAi
# BgNVBAMTG0RpZ2lDZXJ0IEFzc3VyZWQgSUQgUm9vdCBDQTAeFw0yMjA4MDEwMDAw
# MDBaFw0zMTExMDkyMzU5NTlaMGIxCzAJBgNVBAYTAlVTMRUwEwYDVQQKEwxEaWdp
# Q2VydCBJbmMxGTAXBgNVBAsTEHd3dy5kaWdpY2VydC5jb20xITAfBgNVBAMTGERp
# Z2lDZXJ0IFRydXN0ZWQgUm9vdCBHNDCCAiIwDQYJKoZIhvcNAQEBBQADggIPADCC
# AgoCggIBAL/mkHNo3rvkXUo8MCIwaTPswqclLskhPfKK2FnC4SmnPVirdprNrnsb
# hA3EMB/zG6Q4FutWxpdtHauyefLKEdLkX9YFPFIPUh/GnhWlfr6fqVcWWVVyr2iT
# cMKyunWZanMylNEQRBAu34LzB4TmdDttceItDBvuINXJIB1jKS3O7F5OyJP4IWGb
# NOsFxl7sWxq868nPzaw0QF+xembud8hIqGZXV59UWI4MK7dPpzDZVu7Ke13jrclP
# XuU15zHL2pNe3I6PgNq2kZhAkHnDeMe2scS1ahg4AxCN2NQ3pC4FfYj1gj4QkXCr
# VYJBMtfbBHMqbpEBfCFM1LyuGwN1XXhm2ToxRJozQL8I11pJpMLmqaBn3aQnvKFP
# ObURWBf3JFxGj2T3wWmIdph2PVldQnaHiZdpekjw4KISG2aadMreSx7nDmOu5tTv
# kpI6nj3cAORFJYm2mkQZK37AlLTSYW3rM9nF30sEAMx9HJXDj/chsrIRt7t/8tWM
# cCxBYKqxYxhElRp2Yn72gLD76GSmM9GJB+G9t+ZDpBi4pncB4Q+UDCEdslQpJYls
# 5Q5SUUd0viastkF13nqsX40/ybzTQRESW+UQUOsxxcpyFiIJ33xMdT9j7CFfxCBR
# a2+xq4aLT8LWRV+dIPyhHsXAj6KxfgommfXkaS+YHS312amyHeUbAgMBAAGjggE6
# MIIBNjAPBgNVHRMBAf8EBTADAQH/MB0GA1UdDgQWBBTs1+OC0nFdZEzfLmc/57qY
# rhwPTzAfBgNVHSMEGDAWgBRF66Kv9JLLgjEtUYunpyGd823IDzAOBgNVHQ8BAf8E
# BAMCAYYweQYIKwYBBQUHAQEEbTBrMCQGCCsGAQUFBzABhhhodHRwOi8vb2NzcC5k
# aWdpY2VydC5jb20wQwYIKwYBBQUHMAKGN2h0dHA6Ly9jYWNlcnRzLmRpZ2ljZXJ0
# LmNvbS9EaWdpQ2VydEFzc3VyZWRJRFJvb3RDQS5jcnQwRQYDVR0fBD4wPDA6oDig
# NoY0aHR0cDovL2NybDMuZGlnaWNlcnQuY29tL0RpZ2lDZXJ0QXNzdXJlZElEUm9v
# dENBLmNybDARBgNVHSAECjAIMAYGBFUdIAAwDQYJKoZIhvcNAQEMBQADggEBAHCg
# v0NcVec4X6CjdBs9thbX979XB72arKGHLOyFXqkauyL4hxppVCLtpIh3bb0aFPQT
# SnovLbc47/T/gLn4offyct4kvFIDyE7QKt76LVbP+fT3rDB6mouyXtTP0UNEm0Mh
# 65ZyoUi0mcudT6cGAxN3J0TU53/oWajwvy8LpunyNDzs9wPHh6jSTEAZNUZqaVSw
# uKFWjuyk1T3osdz9HNj0d1pcVIxv76FQPfx2CWiEn2/K2yCNNWAcAgPLILCsWKAO
# QGPFmCLBsln1VWvPJ6tsds5vIy30fnFqI2si/xK4VC0nftg62fC2h5b9W9FcrBjD
# TZ9ztwGpn1eqXijiuZQwggYaMIIEAqADAgECAhBiHW0MUgGeO5B5FSCJIRwKMA0G
# CSqGSIb3DQEBDAUAMFYxCzAJBgNVBAYTAkdCMRgwFgYDVQQKEw9TZWN0aWdvIExp
# bWl0ZWQxLTArBgNVBAMTJFNlY3RpZ28gUHVibGljIENvZGUgU2lnbmluZyBSb290
# IFI0NjAeFw0yMTAzMjIwMDAwMDBaFw0zNjAzMjEyMzU5NTlaMFQxCzAJBgNVBAYT
# AkdCMRgwFgYDVQQKEw9TZWN0aWdvIExpbWl0ZWQxKzApBgNVBAMTIlNlY3RpZ28g
# UHVibGljIENvZGUgU2lnbmluZyBDQSBSMzYwggGiMA0GCSqGSIb3DQEBAQUAA4IB
# jwAwggGKAoIBgQCbK51T+jU/jmAGQ2rAz/V/9shTUxjIztNsfvxYB5UXeWUzCxEe
# AEZGbEN4QMgCsJLZUKhWThj/yPqy0iSZhXkZ6Pg2A2NVDgFigOMYzB2OKhdqfWGV
# oYW3haT29PSTahYkwmMv0b/83nbeECbiMXhSOtbam+/36F09fy1tsB8je/RV0mIk
# 8XL/tfCK6cPuYHE215wzrK0h1SWHTxPbPuYkRdkP05ZwmRmTnAO5/arnY83jeNzh
# P06ShdnRqtZlV59+8yv+KIhE5ILMqgOZYAENHNX9SJDm+qxp4VqpB3MV/h53yl41
# aHU5pledi9lCBbH9JeIkNFICiVHNkRmq4TpxtwfvjsUedyz8rNyfQJy/aOs5b4s+
# ac7IH60B+Ja7TVM+EKv1WuTGwcLmoU3FpOFMbmPj8pz44MPZ1f9+YEQIQty/NQd/
# 2yGgW+ufflcZ/ZE9o1M7a5Jnqf2i2/uMSWymR8r2oQBMdlyh2n5HirY4jKnFH/9g
# Rvd+QOfdRrJZb1sCAwEAAaOCAWQwggFgMB8GA1UdIwQYMBaAFDLrkpr/NZZILyhA
# QnAgNpFcF4XmMB0GA1UdDgQWBBQPKssghyi47G9IritUpimqF6TNDDAOBgNVHQ8B
# Af8EBAMCAYYwEgYDVR0TAQH/BAgwBgEB/wIBADATBgNVHSUEDDAKBggrBgEFBQcD
# AzAbBgNVHSAEFDASMAYGBFUdIAAwCAYGZ4EMAQQBMEsGA1UdHwREMEIwQKA+oDyG
# Omh0dHA6Ly9jcmwuc2VjdGlnby5jb20vU2VjdGlnb1B1YmxpY0NvZGVTaWduaW5n
# Um9vdFI0Ni5jcmwwewYIKwYBBQUHAQEEbzBtMEYGCCsGAQUFBzAChjpodHRwOi8v
# Y3J0LnNlY3RpZ28uY29tL1NlY3RpZ29QdWJsaWNDb2RlU2lnbmluZ1Jvb3RSNDYu
# cDdjMCMGCCsGAQUFBzABhhdodHRwOi8vb2NzcC5zZWN0aWdvLmNvbTANBgkqhkiG
# 9w0BAQwFAAOCAgEABv+C4XdjNm57oRUgmxP/BP6YdURhw1aVcdGRP4Wh60BAscjW
# 4HL9hcpkOTz5jUug2oeunbYAowbFC2AKK+cMcXIBD0ZdOaWTsyNyBBsMLHqafvIh
# rCymlaS98+QpoBCyKppP0OcxYEdU0hpsaqBBIZOtBajjcw5+w/KeFvPYfLF/ldYp
# mlG+vd0xqlqd099iChnyIMvY5HexjO2AmtsbpVn0OhNcWbWDRF/3sBp6fWXhz7Dc
# ML4iTAWS+MVXeNLj1lJziVKEoroGs9Mlizg0bUMbOalOhOfCipnx8CaLZeVme5yE
# Lg09Jlo8BMe80jO37PU8ejfkP9/uPak7VLwELKxAMcJszkyeiaerlphwoKx1uHRz
# NyE6bxuSKcutisqmKL5OTunAvtONEoteSiabkPVSZ2z76mKnzAfZxCl/3dq3dUNw
# 4rg3sTCggkHSRqTqlLMS7gjrhTqBmzu1L90Y1KWN/Y5JKdGvspbOrTfOXyXvmPL6
# E52z1NZJ6ctuMFBQZH3pwWvqURR8AgQdULUvrxjUYbHHj95Ejza63zdrEcxWLDX6
# xWls/GDnVNueKjWUH3fTv1Y8Wdho698YADR7TNx8X8z2Bev6SivBBOHY+uqiirZt
# g0y9ShQoPzmCcn63Syatatvx157YK9hlcPmVoa1oDE5/L9Uo2bC5a4CH2RwwggY+
# MIIEpqADAgECAhAHnODk0RR/hc05c892LTfrMA0GCSqGSIb3DQEBDAUAMFQxCzAJ
# BgNVBAYTAkdCMRgwFgYDVQQKEw9TZWN0aWdvIExpbWl0ZWQxKzApBgNVBAMTIlNl
# Y3RpZ28gUHVibGljIENvZGUgU2lnbmluZyBDQSBSMzYwHhcNMjYwMjA5MDAwMDAw
# WhcNMjkwNDIxMjM1OTU5WjBVMQswCQYDVQQGEwJVUzEUMBIGA1UECAwLQ29ubmVj
# dGljdXQxFzAVBgNVBAoMDkphc29uIEFsYmVyaW5vMRcwFQYDVQQDDA5KYXNvbiBB
# bGJlcmlubzCCAiIwDQYJKoZIhvcNAQEBBQADggIPADCCAgoCggIBAPN6aN4B1yYW
# kI5b5TBj3I0VV/peETrHb6EY4BHGxt8Ap+eT+WpEpJyEtRYPxEmNJL3A38Bkg7mw
# zPE3/1NK570ZBCuBjSAn4mSDIgIuXZnvyBO9W1OQs5d67MlJLUAEufl18tOr3ST1
# DeO9gSjQSAE5Nql0QDxPnm93OZBon+Fz3CmE+z3MwAe2h4KdtRAnCqwM+/V7iBdb
# w+JOxolpx+7RVjGyProTENIG3pe/hKvPb501lf8uBAADLdjZr5ip8vIWbf857Yw1
# Bu10nVI7HW3eE8Cl5//d1ribHlzTzQLfttW+k+DaFsKZBBL56l4YAlIVRsrOiE1k
# dHYYx6IGrEA809R7+TZA9DzGqyFiv9qmJAbL4fDwetDeyIq+Oztz1LvEdy8Rcd0J
# BY+J4S0eDEFIA3X0N8VcLeAwabKb9AjulKXwUeqCJLvN79CJ90UTZb2+I+tamj0d
# n+IKMEsJ4v4Ggx72sxFr9+6XziodtTg5Luf2xd6+PhhamOxF2px9LObhBLLEMyRs
# CHZIzVZOFKu9BpHQH7ufGB+Sa80Tli0/6LEyn9+bMYWi2ttn6lLOPThXMiQaooRU
# q6q2u3+F4SaPlxVFLI7OJVMhar6nW6joBvELTJPmANSMjDSRFDfHRCdGbZsL/keE
# LJNy+jZctF6VvxQEjFM8/bazu6qYhrA7AgMBAAGjggGJMIIBhTAfBgNVHSMEGDAW
# gBQPKssghyi47G9IritUpimqF6TNDDAdBgNVHQ4EFgQU6YF0o0D5AVhKHbVocr8G
# aSIBibAwDgYDVR0PAQH/BAQDAgeAMAwGA1UdEwEB/wQCMAAwEwYDVR0lBAwwCgYI
# KwYBBQUHAwMwSgYDVR0gBEMwQTA1BgwrBgEEAbIxAQIBAwIwJTAjBggrBgEFBQcC
# ARYXaHR0cHM6Ly9zZWN0aWdvLmNvbS9DUFMwCAYGZ4EMAQQBMEkGA1UdHwRCMEAw
# PqA8oDqGOGh0dHA6Ly9jcmwuc2VjdGlnby5jb20vU2VjdGlnb1B1YmxpY0NvZGVT
# aWduaW5nQ0FSMzYuY3JsMHkGCCsGAQUFBwEBBG0wazBEBggrBgEFBQcwAoY4aHR0
# cDovL2NydC5zZWN0aWdvLmNvbS9TZWN0aWdvUHVibGljQ29kZVNpZ25pbmdDQVIz
# Ni5jcnQwIwYIKwYBBQUHMAGGF2h0dHA6Ly9vY3NwLnNlY3RpZ28uY29tMA0GCSqG
# SIb3DQEBDAUAA4IBgQAEIsm4xnOd/tZMVrKwi3doAXvCwOA/RYQnFJD7R/bSQRu3
# wXEK4o9SIefye18B/q4fhBkhNAJuEvTQAGfqbbpxow03J5PrDTp1WPCWbXKX8Oz9
# vGWJFyJxRGftkdzZ57JE00synEMS8XCwLO9P32MyR9Z9URrpiLPJ9rQjfHMb1BUd
# vaNayomm7aWLAnD+X7jm6o8sNT5An1cwEAob7obWDM6sX93wphwJNBJAstH9Ozs6
# LwISOX6sKS7CKm9N3Kp8hOUue0ZHAtZdFl6o5u12wy+zzieGEI50fKnN77FfNKFO
# WKlS6OJwlArcbFegB5K89LcE5iNSmaM3VMB2ADV1FEcjGSHw4lTg1Wx+WMAMdl/7
# nbvfFxJ9uu5tNiT54B0s+lZO/HztwXYQUczdsFon3pjsNrsk9ZlalBi5SHkIu+F6
# g7tWiEv3rtVApmJRnLkUr2Xq2a4nbslUCt4jKs5UX4V1nSX8OM++AXoyVGO+iTj7
# z+pl6XE9Gw/Td6WKKKswgga0MIIEnKADAgECAhANx6xXBf8hmS5AQyIMOkmGMA0G
# CSqGSIb3DQEBCwUAMGIxCzAJBgNVBAYTAlVTMRUwEwYDVQQKEwxEaWdpQ2VydCBJ
# bmMxGTAXBgNVBAsTEHd3dy5kaWdpY2VydC5jb20xITAfBgNVBAMTGERpZ2lDZXJ0
# IFRydXN0ZWQgUm9vdCBHNDAeFw0yNTA1MDcwMDAwMDBaFw0zODAxMTQyMzU5NTla
# MGkxCzAJBgNVBAYTAlVTMRcwFQYDVQQKEw5EaWdpQ2VydCwgSW5jLjFBMD8GA1UE
# AxM4RGlnaUNlcnQgVHJ1c3RlZCBHNCBUaW1lU3RhbXBpbmcgUlNBNDA5NiBTSEEy
# NTYgMjAyNSBDQTEwggIiMA0GCSqGSIb3DQEBAQUAA4ICDwAwggIKAoICAQC0eDHT
# CphBcr48RsAcrHXbo0ZodLRRF51NrY0NlLWZloMsVO1DahGPNRcybEKq+RuwOnPh
# of6pvF4uGjwjqNjfEvUi6wuim5bap+0lgloM2zX4kftn5B1IpYzTqpyFQ/4Bt0mA
# xAHeHYNnQxqXmRinvuNgxVBdJkf77S2uPoCj7GH8BLuxBG5AvftBdsOECS1UkxBv
# MgEdgkFiDNYiOTx4OtiFcMSkqTtF2hfQz3zQSku2Ws3IfDReb6e3mmdglTcaarps
# 0wjUjsZvkgFkriK9tUKJm/s80FiocSk1VYLZlDwFt+cVFBURJg6zMUjZa/zbCclF
# 83bRVFLeGkuAhHiGPMvSGmhgaTzVyhYn4p0+8y9oHRaQT/aofEnS5xLrfxnGpTXi
# UOeSLsJygoLPp66bkDX1ZlAeSpQl92QOMeRxykvq6gbylsXQskBBBnGy3tW/AMOM
# CZIVNSaz7BX8VtYGqLt9MmeOreGPRdtBx3yGOP+rx3rKWDEJlIqLXvJWnY0v5ydP
# pOjL6s36czwzsucuoKs7Yk/ehb//Wx+5kMqIMRvUBDx6z1ev+7psNOdgJMoiwOrU
# G2ZdSoQbU2rMkpLiQ6bGRinZbI4OLu9BMIFm1UUl9VnePs6BaaeEWvjJSjNm2qA+
# sdFUeEY0qVjPKOWug/G6X5uAiynM7Bu2ayBjUwIDAQABo4IBXTCCAVkwEgYDVR0T
# AQH/BAgwBgEB/wIBADAdBgNVHQ4EFgQU729TSunkBnx6yuKQVvYv1Ensy04wHwYD
# VR0jBBgwFoAU7NfjgtJxXWRM3y5nP+e6mK4cD08wDgYDVR0PAQH/BAQDAgGGMBMG
# A1UdJQQMMAoGCCsGAQUFBwMIMHcGCCsGAQUFBwEBBGswaTAkBggrBgEFBQcwAYYY
# aHR0cDovL29jc3AuZGlnaWNlcnQuY29tMEEGCCsGAQUFBzAChjVodHRwOi8vY2Fj
# ZXJ0cy5kaWdpY2VydC5jb20vRGlnaUNlcnRUcnVzdGVkUm9vdEc0LmNydDBDBgNV
# HR8EPDA6MDigNqA0hjJodHRwOi8vY3JsMy5kaWdpY2VydC5jb20vRGlnaUNlcnRU
# cnVzdGVkUm9vdEc0LmNybDAgBgNVHSAEGTAXMAgGBmeBDAEEAjALBglghkgBhv1s
# BwEwDQYJKoZIhvcNAQELBQADggIBABfO+xaAHP4HPRF2cTC9vgvItTSmf83Qh8WI
# GjB/T8ObXAZz8OjuhUxjaaFdleMM0lBryPTQM2qEJPe36zwbSI/mS83afsl3YTj+
# IQhQE7jU/kXjjytJgnn0hvrV6hqWGd3rLAUt6vJy9lMDPjTLxLgXf9r5nWMQwr8M
# yb9rEVKChHyfpzee5kH0F8HABBgr0UdqirZ7bowe9Vj2AIMD8liyrukZ2iA/wdG2
# th9y1IsA0QF8dTXqvcnTmpfeQh35k5zOCPmSNq1UH410ANVko43+Cdmu4y81hjaj
# V/gxdEkMx1NKU4uHQcKfZxAvBAKqMVuqte69M9J6A47OvgRaPs+2ykgcGV00TYr2
# Lr3ty9qIijanrUR3anzEwlvzZiiyfTPjLbnFRsjsYg39OlV8cipDoq7+qNNjqFze
# GxcytL5TTLL4ZaoBdqbhOhZ3ZRDUphPvSRmMThi0vw9vODRzW6AxnJll38F0cuJG
# 7uEBYTptMSbhdhGQDpOXgpIUsWTjd6xpR6oaQf/DJbg3s6KCLPAlZ66RzIg9sC+N
# Jpud/v4+7RWsWCiKi9EOLLHfMR2ZyJ/+xhCx9yHbxtl5TPau1j/1MIDpMPx0LckT
# etiSuEtQvLsNz3Qbp7wGWqbIiOWCnb5WqxL3/BAPvIXKUjPSxyZsq8WhbaM2tszW
# kPZPubdcMIIG7TCCBNWgAwIBAgIQCoDvGEuN8QWC0cR2p5V0aDANBgkqhkiG9w0B
# AQsFADBpMQswCQYDVQQGEwJVUzEXMBUGA1UEChMORGlnaUNlcnQsIEluYy4xQTA/
# BgNVBAMTOERpZ2lDZXJ0IFRydXN0ZWQgRzQgVGltZVN0YW1waW5nIFJTQTQwOTYg
# U0hBMjU2IDIwMjUgQ0ExMB4XDTI1MDYwNDAwMDAwMFoXDTM2MDkwMzIzNTk1OVow
# YzELMAkGA1UEBhMCVVMxFzAVBgNVBAoTDkRpZ2lDZXJ0LCBJbmMuMTswOQYDVQQD
# EzJEaWdpQ2VydCBTSEEyNTYgUlNBNDA5NiBUaW1lc3RhbXAgUmVzcG9uZGVyIDIw
# MjUgMTCCAiIwDQYJKoZIhvcNAQEBBQADggIPADCCAgoCggIBANBGrC0Sxp7Q6q5g
# VrMrV7pvUf+GcAoB38o3zBlCMGMyqJnfFNZx+wvA69HFTBdwbHwBSOeLpvPnZ8ZN
# +vo8dE2/pPvOx/Vj8TchTySA2R4QKpVD7dvNZh6wW2R6kSu9RJt/4QhguSssp3qo
# me7MrxVyfQO9sMx6ZAWjFDYOzDi8SOhPUWlLnh00Cll8pjrUcCV3K3E0zz09ldQ/
# /nBZZREr4h/GI6Dxb2UoyrN0ijtUDVHRXdmncOOMA3CoB/iUSROUINDT98oksouT
# MYFOnHoRh6+86Ltc5zjPKHW5KqCvpSduSwhwUmotuQhcg9tw2YD3w6ySSSu+3qU8
# DD+nigNJFmt6LAHvH3KSuNLoZLc1Hf2JNMVL4Q1OpbybpMe46YceNA0LfNsnqcnp
# JeItK/DhKbPxTTuGoX7wJNdoRORVbPR1VVnDuSeHVZlc4seAO+6d2sC26/PQPdP5
# 1ho1zBp+xUIZkpSFA8vWdoUoHLWnqWU3dCCyFG1roSrgHjSHlq8xymLnjCbSLZ49
# kPmk8iyyizNDIXj//cOgrY7rlRyTlaCCfw7aSUROwnu7zER6EaJ+AliL7ojTdS5P
# WPsWeupWs7NpChUk555K096V1hE0yZIXe+giAwW00aHzrDchIc2bQhpp0IoKRR7Y
# ufAkprxMiXAJQ1XCmnCfgPf8+3mnAgMBAAGjggGVMIIBkTAMBgNVHRMBAf8EAjAA
# MB0GA1UdDgQWBBTkO/zyMe39/dfzkXFjGVBDz2GM6DAfBgNVHSMEGDAWgBTvb1NK
# 6eQGfHrK4pBW9i/USezLTjAOBgNVHQ8BAf8EBAMCB4AwFgYDVR0lAQH/BAwwCgYI
# KwYBBQUHAwgwgZUGCCsGAQUFBwEBBIGIMIGFMCQGCCsGAQUFBzABhhhodHRwOi8v
# b2NzcC5kaWdpY2VydC5jb20wXQYIKwYBBQUHMAKGUWh0dHA6Ly9jYWNlcnRzLmRp
# Z2ljZXJ0LmNvbS9EaWdpQ2VydFRydXN0ZWRHNFRpbWVTdGFtcGluZ1JTQTQwOTZT
# SEEyNTYyMDI1Q0ExLmNydDBfBgNVHR8EWDBWMFSgUqBQhk5odHRwOi8vY3JsMy5k
# aWdpY2VydC5jb20vRGlnaUNlcnRUcnVzdGVkRzRUaW1lU3RhbXBpbmdSU0E0MDk2
# U0hBMjU2MjAyNUNBMS5jcmwwIAYDVR0gBBkwFzAIBgZngQwBBAIwCwYJYIZIAYb9
# bAcBMA0GCSqGSIb3DQEBCwUAA4ICAQBlKq3xHCcEua5gQezRCESeY0ByIfjk9iJP
# 2zWLpQq1b4URGnwWBdEZD9gBq9fNaNmFj6Eh8/YmRDfxT7C0k8FUFqNh+tshgb4O
# 6Lgjg8K8elC4+oWCqnU/ML9lFfim8/9yJmZSe2F8AQ/UdKFOtj7YMTmqPO9mzskg
# iC3QYIUP2S3HQvHG1FDu+WUqW4daIqToXFE/JQ/EABgfZXLWU0ziTN6R3ygQBHMU
# BaB5bdrPbF6MRYs03h4obEMnxYOX8VBRKe1uNnzQVTeLni2nHkX/QqvXnNb+YkDF
# kxUGtMTaiLR9wjxUxu2hECZpqyU1d0IbX6Wq8/gVutDojBIFeRlqAcuEVT0cKsb+
# zJNEsuEB7O7/cuvTQasnM9AWcIQfVjnzrvwiCZ85EE8LUkqRhoS3Y50OHgaY7T/l
# wd6UArb+BOVAkg2oOvol/DJgddJ35XTxfUlQ+8Hggt8l2Yv7roancJIFcbojBcxl
# RcGG0LIhp6GvReQGgMgYxQbV1S3CrWqZzBt1R9xJgKf47CdxVRd/ndUlQ05oxYy2
# zRWVFjF7mcr4C34Mj3ocCVccAvlKV9jEnstrniLvUxxVZE/rptb7IRE2lskKPIJg
# baP5t2nGj/ULLi49xTcBZU8atufk+EMF/cWuiC7POGT75qaL6vdCvHlshtjdNXOC
# IUjsarfNZzGCBkQwggZAAgEBMGgwVDELMAkGA1UEBhMCR0IxGDAWBgNVBAoTD1Nl
# Y3RpZ28gTGltaXRlZDErMCkGA1UEAxMiU2VjdGlnbyBQdWJsaWMgQ29kZSBTaWdu
# aW5nIENBIFIzNgIQB5zg5NEUf4XNOXPPdi036zANBglghkgBZQMEAgEFAKCBhDAY
# BgorBgEEAYI3AgEMMQowCKACgAChAoAAMBkGCSqGSIb3DQEJAzEMBgorBgEEAYI3
# AgEEMBwGCisGAQQBgjcCAQsxDjAMBgorBgEEAYI3AgEVMC8GCSqGSIb3DQEJBDEi
# BCBeEUAAFNpoFw73Gs/1ST7VnPFtPBfhzj31STCF7QPPxDANBgkqhkiG9w0BAQEF
# AASCAgC/x3ioaK51x1T9qJLevQ4NOQzY81U4+RJDxtbweTUAKWSyowJOZgqP76mg
# ZJuE0wXYyL0MkG8nYY2eyjg3LvVV5PMQDKHYjzqHBV4vZQifeW3dartCcFD6lIu0
# 1F4mu1jmoLn42/nHS4+9vTw1eD7j6aYz+z9PdnzuWp8qWMBdT4Rvc5oWCDHsPIoO
# T4vm+DGa8OYUK6GYZii49Vj7JomZsVctXk6jN5TSl1NKYZgvMh0/0jxWbHyAEj1C
# F9sYu/RigbXqhftPtU7F7MF0nGUEF5d1+i3FPml1I3jo/XbcjN/IasIF2VmaTiyN
# eAivuyWFEUYYrpgNOMw7hoHktAKhVdEAIKUeER/uATpbTIuW5PbnO/K1M6RVCZAx
# WI0uzQkqL6HfIKOo7cV2QhLZ5pqYVGnRkWg1eldrAl8Y3DItczefZbEJlzolHjka
# CV1LVBO8z6Z1UNtxm2+jaXLdfAceFRcUw7tnKYd2E24vpnKergEJDFgKYr8B6+5I
# gAQ1TorfHts7VV/2Ix/TKknoTyON37nk37w8u42CThcHpFOeaEl6B0xGc9NFIziV
# c0eg/d+Y7QkGiNkEp/l9CO7/U1dpO40UkbDhxKr9X++dp89dF30884gwRYZYWtLF
# L3W/r8cMQ0D8+wMChTANXPFqR5yexn7vQnTFDiX6xZ+9B2kRM6GCAyYwggMiBgkq
# hkiG9w0BCQYxggMTMIIDDwIBATB9MGkxCzAJBgNVBAYTAlVTMRcwFQYDVQQKEw5E
# aWdpQ2VydCwgSW5jLjFBMD8GA1UEAxM4RGlnaUNlcnQgVHJ1c3RlZCBHNCBUaW1l
# U3RhbXBpbmcgUlNBNDA5NiBTSEEyNTYgMjAyNSBDQTECEAqA7xhLjfEFgtHEdqeV
# dGgwDQYJYIZIAWUDBAIBBQCgaTAYBgkqhkiG9w0BCQMxCwYJKoZIhvcNAQcBMBwG
# CSqGSIb3DQEJBTEPFw0yNjA2MjkxMjE1MjlaMC8GCSqGSIb3DQEJBDEiBCClOJ02
# glvv3bveLoizEiLJajf02/cFMLIvC3bU8a5KXTANBgkqhkiG9w0BAQEFAASCAgC2
# 9jrU/KJhYKKfLXPPVqcMhpOLLBYrT586+EtqyG4J7xzfZUT2yUpDt1I3oax0FPRR
# nD5SGzbWCbVqazUNCRqxyZCUiWmUtyABUu6Y1ZDF07dIwyAH6yYmAxv7t1XiMddy
# I5GvzdVTGfom80ZVVPN5FVbkswSVJzIHZc5x9z84CLIj/Ro6RA6LTeC2Kr7itm1p
# 4kVNz+QRgmM4l7VPvsVUsfa20A9VDE1YBCNLcmvajfA8zciydJ3KbnF+5muZ92FE
# sorw+/yek3FleGaVaax4lxIVsunkC4puFF7n9+FgqAW1W6X215TVfeqgrIgZACXK
# uxco9yrpCv9YKDfZ1AZ4nPJl7wz97uJvHcQDRJ9Q2srQEy0nodnw4hynRvhVg9eL
# BWEJOC/n/mgaKidr32O+L77/a/ZVEr9inrA4LNBoO2ysgI8fA4PHpH4ZyWMbY2Ya
# KcXrVliGwHCHv6Hd0LzqYl1W41+IFhlKBPSXX+YiVcvc2e0rbIt4KavoGW/B6U6e
# s8wNA6uZWMsgDwnQT0P3KCSSz3ii51g+J7BIJwagcmN7x7DFHpiJA2olIWMtuQgB
# q1cOd6txA79YS7vXBB6nshlB/LKSRUnqD4fEB5bauMKuiNYe6YkxZmIBhgcXRkz4
# EHPpIYBCo/uGapvu3XOU+hwstGAD3iLnXjlpEJoRJw==
# SIG # End signature block