Get-VNetSubnetAvailability.ps1

<#
.SYNOPSIS
Analyzes an Azure Virtual Network (VNet) to discover the first available subnet address blocks for requested CIDR sizes.
     
.DESCRIPTION
Get-VNetSubnetAvailability queries an Azure VNet (via Azure CLI) to:
    1. Gather the VNet's address space prefixes.
    2. Enumerate existing subnet prefixes (single and multi-addressPrefix forms).
    3. Compute free (unallocated) IP ranges within each VNet address space.
    4. For each requested CIDR size, find the first contiguous block that can be carved out.
If multiple VNet address space prefixes exist and no -VNetPrefix filter is supplied, a summary of the first available CIDR (in size order) across all prefixes is produced.
     
All IP math is performed using native .NET / PowerShell types (System.Net.IPAddress and bitwise operations). No external PowerShell modules are required beyond what ships with Windows PowerShell / PowerShell 7. Azure CLI (az) must be installed and authenticated.
     
.PARAMETER SubscriptionId
Azure Subscription ID containing the VNet.
     
.PARAMETER ResourceGroup
Resource group name where the target VNet resides.
     
.PARAMETER VNetName
Name of the target Virtual Network.
     
.PARAMETER Sizes
Array of CIDR sizes (string form: "/24","/25", etc.) to evaluate for availability.
Defaults to /23 through /28 if not specified. Order matters; first match per size is returned.
     
.PARAMETER VNetPrefix
Optional single VNet address space prefix (e.g. 10.10.0.0/16) to restrict calculations.
If omitted, all VNet address prefixes are scanned.
     
.PARAMETER ReturnJson
Switch. If supplied, function writes JSON (array of per-prefix results) instead of returning a PSCustomObject.
     
.OUTPUTS
When -ReturnJson is used:
    JSON string representing an array of objects:
        [
            {
                "VNetPrefix": "10.10.0.0/16",
                "Sizes": [
                    {
                        "Size": "/24",
                        "Prefix": 24,
                        "AvailableSubnets": { "CIDR": "10.10.5.0/24", "Start": "10.10.5.0", "End": "10.10.5.255" }
                    },
                    ...
                ]
            },
            ...
        ]
     
When -ReturnJson is NOT used:
    PSCustomObject with properties:
        Results : Collection of per-prefix availability detail.
        FirstAvailable : First size match across all prefixes (null if none or filter applied).
        VNetName : Provided VNet name.
        ResourceGroup : Provided resource group.
        SubscriptionId : Provided subscription ID.
        FilteredPrefixUsed : Boolean indicating whether -VNetPrefix was applied.
     
Each entry in Results.Sizes contains:
    Size : Original CIDR size string (e.g. "/26").
    Prefix : Numeric CIDR prefix length (e.g. 26).
    AvailableSubnets : Object with CIDR, Start, End of FIRST available subnet, or $null if none.
     
.EXAMPLES
# Basic usage with defaults (/23 - /28)
Get-VNetSubnetAvailability -SubscriptionId "00000000-0000-0000-0000-000000000000" `
    -ResourceGroup "rg-network" `
    -VNetName "vnet-hub"
     
# Restrict to a single VNet address space prefix and request specific sizes
Get-VNetSubnetAvailability -SubscriptionId "00000000-0000-0000-0000-000000000000" `
    -ResourceGroup "rg-network" `
    -VNetName "vnet-hub" `
    -VNetPrefix "10.20.0.0/16" `
    -Sizes "/24","/26","/27"
     
# Return JSON for downstream processing
Get-VNetSubnetAvailability -SubscriptionId "00000000-0000-0000-0000-000000000000" `
    -ResourceGroup "rg-network" `
    -VNetName "vnet-spoke" `
    -Sizes "/26","/27" `
    -ReturnJson
     
