<#
.SYNOPSIS
    Scans a subnet for Yealink phones and extracts inventory data from their web UI.

.DESCRIPTION
    Performs a fast parallel ping sweep of a given CIDR subnet, filters for Yealink
    MAC address prefixes via the ARP table, authenticates to each phone's web UI
    using digest auth, and extracts MAC, serial number, model, and firmware version.
    Results are written to CSV and displayed in the console.

.PARAMETER Subnet
    Subnet to scan in CIDR notation (e.g., 192.168.1.0/24).

.PARAMETER Username
    Web UI username for the Yealink phones. Default: admin

.PARAMETER Password
    Web UI password for the Yealink phones.

.PARAMETER SiteName
    Client/site name included in each CSV row for multi-site inventory tracking.

.PARAMETER OutputFile
    Path for the output CSV file. Default: yealink_inventory.csv

.PARAMETER Timeout
    Timeout in milliseconds for network operations. Default: 1000

.EXAMPLE
    .\Scan-YealinkPhones.ps1 -Subnet "192.168.1.0/24" -Password "mypass" -SiteName "ClientHQ"

.EXAMPLE
    .\Scan-YealinkPhones.ps1 -Subnet "10.0.5.0/24" -Username "admin" -Password "p@ss" -SiteName "BranchOffice" -OutputFile "C:\inventory\phones.csv" -Timeout 2000

.NOTES
    Compatible with PowerShell 5.1 (Windows built-in). No external module dependencies.
    Uses runspaces for parallel ping sweep. Supports Yealink digest authentication.
#>

[CmdletBinding()]
param(
    [Parameter(Mandatory = $true, HelpMessage = "Subnet in CIDR notation, e.g. 192.168.1.0/24")]
    [ValidatePattern('^\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}/\d{1,2}$')]
    [string]$Subnet,

    [Parameter(Mandatory = $false)]
    [string]$Username = "admin",

    [Parameter(Mandatory = $true, HelpMessage = "Web UI password for the phones")]
    [string]$Password,

    [Parameter(Mandatory = $true, HelpMessage = "Client/site name for the CSV output")]
    [string]$SiteName,

    [Parameter(Mandatory = $false)]
    [string]$OutputFile = "yealink_inventory.csv",

    [Parameter(Mandatory = $false)]
    [ValidateRange(100, 30000)]
    [int]$Timeout = 1000
)

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

# ---------------------------------------------------------------------------
# Yealink OUI prefixes (colon-separated, uppercase for comparison)
# ---------------------------------------------------------------------------
$YealinkPrefixes = @("80:5E:C0", "80:5E:0C", "80:5A:35", "00:15:65", "28:6D:97", "24:4B:FE")

# ---------------------------------------------------------------------------
# CIDR Calculation
# ---------------------------------------------------------------------------
function Get-SubnetAddresses {
    <#
    .SYNOPSIS
        Returns all usable host IP addresses for a given CIDR subnet.
    #>
    param(
        [Parameter(Mandatory)]
        [string]$CidrSubnet
    )

    $parts = $CidrSubnet -split '/'
    $networkAddress = [System.Net.IPAddress]::Parse($parts[0])
    $prefixLength = [int]$parts[1]

    if ($prefixLength -lt 8 -or $prefixLength -gt 30) {
        throw "Prefix length must be between /8 and /30. Got /$prefixLength."
    }

    $networkBytes = $networkAddress.GetAddressBytes()
    # Convert to UInt32 (big-endian)
    $networkUInt = ([uint32]$networkBytes[0] -shl 24) -bor `
                   ([uint32]$networkBytes[1] -shl 16) -bor `
                   ([uint32]$networkBytes[2] -shl 8) -bor `
                   ([uint32]$networkBytes[3])

    $hostBits = 32 - $prefixLength
    $totalAddresses = [math]::Pow(2, $hostBits)
    $subnetMask = ([uint32]::MaxValue) -shl $hostBits -band [uint32]::MaxValue
    $networkStart = $networkUInt -band $subnetMask

    $addresses = [System.Collections.Generic.List[string]]::new()

    # Skip network address (first) and broadcast address (last)
    for ($i = 1; $i -lt ($totalAddresses - 1); $i++) {
        $ipUInt = $networkStart + $i
        $octet1 = ($ipUInt -shr 24) -band 0xFF
        $octet2 = ($ipUInt -shr 16) -band 0xFF
        $octet3 = ($ipUInt -shr 8) -band 0xFF
        $octet4 = $ipUInt -band 0xFF
        $addresses.Add("$octet1.$octet2.$octet3.$octet4")
    }

    return $addresses
}

# ---------------------------------------------------------------------------
# Parallel Ping Sweep using Runspaces
# ---------------------------------------------------------------------------
function Invoke-PingSweep {
    <#
    .SYNOPSIS
        Pings all IPs in parallel using runspaces. Returns list of responding IPs.
    #>
    param(
        [Parameter(Mandatory)]
        [System.Collections.Generic.List[string]]$IPAddresses,

        [int]$TimeoutMs = 1000,

        [int]$ThrottleLimit = 64
    )

    $runspacePool = [System.Management.Automation.Runspaces.RunspaceFactory]::CreateRunspacePool(1, $ThrottleLimit)
    $runspacePool.Open()

    $scriptBlock = {
        param([string]$IP, [int]$Timeout)
        $pinger = New-Object System.Net.NetworkInformation.Ping
        try {
            $result = $pinger.Send($IP, $Timeout)
            if ($result.Status -eq [System.Net.NetworkInformation.IPStatus]::Success) {
                return $IP
            }
        }
        catch {
            # Host unreachable or other error — skip silently
        }
        finally {
            $pinger.Dispose()
        }
        return $null
    }

    $jobs = [System.Collections.Generic.List[PSCustomObject]]::new()

    foreach ($ip in $IPAddresses) {
        $ps = [System.Management.Automation.PowerShell]::Create()
        $ps.RunspacePool = $runspacePool
        [void]$ps.AddScript($scriptBlock)
        [void]$ps.AddArgument($ip)
        [void]$ps.AddArgument($TimeoutMs)

        $handle = $ps.BeginInvoke()
        $jobs.Add([PSCustomObject]@{
            PowerShell = $ps
            Handle     = $handle
        })
    }

    $liveHosts = [System.Collections.Generic.List[string]]::new()
    $completed = 0
    $total = $jobs.Count

    foreach ($job in $jobs) {
        try {
            $result = $job.PowerShell.EndInvoke($job.Handle)
            if ($result -and $result[0]) {
                $liveHosts.Add($result[0])
            }
        }
        catch {
            # Silently skip failed pings
        }
        finally {
            $job.PowerShell.Dispose()
        }
        $completed++
        if ($completed % 50 -eq 0 -or $completed -eq $total) {
            $pct = [math]::Round(($completed / $total) * 100)
            Write-Host "`r  Ping progress: $completed/$total ($pct%)" -NoNewline
        }
    }
    Write-Host ""

    $runspacePool.Close()
    $runspacePool.Dispose()

    return $liveHosts
}

# ---------------------------------------------------------------------------
# ARP Table MAC Lookup
# ---------------------------------------------------------------------------
function Get-ArpMac {
    <#
    .SYNOPSIS
        Retrieves MAC address for an IP from the local ARP table.
        Returns MAC in colon-separated uppercase format, or $null if not found.
    #>
    param(
        [Parameter(Mandatory)]
        [string]$IPAddress
    )

    try {
        $arpOutput = & arp -a $IPAddress 2>$null
        if (-not $arpOutput) { return $null }

        foreach ($line in $arpOutput) {
            $line = $line.Trim()
            # Windows ARP format: "192.168.1.1     80-5e-c0-aa-bb-cc     dynamic"
            if ($line -match '^\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}\s+([\da-fA-F]{2}[-:][\da-fA-F]{2}[-:][\da-fA-F]{2}[-:][\da-fA-F]{2}[-:][\da-fA-F]{2}[-:][\da-fA-F]{2})') {
                $mac = $Matches[1].ToUpper() -replace '-', ':'
                return $mac
            }
        }
    }
    catch {
        # ARP lookup failed — not critical
    }

    return $null
}

# ---------------------------------------------------------------------------
# Yealink MAC Prefix Check
# ---------------------------------------------------------------------------
function Test-YealinkMac {
    <#
    .SYNOPSIS
        Returns $true if the MAC address belongs to a known Yealink OUI prefix.
    #>
    param(
        [Parameter(Mandatory)]
        [string]$Mac
    )

    $macUpper = $Mac.ToUpper() -replace '-', ':'
    $prefix = ($macUpper -split ':')[0..2] -join ':'

    return ($YealinkPrefixes -contains $prefix)
}