# Pipe JSON into jq or other tooling
Get-VNetSubnetAvailability -SubscriptionId "00000000-0000-0000-0000-000000000000" `
    -ResourceGroup "rg-network" `
    -VNetName "vnet-spoke" `
    -ReturnJson | jq '.[] | {prefix: .VNetPrefix, first26: (.Sizes[] | select(.Size == "/26") | .AvailableSubnets.CIDR)}'
     
.NOTES
Prerequisites:
    - Azure CLI (az) installed and authenticated (az login).
    - Caller must have permissions to read the VNet and its subnets.
     
Performance:
    - Designed for modest VNet sizes; very large numbers of subnets may increase scan time.
    - The algorithm scans free ranges sequentially; earliest (lowest address) qualifying block per size is returned.
     
Limitations:
    - Only IPv4 address spaces are supported.
    - Does not validate service-specific reserved IPs (e.g., excluding first/last usable host addresses). It reports full network ranges.
     
Error Handling:
    - Throws if the VNet cannot be retrieved or if a specified -VNetPrefix is not part of the VNet.
    - Writes progress and diagnostic messages to host output.
     
Future Enhancements (suggestions):
    - Add option to return ALL candidate subnets per size (not just the first).
    - Support exclusion lists (manual reservations).
    - Add IPv6 support.
    - Parallelize per-prefix scans if needed.
    - Implement logging for better traceability.
    - Add detailed error logging for troubleshooting.
    - Enhance performance metrics for subnet scanning.
    - Consider adding a progress bar for long-running operations.
#>


function Get-VNetSubnetAvailability {
    param(
        [Parameter(Mandatory)][string] $SubscriptionId,
        [Parameter(Mandatory)][string] $ResourceGroup,
        [Parameter(Mandatory)][string] $VNetName,
        [string[]] $Sizes = @("/28"),
        [string] $VNetPrefix,
        [switch] $ReturnJson
    )
    Write-Host ('-' * 75)
    ################################################################
    ## Signature
    # Colorful ANSI banner (works in modern Windows Terminal / pwsh)
    $esc = [char]27
    $t = @"
${esc}[96m=====================================================================
${esc}[95m Get-VNetSubnetAvailability ${esc}[90m- ${esc}[95mAzure VNet Subnet Availability Analyzer
${esc}[96m=====================================================================
${esc}[93m Purpose :${esc}[97m Identify available subnet blocks for requested CIDR sizes
${esc}[92m Author :${esc}[97m Aammir Mirza
${esc}[92m Version :${esc}[97m 1.0.10
${esc}[92m Timestamp :${esc}[97m $(Get-Date -Format 'yyyy-MM-dd HH:mm:ss K')
${esc}[90m---------------------------------------------------------------------
${esc}[94m Context
${esc}[94m SubscriptionId :${esc}[97m $SubscriptionId
${esc}[94m ResourceGroup :${esc}[97m $ResourceGroup
${esc}[94m VNet Name :${esc}[97m $VNetName
${esc}[94m VNet Prefix :${esc}[97m $(if ($VNetPrefix) { $VNetPrefix } else { "<all VNet prefixes>" })
${esc}[94m CIDR Sizes :${esc}[97m $(if ($Sizes) { $Sizes -join ', ' } else { "<none>" })
${esc}[90m---------------------------------------------------------------------
${esc}[96m Operational Notes
${esc}[96m - Requires Azure CLI (az). Run 'az login' beforehand.
${esc}[96m - Evaluates existing subnets and reports first free aligned block per size.
${esc}[96m - Use -ReturnJson for structured output (automation / pipelines).
${esc}[96m - Attempts consecutive chain allocation when multiple sizes supplied.
${esc}[90m---------------------------------------------------------------------
${esc}[91m Disclaimer
${esc}[91m Provided as-is without warranty. Validate results before deployment.
In case of errors or incorrect output, review logs and ensure proper permissions. Also, try reverting back to previous version if issues persist.
${esc}[96m=====================================================================${esc}[0m
"@


    Write-Host "`n$($t)"
    ################################################################
    Write-Host ('-' * 75)
    # 1. Ensure Azure CLI available
    if (-not (Get-Command az -ErrorAction SilentlyContinue)) {
        Write-Host "[ERROR] Azure CLI (az) not found in PATH."
    }
    Write-Host "[INFO] Azure CLI detected."

    Write-Host "[START] Subnet carve-out process initiated"
    Write-Host "[SUBSCRIPTION] Setting context to subscription: $SubscriptionId"
    # Robust subscription context handling
    if ([string]::IsNullOrWhiteSpace($SubscriptionId)) {
        Write-Warning "[WARNING] SubscriptionId parameter is empty or whitespace. Cannot set Azure CLI context."
    }
    else {
        Write-Host "[SUBSCRIPTION] Attempting to set Azure CLI context via Azure CLI"
        $setResult = & az account set --subscription $SubscriptionId 2>&1
        if ($LASTEXITCODE -ne 0) {
            Write-Warning "[WARNING] Failed to set subscription context for: $SubscriptionId"
            if ($setResult -match 'az login') {
                Write-Warning "[WARNING] Azure CLI not authenticated. Run 'az login' and retry."
            }
            elseif ($setResult -match 'not found' -or $setResult -match 'does not exist') {
                Write-Warning "[WARNING] Provided SubscriptionId not found or inaccessible."
            }
            else {
                Write-Warning "[WARNING] az account set error: $setResult"
            }
        }
        else {
            $currentSub = az account show --query id -o tsv 2>$null
            if ($currentSub -ne $SubscriptionId) {
                Write-Warning "[WARNING] Context mismatch. Expected: $SubscriptionId Got: $currentSub"
            }
            else {
                Write-Host "[SUBSCRIPTION] Context successfully set to: $currentSub"
            }
        }
    }

    Write-Host "[VNET] Retrieving VNet address space for: $VNetName (RG: $ResourceGroup)"
    $VNetAddressSpace = az network vnet show `
        --resource-group $ResourceGroup `
        --name $VNetName `
        --query "addressSpace.addressPrefixes" `
        --output json | ConvertFrom-Json

    Write-Host "[VNET] Retrieving existing subnets from VNet: $VNetName"
    $SubnetData = az network vnet subnet list `
        --resource-group $ResourceGroup `
        --vnet-name $VNetName `
        --query "[].{Single:addressPrefix,Multi:addressPrefixes}" `
        --output json | ConvertFrom-Json

    Write-Host "[SUBNETS] Flattening subnet prefixes"
    $SubnetPrefixes = @()
    foreach ($sd in $SubnetData) {
        if ($sd.Single) { $SubnetPrefixes += $sd.Single }
        if ($sd.Multi) { $SubnetPrefixes += $sd.Multi }
    }

    # Suppress stack trace / line numbers by avoiding 'throw'
    if (-not $VNetAddressSpace) {
        Write-Host "[ERROR] Failed to retrieve VNet: $VNetName"
        Write-Host "[ERROR] Authentication Error, VNET not exist, Authorization Issue, Az Login missing."
        if ($ReturnJson) { return '[]' }  # empty JSON
        return                              # graceful exit
    }

    $VNetPrefixes = $VNetAddressSpace
    $UsedSubnets = $SubnetPrefixes

    Write-Host "[ADDRESS_SPACE] VNet prefixes detected: $($VNetPrefixes -join ', ')"
    if ($VNetPrefix) {
        Write-Host "[FILTER] Requested single VNet prefix filter: $VNetPrefix"
        if (-not ($VNetPrefixes -contains $VNetPrefix)) {
            Write-Host "[ERROR] Provided VNetPrefix '$VNetPrefix' not found."
            throw "Specified VNetPrefix '$VNetPrefix' not found in $($VNetPrefixes -join ', ')"
        }
        $EffectivePrefixes = @($VNetPrefix)
        Write-Host "[FILTER] Using filtered prefix list: $VNetPrefix"
    }
    else {
        $EffectivePrefixes = $VNetPrefixes
        Write-Host "[FILTER] No prefix filter provided; using all prefixes"
    }

    Write-Host "[EXISTING_SUBNETS] Listing existing subnet CIDRs:"
    $UsedSubnets | ForEach-Object { Write-Host " - $_" }

    # Local helpers
    function ConvertTo-IPRange {
        param([string] $CIDR)
        $split = $CIDR.Split('/')
        $ip = [System.Net.IPAddress]::Parse($split[0])
        $prefix = [int]$split[1]
        $ipBytes = $ip.GetAddressBytes(); [Array]::Reverse($ipBytes)
        $ipInt = [BitConverter]::ToUInt32($ipBytes, 0)
        $mask = ([uint32]::MaxValue -shl (32 - $prefix))
        $network = $ipInt -band $mask
        $broadcast = $network + ([uint32]::MaxValue -bxor $mask)
        $startBytes = [BitConverter]::GetBytes($network)
        $endBytes = [BitConverter]::GetBytes($broadcast)
        [Array]::Reverse($startBytes); [Array]::Reverse($endBytes)
        [PSCustomObject]@{
            CIDR   = $CIDR
            Start  = [System.Net.IPAddress]::new($startBytes).ToString()
            End    = [System.Net.IPAddress]::new($endBytes).ToString()
            Prefix = $prefix
        }
    }
    function Compare-IP {
        param([string]$IP1, [string]$IP2)
        $b1 = [System.Net.IPAddress]::Parse($IP1).GetAddressBytes()
        $b2 = [System.Net.IPAddress]::Parse($IP2).GetAddressBytes()
        [Array]::Reverse($b1); [Array]::Reverse($b2)
        $i1 = [BitConverter]::ToUInt32($b1, 0)
        $i2 = [BitConverter]::ToUInt32($b2, 0)
        $i1 - $i2
    }
    function Next-IP {
        param([string]$IP)
        $b = [System.Net.IPAddress]::Parse($IP).GetAddressBytes(); [Array]::Reverse($b)
        $i = [BitConverter]::ToUInt32($b, 0); $i++
        $nb = [BitConverter]::GetBytes($i); [Array]::Reverse($nb)
        [System.Net.IPAddress]::new($nb).ToString()
    }
    function Previous-IP {
        param([string]$IP)
        $b = [System.Net.IPAddress]::Parse($IP).GetAddressBytes(); [Array]::Reverse($b)
        $i = [BitConverter]::ToUInt32($b, 0); $i--
        $nb = [BitConverter]::GetBytes($i); [Array]::Reverse($nb)
        [System.Net.IPAddress]::new($nb).ToString()
    }
    function Get-FreeRanges {
        param([string] $VNetCIDR, [string[]] $UsedCIDRs)
        Write-Host "[RANGE_SCAN] Computing free ranges inside $VNetCIDR"
        $parent = ConvertTo-IPRange $VNetCIDR
        $used = $UsedCIDRs | Where-Object { $_ -like "$($VNetCIDR.Split('/')[0])*" } | ForEach-Object { ConvertTo-IPRange $_ }
        $used = $used | Sort-Object { [System.Net.IPAddress]::Parse($_.Start).GetAddressBytes() }
        $free = @(); $current = $parent.Start
        foreach ($u in $used) {
            if ((Compare-IP $current $u.Start) -lt 0) {
                $free += [PSCustomObject]@{ Start = $current; End = (Previous-IP $u.Start) }
            }
            $current = Next-IP $u.End
        }
        if ((Compare-IP $current $parent.End) -le 0) {
            $free += [PSCustomObject]@{ Start = $current; End = $parent.End }
        }
        Write-Host "[RANGE_SCAN] Free range count for $($VNetCIDR): $($free.Count)"
        $free
    }
    function Get-SubnetRange {
        param([string]$Start, [int]$Prefix)
        # Parse start IP and convert to uint32
        $ip = [System.Net.IPAddress]::Parse($Start)
        $bytes = $ip.GetAddressBytes(); [Array]::Reverse($bytes)
        $startInt = [BitConverter]::ToUInt32($bytes, 0)

        # block size (number of addresses in prefix)
        $blockSize = [uint32][math]::Pow(2, (32 - $Prefix))

        # find the aligned network start (round UP to next boundary if necessary)
        $offset = $startInt % $blockSize
        if ($offset -ne 0) {
            $alignedStartInt = $startInt + ($blockSize - $offset)
        }
        else {
            $alignedStartInt = $startInt
        }

        # compute end
        $endInt = $alignedStartInt + $blockSize - 1

        # convert back to IP string
        $startBytes = [BitConverter]::GetBytes([uint32]$alignedStartInt); [Array]::Reverse($startBytes)
        $endBytes = [BitConverter]::GetBytes([uint32]$endInt); [Array]::Reverse($endBytes)

        [PSCustomObject]@{
            CIDR     = ("{0}/{1}" -f ([System.Net.IPAddress]::new($startBytes).ToString()), $Prefix)
            Start    = [System.Net.IPAddress]::new($startBytes).ToString()
            End      = [System.Net.IPAddress]::new($endBytes).ToString()
            StartInt = $alignedStartInt
            EndInt   = $endInt
        }
    }

    Write-Host "[PROCESS] Beginning availability scan across effective prefixes: $($EffectivePrefixes -join ', ')"
    $results = @()
    $usedPrefixes = @()  # Track prefixes already used for allocations
    $allocationMap = @{}  # Track which prefix each size was allocated to

    if ($Sizes.Count -gt 1) {
        # Multiple sizes supplied – sort from largest subnet to smallest.
        # Largest subnet = smallest prefix number (/16 larger than /24).
        Write-Host "[CHAIN] Multiple sizes detected. Original order: $($Sizes -join ', ')"
        $Sizes = $Sizes | Sort-Object { [int]($_.TrimStart('/')) }
        Write-Host "[CHAIN] Sorted (largest -> smallest): $($Sizes -join ', ')"
        Write-Host "[CHAIN] Attempting consecutive allocation for sizes: $($Sizes -join ', ')"
        $chainAlloc = $null
        $chainPrefix = $null

        # Try to find a consecutive chain in any single prefix
        foreach ($vnetPrefix in $EffectivePrefixes) {
            $freeRanges = Get-FreeRanges -VNetCIDR $vnetPrefix -UsedCIDRs $UsedSubnets
            foreach ($fr in $freeRanges) {
                $chainCursor = $fr.Start
                $allocList = @()
                $failed = $false

                foreach ($size in $Sizes | Sort-Object) {
                    $prefix = [int]$size.TrimStart('/')
                    $candidate = Get-SubnetRange -Start $chainCursor -Prefix $prefix

                    if ((Compare-IP $candidate.End $fr.End) -gt 0) { $failed = $true; break }

                    $allocList += [PSCustomObject]@{
                        Size   = $size
                        Prefix = $prefix
                        Subnet = $candidate
                    }

                    $chainCursor = Next-IP $candidate.End
                }

                if (-not $failed -and $allocList.Count -eq $Sizes.Count) {
                    $chainAlloc = $allocList
                    $chainPrefix = $vnetPrefix
                    Write-Host "[CHAIN] Success inside free range $($fr.Start)-$($fr.End) in prefix $vnetPrefix"
                    break
                }
            }
            if ($chainAlloc) { break }
        }

        if ($chainAlloc) {
            # Output per-prefix results with chain allocations
            foreach ($vnetPrefix in $EffectivePrefixes) {
                $prefixSizes = @()
                if ($vnetPrefix -eq $chainPrefix) {
                    foreach ($entry in $chainAlloc) {
                        Write-Host "[CHAIN_ALLOC] $($entry.Size) -> $($entry.Subnet.CIDR)"
                        $prefixSizes += [PSCustomObject]@{
                            Size             = $entry.Size
                            Prefix           = $entry.Prefix
                            AvailableSubnets = [PSCustomObject]@{ CIDR = $entry.Subnet.CIDR; Start = $entry.Subnet.Start; End = $entry.Subnet.End }
                            ConsecutiveChain = $true
                        }
                    }
                }
                else {
                    foreach ($size in $Sizes) {
                        $prefixSizes += [PSCustomObject]@{
                            Size             = $size
                            Prefix           = [int]($size.TrimStart('/'))
                            AvailableSubnets = $null
                            ConsecutiveChain = $false
                        }
                    }
                }
                $results += [PSCustomObject]@{ VNetPrefix = $vnetPrefix; Sizes = $prefixSizes }
            }
        }
        else {
            Write-Host "[CHAIN] No single consecutive block fits all sizes. Falling back to distributed per-size allocation."

            # Process each size and allocate to first available unused prefix
            $sizeAllocations = @()
            $tempUsedSubnets = @($UsedSubnets)  # Track allocations during this cycle

            foreach ($size in $Sizes) {
                Write-Host "[SIZE] Evaluating first available subnet for size $size"
                $prefix = [int]($size.TrimStart('/'))
                $chosen = $null
                $allocatedPrefix = $null

                # Scan prefixes in order, preferring unused ones
                foreach ($vnetPrefix in $EffectivePrefixes) {
                    # Use updated $tempUsedSubnets to account for allocations in this cycle
                    $freeRanges = Get-FreeRanges -VNetCIDR $vnetPrefix -UsedCIDRs $tempUsedSubnets
                    $available = @()

                    foreach ($fr in $freeRanges) {
                        $cursor = $fr.Start
                        while ($true) {
                            $subnet = Get-SubnetRange -Start $cursor -Prefix $prefix
                            if ((Compare-IP $subnet.End $fr.End) -gt 0) { break }

                            $overlaps = $tempUsedSubnets | ForEach-Object {
                                $usedRange = ConvertTo-IPRange $_
                                if ((Compare-IP $subnet.Start $usedRange.End) -le 0 -and (Compare-IP $subnet.End $usedRange.Start) -ge 0) { $true }
                            }
                            if (-not $overlaps) { $available += $subnet }

                            $cursor = Next-IP $subnet.End
                            if ((Compare-IP $cursor $fr.End) -gt 0) { break }
                        }
                    }

                    $chosen = $available | Select-Object -First 1
                    if ($chosen) {
                        $allocatedPrefix = $vnetPrefix
                        # Mark prefix as used if not already marked
                        if ($vnetPrefix -notin $usedPrefixes) {
                            $usedPrefixes += $vnetPrefix
                        }
                        Write-Host "[ALLOC_CANDIDATE] Found $($chosen.CIDR) in $vnetPrefix for $size"

                        # Add newly allocated subnet to temp tracking
                        $tempUsedSubnets += $chosen.CIDR
                        Write-Host "[TRACKING] Updated temp used subnets: $($tempUsedSubnets -join ', ')"

                        break  # Allocate to first prefix that has space
                    }
                }

                $sizeAllocations += [PSCustomObject]@{
                    Size       = $size
                    Prefix     = $prefix
                    CIDR       = if ($chosen) { $chosen.CIDR } else { $null }
                    Start      = if ($chosen) { $chosen.Start } else { $null }
                    End        = if ($chosen) { $chosen.End } else { $null }
                    VNetPrefix = $allocatedPrefix
                    Available  = [bool]$chosen
                }

                if (-not $chosen) {
                    Write-Host "[ALLOC_CANDIDATE] None for $size in any prefix"
                }
            }

            # Build results grouped by VNetPrefix - iterate through sizeAllocations in order to preserve duplicates
            $groupedByPrefix = $sizeAllocations | Group-Object VNetPrefix
            foreach ($group in $groupedByPrefix) {
                $prefixSizes = @()
                foreach ($alloc in $group.Group) {
                    $prefixSizes += [PSCustomObject]@{
                        Size             = $alloc.Size
                        Prefix           = $alloc.Prefix
                        AvailableSubnets = if ($alloc.CIDR) { [PSCustomObject]@{ CIDR = $alloc.CIDR; Start = $alloc.Start; End = $alloc.End } } else { $null }
                        ConsecutiveChain = $false
                    }
                }
                $results += [PSCustomObject]@{ VNetPrefix = $group.Name; Sizes = $prefixSizes }
            }

            # Add empty entries for prefixes with no allocations
            foreach ($vnetPrefix in $EffectivePrefixes) {
                if (-not ($results | Where-Object { $_.VNetPrefix -eq $vnetPrefix })) {
                    $prefixSizes = @()
                    foreach ($size in $Sizes) {
                        $prefixSizes += [PSCustomObject]@{
                            Size             = $size
                            Prefix           = [int]($size.TrimStart('/'))
                            AvailableSubnets = $null
                            ConsecutiveChain = $false
                        }
                    }
                    $results += [PSCustomObject]@{ VNetPrefix = $vnetPrefix; Sizes = $prefixSizes }
                }
            }
        }
    }
    else {
        # Single size: allocate in first available prefix (original logic)
        foreach ($vnetPrefix in $EffectivePrefixes) {
            ('-' * 75) | Out-Host
            Write-Host "[PREFIX] Processing VNet prefix: $vnetPrefix"
            $freeRanges = Get-FreeRanges -VNetCIDR $vnetPrefix -UsedCIDRs $UsedSubnets
            $sizeEntries = @()

            foreach ($size in $Sizes) {
                Write-Host "[SIZE] Evaluating first available subnet for size $size in $vnetPrefix"
                $prefix = [int]($size.TrimStart('/'))
                $available = @()

                foreach ($fr in $freeRanges) {
                    $cursor = $fr.Start
                    while ($true) {
                        $subnet = Get-SubnetRange -Start $cursor -Prefix $prefix
                        if ((Compare-IP $subnet.End $fr.End) -gt 0) { break }

                        $overlaps = $UsedSubnets | ForEach-Object {
                            $usedRange = ConvertTo-IPRange $_
                            if ((Compare-IP $subnet.Start $usedRange.End) -le 0 -and (Compare-IP $subnet.End $usedRange.Start) -ge 0) {
                                $true
                            }
                        }
                        if (-not $overlaps) { $available += $subnet }

                        $cursor = Next-IP $subnet.End
                        if ((Compare-IP $cursor $fr.End) -gt 0) { break }
                    }
                }

                $chosen = $available | Select-Object -First 1
                if ($chosen) {
                    Write-Host "[ALLOC_CANDIDATE] Found $($chosen.CIDR)"
                }
                else {
                    Write-Host "[ALLOC_CANDIDATE] None for $size"
                }

                $sizeEntries += [PSCustomObject]@{
                    Size             = $size
                    Prefix           = $prefix
                    AvailableSubnets = if ($chosen) { [PSCustomObject]@{ CIDR = $chosen.CIDR; Start = $chosen.Start; End = $chosen.End } } else { $null }
                    ConsecutiveChain = $false
                }
            }

            $results += [PSCustomObject]@{ VNetPrefix = $vnetPrefix; Sizes = $sizeEntries }
            Write-Host ('-' * 75)
        }
    }

    $firstSizeResult = $null
    if (-not $VNetPrefix -and $VNetPrefixes.Count -gt 1) {
        Write-Host "[SUMMARY] Computing first available size across all prefixes"
        foreach ($size in $Sizes) {
            foreach ($r in $results) {
                $entry = $r.Sizes | Where-Object { $_.Size -eq $size -and $_.AvailableSubnets }
                if ($entry) {
                    $candidate = $entry.AvailableSubnets
                    $firstSizeResult = [PSCustomObject]@{
                        Size       = $size
                        CIDR       = $candidate.CIDR
                        Start      = $candidate.Start
                        End        = $candidate.End
                        VNetPrefix = $r.VNetPrefix
                    }
                    Write-Host "[SUMMARY] First available: Size=$size CIDR=$($candidate.CIDR) Prefix=$($r.VNetPrefix)"
                    break
                }
            }
            if ($firstSizeResult) { break }
        }
        if (-not $firstSizeResult) { Write-Host "[SUMMARY] No available subnet found for any requested size." }
    }

    Write-Host "[RESULT] Raw allocation scan complete."
    if ($ReturnJson) {
        ($results | ConvertTo-Json -Depth 6)
    }
    else {
        # Return simplified availability objects: one per allocation (not grouped by size)
        # This ensures duplicate sizes get separate rows
        $allocationRows = @()

        $results | ForEach-Object {
            $vnetPrefix = $_.VNetPrefix
            $_.Sizes | ForEach-Object {
                # Create a row for each size entry
                $allocationRows += [PSCustomObject]@{
                    VNET_Prefix      = $vnetPrefix
                    Requested_Size   = $_.Size
                    PrefixLength     = $_.Prefix
                    Available        = [bool]$_.AvailableSubnets
                    CIDR             = if ($_.AvailableSubnets) { $_.AvailableSubnets.CIDR } else { $null }
                    Start            = if ($_.AvailableSubnets) { $_.AvailableSubnets.Start } else { $null }
                    End              = if ($_.AvailableSubnets) { $_.AvailableSubnets.End } else { $null }
                    ConsecutiveChain = $_.ConsecutiveChain
                }
            }
        }

        # Output all rows (each row represents one requested size)
        $allocationRows
    }
}