# ---------------------------------------------------------------------------
# Digest Authentication Helper
# ---------------------------------------------------------------------------
function Set-UnsafeHeaderParsing {
    <#
    .SYNOPSIS
        Enables useUnsafeHeaderParsing to tolerate non-standard HTTP headers
        from embedded devices like Yealink phones.
    #>
    $netAssembly = [System.Reflection.Assembly]::GetAssembly([System.Net.Configuration.SettingsSection])
    if ($netAssembly) {
        $bindingFlags = [System.Reflection.BindingFlags]::Static -bor
                        [System.Reflection.BindingFlags]::GetProperty -bor
                        [System.Reflection.BindingFlags]::NonPublic
        $settingsType = $netAssembly.GetType("System.Net.Configuration.SettingsSectionInternal")
        if ($settingsType) {
            $instance = $settingsType.InvokeMember("Section", $bindingFlags, $null, $null, @())
            if ($instance) {
                $useUnsafeField = $settingsType.GetField("useUnsafeHeaderParsing",
                    [System.Reflection.BindingFlags]::NonPublic -bor [System.Reflection.BindingFlags]::Instance)
                if ($useUnsafeField) {
                    $useUnsafeField.SetValue($instance, $true)
                }
            }
        }
    }
}

function Invoke-DigestAuthRequest {
    <#
    .SYNOPSIS
        Performs an HTTP GET with digest authentication.
        Compatible with PowerShell 5.1.
    #>
    param(
        [Parameter(Mandatory)]
        [string]$Uri,

        [Parameter(Mandatory)]
        [string]$User,

        [Parameter(Mandatory)]
        [string]$Pass,

        [int]$TimeoutMs = 5000
    )

    # Enable lenient header parsing for Yealink's non-standard HTTP responses
    Set-UnsafeHeaderParsing

    # Ignore SSL certificate errors (phones use self-signed certs)
    [System.Net.ServicePointManager]::ServerCertificateValidationCallback = { $true }
    # Ensure TLS 1.2 support
    [System.Net.ServicePointManager]::SecurityProtocol = [System.Net.SecurityProtocolType]::Tls12 -bor [System.Net.SecurityProtocolType]::Tls11 -bor [System.Net.SecurityProtocolType]::Tls -bor [System.Net.SecurityProtocolType]::Ssl3

    $credential = New-Object System.Net.NetworkCredential($User, $Pass)
    $credCache = New-Object System.Net.CredentialCache
    $credCache.Add([Uri]$Uri, "Digest", $credential)
    # Also add Basic in case some models use it
    $credCache.Add([Uri]$Uri, "Basic", $credential)

    $request = [System.Net.HttpWebRequest]::Create($Uri)
    $request.Credentials = $credCache
    $request.PreAuthenticate = $false
    $request.Timeout = $TimeoutMs
    $request.ReadWriteTimeout = $TimeoutMs
    $request.Method = "GET"
    $request.UserAgent = "Mozilla/5.0 (YealinkScanner)"

    try {
        $response = $request.GetResponse()
        $stream = $response.GetResponseStream()
        $reader = New-Object System.IO.StreamReader($stream)
        $body = $reader.ReadToEnd()
        $reader.Close()
        $stream.Close()
        $response.Close()
        return $body
    }
    catch [System.Net.WebException] {
        $ex = $_.Exception
        if ($ex.Response) {
            $statusCode = [int]$ex.Response.StatusCode
            if ($statusCode -eq 401) {
                throw "Authentication failed (HTTP 401)"
            }
            throw "HTTP error $statusCode"
        }
        throw "Connection failed: $($ex.Message)"
    }
}

# ---------------------------------------------------------------------------
# Yealink Data Extraction
# ---------------------------------------------------------------------------
function Get-YealinkPhoneData {
    <#
    .SYNOPSIS
        Queries a Yealink phone's web UI and extracts inventory data.
        Tries the structured data endpoint first, then falls back to the HTML status page.
    #>
    param(
        [Parameter(Mandatory)]
        [string]$IPAddress,

        [Parameter(Mandatory)]
        [string]$User,

        [Parameter(Mandatory)]
        [string]$Pass,

        [int]$TimeoutMs = 5000
    )

    $phoneData = [PSCustomObject]@{
        MAC             = ""
        Serial          = ""
        Model           = ""
        FirmwareVersion = ""
        IP              = $IPAddress
        Success         = $false
        Error           = ""
    }

    # Enable lenient header parsing and SSL bypass
    Set-UnsafeHeaderParsing
    [System.Net.ServicePointManager]::ServerCertificateValidationCallback = { $true }
    [System.Net.ServicePointManager]::SecurityProtocol = [System.Net.SecurityProtocolType]::Tls12 -bor [System.Net.SecurityProtocolType]::Tls11 -bor [System.Net.SecurityProtocolType]::Tls -bor [System.Net.SecurityProtocolType]::Ssl3

    $body = $null
    $protocols = @("https", "http")

    # Disable Expect: 100-continue globally (Yealink returns 417 otherwise)
    [System.Net.ServicePointManager]::Expect100Continue = $false

    foreach ($proto in $protocols) {
        $baseUrl = "${proto}://$IPAddress"

        # --- Method 1: Cookie-based login (T4x/T5x with newer firmware) ---
        try {
            $cookieContainer = New-Object System.Net.CookieContainer

            # Step 1: POST login
            $loginUrl = "$baseUrl/servlet?m=mod_listener&p=login&q=login&Ession=0"
            $loginRequest = [System.Net.HttpWebRequest]::Create($loginUrl)
            $loginRequest.Method = "POST"
            $loginRequest.ContentType = "application/x-www-form-urlencoded"
            $loginRequest.ServicePoint.Expect100Continue = $false
            $loginRequest.CookieContainer = $cookieContainer
            $loginRequest.Timeout = $TimeoutMs
            $loginRequest.ReadWriteTimeout = $TimeoutMs
            $loginRequest.UserAgent = "Mozilla/5.0 (YealinkScanner)"
            $loginRequest.AllowAutoRedirect = $true

            $loginBody = "username=$User&pwd=$Pass"
            $loginBytes = [System.Text.Encoding]::UTF8.GetBytes($loginBody)
            $loginRequest.ContentLength = $loginBytes.Length
            $reqStream = $loginRequest.GetRequestStream()
            $reqStream.Write($loginBytes, 0, $loginBytes.Length)
            $reqStream.Close()

            $loginResponse = $loginRequest.GetResponse()
            $loginReader = New-Object System.IO.StreamReader($loginResponse.GetResponseStream())
            $loginResult = $loginReader.ReadToEnd()
            $loginReader.Close()
            $loginResponse.Close()

            # Step 2: Fetch status page with session cookie
            $statusUrl = "$baseUrl/servlet?m=mod_data&p=status-status&q=load"
            $statusRequest = [System.Net.HttpWebRequest]::Create($statusUrl)
            $statusRequest.Method = "GET"
            $statusRequest.CookieContainer = $cookieContainer
            $statusRequest.Timeout = $TimeoutMs
            $statusRequest.ReadWriteTimeout = $TimeoutMs
            $statusRequest.UserAgent = "Mozilla/5.0 (YealinkScanner)"

            $statusResponse = $statusRequest.GetResponse()
            $statusReader = New-Object System.IO.StreamReader($statusResponse.GetResponseStream())
            $body = $statusReader.ReadToEnd()
            $statusReader.Close()
            $statusResponse.Close()

            # Check if we got actual data (not a login page)
            if ($body -and $body.Length -gt 10 -and $body -notmatch 'authstatus.*none' -and $body -notmatch 'CheckLogin') {
                break
            }

            # Try alternate status endpoint
            $statusUrl2 = "$baseUrl/servlet?p=status-status"
            $statusRequest2 = [System.Net.HttpWebRequest]::Create($statusUrl2)
            $statusRequest2.Method = "GET"
            $statusRequest2.CookieContainer = $cookieContainer
            $statusRequest2.Timeout = $TimeoutMs
            $statusRequest2.ReadWriteTimeout = $TimeoutMs
            $statusRequest2.UserAgent = "Mozilla/5.0 (YealinkScanner)"

            $statusResponse2 = $statusRequest2.GetResponse()
            $statusReader2 = New-Object System.IO.StreamReader($statusResponse2.GetResponseStream())
            $body = $statusReader2.ReadToEnd()
            $statusReader2.Close()
            $statusResponse2.Close()

            if ($body -and $body.Length -gt 10 -and $body -notmatch 'authstatus.*none' -and $body -notmatch 'CheckLogin') {
                break
            }
            $body = $null
        }
        catch {
            $phoneData.Error = $_.Exception.Message
            $body = $null
            continue
        }

        # --- Method 2: Digest auth fallback (older firmware) ---
        $endpoints = @(
            "$baseUrl/servlet?m=mod_data&p=status-status&q=load",
            "$baseUrl/servlet?p=status-status"
        )
        foreach ($endpoint in $endpoints) {
            try {
                $body = Invoke-DigestAuthRequest -Uri $endpoint -User $User -Pass $Pass -TimeoutMs $TimeoutMs
                if ($body -and $body.Length -gt 10 -and $body -notmatch 'authstatus.*none' -and $body -notmatch 'CheckLogin') {
                    break
                }
                $body = $null
            }
            catch {
                $phoneData.Error = $_.Exception.Message
                $body = $null
                continue
            }
        }
        if ($body) { break }
    }

    if (-not $body) {
        if (-not $phoneData.Error) {
            $phoneData.Error = "No valid response from any status endpoint"
        }
        return $phoneData
    }

    # DEBUG: dump raw response to temp file for inspection
    $debugFile = Join-Path $env:TEMP "yealink_debug_$($IPAddress -replace '\.','_').txt"
    $body | Out-File -FilePath $debugFile -Encoding UTF8 -Force
    Write-Host "    DEBUG: Raw response saved to $debugFile" -ForegroundColor DarkGray

    # --- Parse structured JSON response (mod_data endpoint) ---
    try {
        # Attempt JSON parse first (mod_data endpoint returns JSON on many models)
        $jsonData = $body | ConvertFrom-Json -ErrorAction Stop

        # Different models use different JSON field names.
        # Common patterns observed across T2x/T4x/T5x series:
        $macFields = @("MacAddress", "MAC", "mac", "Mac_Address", "MACAddress")
        $serialFields = @("SerialNumber", "Serial", "serial", "Machine_ID", "MachineID", "SN")
        $modelFields = @("ModelName", "Model", "model", "ProductName", "Product", "DeviceModel")
        $fwFields = @("FirmwareVersion", "Firmware", "firmware", "FWVersion", "fw_version", "SoftwareVersion")

        foreach ($field in $macFields) {
            $val = $jsonData.PSObject.Properties | Where-Object { $_.Name -eq $field } | Select-Object -First 1
            if ($val -and $val.Value) { $phoneData.MAC = ($val.Value -replace '-', ':').ToUpper(); break }
        }
        foreach ($field in $serialFields) {
            $val = $jsonData.PSObject.Properties | Where-Object { $_.Name -eq $field } | Select-Object -First 1
            if ($val -and $val.Value) { $phoneData.Serial = [string]$val.Value; break }
        }
        foreach ($field in $modelFields) {
            $val = $jsonData.PSObject.Properties | Where-Object { $_.Name -eq $field } | Select-Object -First 1
            if ($val -and $val.Value) { $phoneData.Model = [string]$val.Value; break }
        }
        foreach ($field in $fwFields) {
            $val = $jsonData.PSObject.Properties | Where-Object { $_.Name -eq $field } | Select-Object -First 1
            if ($val -and $val.Value) { $phoneData.FirmwareVersion = [string]$val.Value; break }
        }

        # Some models nest data under a "body" or "data" property
        $nestedContainers = @("body", "data", "Body", "Data", "status")
        foreach ($container in $nestedContainers) {
            $nested = $jsonData.PSObject.Properties | Where-Object { $_.Name -eq $container } | Select-Object -First 1
            if ($nested -and $nested.Value -and $nested.Value -is [PSCustomObject]) {
                $obj = $nested.Value
                if (-not $phoneData.MAC) {
                    foreach ($field in $macFields) {
                        $val = $obj.PSObject.Properties | Where-Object { $_.Name -eq $field } | Select-Object -First 1
                        if ($val -and $val.Value) { $phoneData.MAC = ($val.Value -replace '-', ':').ToUpper(); break }
                    }
                }
                if (-not $phoneData.Serial) {
                    foreach ($field in $serialFields) {
                        $val = $obj.PSObject.Properties | Where-Object { $_.Name -eq $field } | Select-Object -First 1
                        if ($val -and $val.Value) { $phoneData.Serial = [string]$val.Value; break }
                    }
                }
                if (-not $phoneData.Model) {
                    foreach ($field in $modelFields) {
                        $val = $obj.PSObject.Properties | Where-Object { $_.Name -eq $field } | Select-Object -First 1
                        if ($val -and $val.Value) { $phoneData.Model = [string]$val.Value; break }
                    }
                }
                if (-not $phoneData.FirmwareVersion) {
                    foreach ($field in $fwFields) {
                        $val = $obj.PSObject.Properties | Where-Object { $_.Name -eq $field } | Select-Object -First 1
                        if ($val -and $val.Value) { $phoneData.FirmwareVersion = [string]$val.Value; break }
                    }
                }
            }
        }
    }
    catch {
        # Not JSON — fall through to HTML/text parsing
    }

    # --- Fallback: Parse HTML/text response ---
    if (-not $phoneData.MAC -or -not $phoneData.Serial -or -not $phoneData.Model -or -not $phoneData.FirmwareVersion) {
        # MAC Address patterns in HTML
        if (-not $phoneData.MAC) {
            if ($body -match '(?i)(?:mac\s*(?:address)?|MAC)\s*[:=]\s*([0-9A-Fa-f]{2}[:-][0-9A-Fa-f]{2}[:-][0-9A-Fa-f]{2}[:-][0-9A-Fa-f]{2}[:-][0-9A-Fa-f]{2}[:-][0-9A-Fa-f]{2})') {
                $phoneData.MAC = ($Matches[1] -replace '-', ':').ToUpper()
            }
        }

        # Serial / Machine ID
        if (-not $phoneData.Serial) {
            if ($body -match '(?i)(?:serial\s*(?:number)?|machine\s*id|SN)\s*[:=]\s*([A-Za-z0-9]+)') {
                $phoneData.Serial = $Matches[1]
            }
        }

        # Model
        if (-not $phoneData.Model) {
            # Look for Yealink model patterns like T46S, T54W, SIP-T48G, VP59, CP920, etc.
            if ($body -match '(?i)(?:model|product\s*name|device\s*model)\s*[:=]\s*((?:SIP-)?[A-Za-z]{1,4}[\-]?[0-9]{2,4}[A-Za-z]?)') {
                $phoneData.Model = $Matches[1]
            }
            elseif ($body -match '(?i)(Yealink\s+(?:SIP-)?[A-Za-z]{1,4}[\-]?[0-9]{2,4}[A-Za-z]?)') {
                $phoneData.Model = $Matches[1]
            }
        }

        # Firmware Version
        if (-not $phoneData.FirmwareVersion) {
            if ($body -match '(?i)(?:firmware|software)\s*(?:version)?\s*[:=]\s*([0-9]+\.[0-9]+\.[0-9]+[.\-][0-9A-Za-z.]+)') {
                $phoneData.FirmwareVersion = $Matches[1]
            }
            elseif ($body -match '(?i)(?:firmware|software)\s*(?:version)?\s*[:=]\s*(\S+)') {
                $phoneData.FirmwareVersion = $Matches[1]
            }
        }

        # Try parsing HTML table rows (common Yealink status page format):
        # <tr><td>Label</td><td>Value</td></tr>
        $tablePattern = '<tr[^>]*>\s*<td[^>]*>\s*(?<label>[^<]+)\s*</td>\s*<td[^>]*>\s*(?<value>[^<]+)\s*</td>\s*</tr>'
        $tableMatches = [regex]::Matches($body, $tablePattern, [System.Text.RegularExpressions.RegexOptions]::IgnoreCase)

        foreach ($m in $tableMatches) {
            $label = $m.Groups['label'].Value.Trim()
            $value = $m.Groups['value'].Value.Trim()

            if (-not $phoneData.MAC -and $label -match '(?i)mac') {
                $phoneData.MAC = ($value -replace '-', ':').ToUpper()
            }
            if (-not $phoneData.Serial -and $label -match '(?i)(serial|machine)') {
                $phoneData.Serial = $value
            }
            if (-not $phoneData.Model -and $label -match '(?i)(model|product)') {
                $phoneData.Model = $value
            }
            if (-not $phoneData.FirmwareVersion -and $label -match '(?i)(firmware|software)') {
                $phoneData.FirmwareVersion = $value
            }
        }
    }

    # If we got at least one meaningful field, consider it a success
    if ($phoneData.MAC -or $phoneData.Serial -or $phoneData.Model -or $phoneData.FirmwareVersion) {
        $phoneData.Success = $true
        $phoneData.Error = ""
    }
    elseif (-not $phoneData.Error) {
        $phoneData.Error = "Could not parse any fields from status page response"
    }

    return $phoneData
}

# ---------------------------------------------------------------------------
# CSV Output
# ---------------------------------------------------------------------------
function Export-PhoneInventory {
    <#
    .SYNOPSIS
        Appends phone inventory data to a CSV file. Creates the file with headers if it does not exist.
    #>
    param(
        [Parameter(Mandatory)]
        [array]$PhoneRecords,

        [Parameter(Mandatory)]
        [string]$FilePath,

        [Parameter(Mandatory)]
        [string]$Site
    )

    $csvRows = [System.Collections.Generic.List[PSCustomObject]]::new()

    foreach ($phone in $PhoneRecords) {
        $csvRows.Add([PSCustomObject]@{
            MAC             = $phone.MAC
            Serial          = $phone.Serial
            Model           = $phone.Model
            FirmwareVersion = $phone.FirmwareVersion
            IP              = $phone.IP
            SiteName        = $Site
        })
    }

    $fileExists = Test-Path -Path $FilePath -PathType Leaf
    if ($fileExists) {
        # Append without header
        $csvRows | Export-Csv -Path $FilePath -NoTypeInformation -Append -Force
    }
    else {
        $csvRows | Export-Csv -Path $FilePath -NoTypeInformation -Force
    }
}

# ===========================================================================
# MAIN EXECUTION
# ===========================================================================

$scriptStartTime = Get-Date

Write-Host ""
Write-Host "==========================================" -ForegroundColor Cyan
Write-Host "  Yealink Phone Scanner" -ForegroundColor Cyan
Write-Host "==========================================" -ForegroundColor Cyan
Write-Host "  Subnet:    $Subnet"
Write-Host "  Site:      $SiteName"
Write-Host "  Output:    $OutputFile"
Write-Host "  Timeout:   ${Timeout}ms"
Write-Host "==========================================" -ForegroundColor Cyan
Write-Host ""

# --- Step 1: Calculate subnet addresses ---
Write-Host "[INFO] Calculating IP addresses for $Subnet..." -ForegroundColor Yellow
try {
    $ipList = Get-SubnetAddresses -CidrSubnet $Subnet
}
catch {
    Write-Host "[ERROR] Invalid subnet: $_" -ForegroundColor Red
    exit 1
}
Write-Host "[OK] $($ipList.Count) host addresses in range." -ForegroundColor Green
Write-Host ""

# --- Step 2: Ping sweep to populate ARP table ---
Write-Host "[INFO] Pinging $($ipList.Count) addresses to populate ARP table..." -ForegroundColor Yellow

# Use ping in batches — send async pings using .NET Ping.SendPingAsync for speed
$pingTasks = [System.Collections.Generic.List[object]]::new()
$batchSize = 100
for ($i = 0; $i -lt $ipList.Count; $i += $batchSize) {
    $batch = $ipList[$i..[math]::Min($i + $batchSize - 1, $ipList.Count - 1)]
    foreach ($ip in $batch) {
        $pinger = New-Object System.Net.NetworkInformation.Ping
        try {
            $task = $pinger.SendPingAsync($ip, $Timeout)
            $pingTasks.Add(@{ Task = $task; Pinger = $pinger })
        }
        catch {
            $pinger.Dispose()
        }
    }
    # Wait for this batch to finish
    foreach ($pt in $pingTasks) {
        try { [void]$pt.Task.Wait(($Timeout + 500)) } catch {}
        $pt.Pinger.Dispose()
    }
    $pingTasks.Clear()
    $done = [math]::Min($i + $batchSize, $ipList.Count)
    $pct = [math]::Round(($done / $ipList.Count) * 100)
    Write-Host "`r  Ping progress: $done/$($ipList.Count) ($pct%)" -NoNewline
}
Write-Host ""
Write-Host "[OK] Ping sweep complete." -ForegroundColor Green
Write-Host ""

# --- Step 3: Parse ARP table for Yealink MACs within our subnet ---
Write-Host "[INFO] Scanning ARP table for Yealink MAC prefixes..." -ForegroundColor Yellow

$yealinkDevices = [System.Collections.Generic.List[PSCustomObject]]::new()

# Build a HashSet of IPs in our target subnet for fast lookup
$subnetIPs = [System.Collections.Generic.HashSet[string]]::new()
foreach ($ip in $ipList) { [void]$subnetIPs.Add($ip) }

# Parse the full ARP table
$arpLines = & arp -a 2>$null
foreach ($line in $arpLines) {
    $line = $line.Trim()
    if ($line -match '^(\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3})\s+([\da-fA-F]{2}[-:][\da-fA-F]{2}[-:][\da-fA-F]{2}[-:][\da-fA-F]{2}[-:][\da-fA-F]{2}[-:][\da-fA-F]{2})') {
        $ip = $Matches[1]
        $mac = $Matches[2].ToUpper() -replace '-', ':'
        if ($subnetIPs.Contains($ip) -and (Test-YealinkMac -Mac $mac)) {
            $yealinkDevices.Add([PSCustomObject]@{
                IP  = $ip
                MAC = $mac
            })
            Write-Host "  Found: $ip -> $mac" -ForegroundColor Cyan
        }
    }
}

# Also try Get-NetNeighbor as a fallback (more reliable on Windows 10/11)
try {
    $neighbors = Get-NetNeighbor -State Reachable,Stale,Delay,Probe -ErrorAction SilentlyContinue
    foreach ($n in $neighbors) {
        $ip = $n.IPAddress
        $mac = ($n.LinkLayerAddress -replace '-', ':').ToUpper()
        if ($subnetIPs.Contains($ip) -and (Test-YealinkMac -Mac $mac)) {
            # Avoid duplicates
            $already = $yealinkDevices | Where-Object { $_.IP -eq $ip }
            if (-not $already) {
                $yealinkDevices.Add([PSCustomObject]@{
                    IP  = $ip
                    MAC = $mac
                })
                Write-Host "  Found: $ip -> $mac (via NetNeighbor)" -ForegroundColor Cyan
            }
        }
    }
}
catch {
    Write-Host "  [INFO] Get-NetNeighbor not available, using ARP table only." -ForegroundColor DarkGray
}

Write-Host "[OK] Found $($yealinkDevices.Count) Yealink devices." -ForegroundColor Green
Write-Host ""

if ($yealinkDevices.Count -eq 0) {
    Write-Host "[WARNING] No Yealink devices detected on this subnet. Exiting." -ForegroundColor DarkYellow
    exit 0
}

# Display detected devices
Write-Host "  Detected Yealink devices:" -ForegroundColor Cyan
foreach ($dev in $yealinkDevices) {
    Write-Host "    $($dev.IP)  ($($dev.MAC))"
}
Write-Host ""

# --- Step 4: Query each Yealink phone's web UI ---
Write-Host "[INFO] Querying Yealink phone web UIs for inventory data..." -ForegroundColor Yellow
Write-Host ""

$successfulScrapes = [System.Collections.Generic.List[PSCustomObject]]::new()
$failedScrapes = [System.Collections.Generic.List[PSCustomObject]]::new()

$deviceIndex = 0
foreach ($device in $yealinkDevices) {
    $deviceIndex++
    Write-Host "  [$deviceIndex/$($yealinkDevices.Count)] Querying $($device.IP) ($($device.MAC))..." -NoNewline

    $phoneData = Get-YealinkPhoneData -IPAddress $device.IP -User $Username -Pass $Password -TimeoutMs ($Timeout * 5)

    # If we didn't get MAC from the web UI, use the ARP MAC
    if (-not $phoneData.MAC) {
        $phoneData.MAC = $device.MAC
    }

    if ($phoneData.Success) {
        $successfulScrapes.Add($phoneData)
        Write-Host " [OK] $($phoneData.Model) / $($phoneData.Serial)" -ForegroundColor Green
    }
    else {
        $failedScrapes.Add([PSCustomObject]@{
            IP    = $device.IP
            MAC   = $device.MAC
            Error = $phoneData.Error
        })
        Write-Host " [FAILED] $($phoneData.Error)" -ForegroundColor Red
    }
}

Write-Host ""

# --- Step 5: Output results ---
if ($successfulScrapes.Count -gt 0) {
    # Write to CSV
    Write-Host "[INFO] Writing $($successfulScrapes.Count) records to $OutputFile..." -ForegroundColor Yellow
    try {
        Export-PhoneInventory -PhoneRecords $successfulScrapes -FilePath $OutputFile -Site $SiteName
        Write-Host "[OK] CSV updated: $OutputFile" -ForegroundColor Green
    }
    catch {
        Write-Host "[ERROR] Failed to write CSV: $_" -ForegroundColor Red
    }

    # Display results table
    Write-Host ""
    Write-Host "==========================================" -ForegroundColor Cyan
    Write-Host "  Scan Results" -ForegroundColor Cyan
    Write-Host "==========================================" -ForegroundColor Cyan
    Write-Host ""

    $successfulScrapes | ForEach-Object {
        [PSCustomObject]@{
            IP              = $_.IP
            MAC             = $_.MAC
            Model           = $_.Model
            Serial          = $_.Serial
            FirmwareVersion = $_.FirmwareVersion
        }
    } | Format-Table -AutoSize | Out-String | Write-Host
}

# Report failures
if ($failedScrapes.Count -gt 0) {
    Write-Host "  Failed devices:" -ForegroundColor Red
    foreach ($fail in $failedScrapes) {
        Write-Host "    $($fail.IP) ($($fail.MAC)): $($fail.Error)" -ForegroundColor Red
    }
    Write-Host ""
}

# --- Summary ---
$elapsed = (Get-Date) - $scriptStartTime

Write-Host "==========================================" -ForegroundColor Cyan
Write-Host "  Summary" -ForegroundColor Cyan
Write-Host "==========================================" -ForegroundColor Cyan
Write-Host "  Site:              $SiteName"
Write-Host "  Subnet:            $Subnet"
Write-Host "  IPs scanned:       $($ipList.Count)"
Write-Host "  Yealink detected:  $($yealinkDevices.Count)"
Write-Host "  Successfully scraped: $($successfulScrapes.Count)" -ForegroundColor Green
Write-Host "  Failed:            $($failedScrapes.Count)" -ForegroundColor $(if ($failedScrapes.Count -gt 0) { "Red" } else { "Green" })
Write-Host "  Elapsed time:      $([math]::Round($elapsed.TotalSeconds, 1))s"
Write-Host "==========================================" -ForegroundColor Cyan
Write-Host ""
