checks/arm/_reservations.ps1

<#
  Reserved VM instance coverage — the math, in one place.

  PURE: no cmdlets, no network, no Az context. vm-reservation-coverage.ps1 does the
  fetching and hands the four shapes below to Get-CoverageGroups / Get-ReservationFindings.
  That split is what makes the logic testable — ps/tests/reservation-coverage.ps1 runs
  every branch against fixtures with no tenant.

  Shapes (every field optional except where noted; the fetcher normalises):
    Instance @{ Id; Name; Size; Power; Loc; Sub; Count }
    Reservation @{ Id; Name; Sku; Loc; Quantity; Term; Expiry; ScopeType; Scopes;
                    Isf; Renew; Util; UtilGrain }
    Ratios hashtable: region(lower) -> hashtable: size(lower) -> @{ Group; Ratio }
    Recommend @{ Sku; Loc; Sub; Quantity; Savings1Y; Savings3Y; Currency; LookBack; Scope }
#>


# Units are RAW instance-size-flexibility ratios. Microsoft does not normalise them to 1
# for the smallest SKU in a group (Ddsv5 Series starts at 2), but both sides of every
# comparison here use the same scale, so normalising would change nothing. Don't add it.
$script:EPS = 0.01

# VM series that can NEVER draw a reservation discount, so counting them as "uncovered"
# invents a shortfall you cannot buy your way out of. A-series (NOT Av2) and G/GS-series
# per the purchase-restriction list; Spot is handled separately because Azure does not
# offer reservations for Spot VMs at all.
function Test-ReservationEligible {
    param([string]$Size, [string]$Priority)
    if ("$Priority" -match '(?i)spot') { return $false }
    $s = "$Size"
    if ($s -match '(?i)^(Basic|Standard)_A\d+[a-z]*$') { return $false }   # A-series; Av2 ends _v2
    if ($s -match '(?i)^Standard_GS?\d+') { return $false }               # G / GS series
    return $true
}

# Reservations for these series stopped being purchasable or RENEWABLE on 2026-07-01.
# Auto-renew silently does nothing for them, so an expiring reservation on this list drops
# straight to pay-as-you-go with no warning from Azure.
# Explicit patterns, not derived ones: a derived "[a-z]*" swallowed the 's' and made
# D4s_v3 match Dv3. Ordered most-specific first, and the s-variants precede their
# non-s siblings. Term matters — the v3 families lost BOTH terms, the rest only 1-year.
$script:RETIRED_ALL = [ordered]@{      # no purchase or renewal, either term
    'Dsv3' = '^D\d+(-\d+)?s_v3$'; 'Dv3' = '^D\d+(-\d+)?_v3$'
    'Esv3' = '^E\d+(-\d+)?s_v3$'; 'Ev3' = '^E\d+(-\d+)?_v3$'
}
$script:RETIRED_1Y = [ordered]@{       # 1-year term only
    'Dsv2' = '^D\d+(-\d+)?s_v2$';  'Dv2'  = '^D\d+(-\d+)?_v2$'
    'Ds'   = '^DS\d+(-\d+)?$';     'D'    = '^D\d+$'
    'Fsv2' = '^F\d+s_v2$';          'Fs'   = '^F\d+s$';        'F' = '^F\d+$'
    'Amv2' = '^A\d+m_v2$';          'Av2'  = '^A\d+m?_v2$'
    'Bv1'  = '^B\d+m?s$'
    'Gs'   = '^GS\d+$';             'G'    = '^G\d+$'
    'Lsv2' = '^L\d+s_v2$';          'Ls'   = '^L\d+s$'
}

function Get-RetiredSeries {
    <# The retired series name for a SKU, or $null. Since 2026-07-01 these cannot be bought
       or RENEWED — auto-renew stays on and silently does nothing. #>

    param([string]$Sku, [string]$Term)
    $n = "$Sku" -replace '(?i)^(Standard|Basic)_', ''
    foreach ($k in $script:RETIRED_ALL.Keys) {
        if ($n -match ('(?i)' + $script:RETIRED_ALL[$k])) { return $k }
    }
    if ("$Term" -eq 'P1Y') {
        foreach ($k in $script:RETIRED_1Y.Keys) {
            if ($n -match ('(?i)' + $script:RETIRED_1Y[$k])) { return $k }
        }
    }
    return $null
}

function Test-BillablePower {
    <# A VM consumes its reservation unless it is DEALLOCATED. 'stopped' — stopped but not
       deallocated — is still billed for compute and still consumes the reservation; that
       distinction is the whole "machines are turned off" question, and inverting it
       inverts the headline finding. An unknown/blank power state counts as billable: that
       errs toward "no spare capacity", which is the safe direction to be wrong in. #>

    param([string]$Power)
    return ("$Power" -notmatch '(?i)deallocat')
}

function Format-IsfGroup {
    <# The catalog names flexibility groups verbosely and per-OS, e.g. "Virtual Machines
       Dsv6-series Linux". A reservation covers COMPUTE regardless of operating system —
       Windows licensing is billed separately — so the Linux and Windows variants are one
       pool, and keeping them apart would split it and over-report every shortfall. Strip
       both the prefix and the OS suffix: shorter to read, and one pool per family. #>

    param([string]$Group)
    $g = "$Group" -replace '(?i)^virtual machines\s+', ''
    $g = $g -replace '(?i)\s+(linux|windows)$', ''
    return $g.Trim()
}

function Get-SizeIsf {
    <# Flexibility group + ratio for a VM size in a region. #>
    param($Ratios, [string]$Region, [string]$Size)
    $reg = "$Region".ToLower(); $sz = "$Size".ToLower()
    if ($Ratios -and $Ratios.ContainsKey($reg) -and $Ratios[$reg].ContainsKey($sz)) {
        $r = $Ratios[$reg][$sz]
        return [pscustomobject]@{ Group = (Format-IsfGroup $r.Group); Ratio = [double]$r.Ratio; Degraded = $false }
    }
    # No catalog row for this size. Fall back to exact-size matching: the size becomes its
    # own group at ratio 1. Arithmetically sound, but blind to instance size flexibility —
    # so Degraded rides along and the caller MUST say so rather than imply group math ran.
    return [pscustomobject]@{ Group = "$Size"; Ratio = 1.0; Degraded = $true }
}

function Get-CoverageRow {
    <# Fetch-or-create the (region x group) accumulator inside $Map. #>
    param([hashtable]$Map, [string]$Region, [string]$Group)
    $k = "$Region|$Group".ToLower()
    if (-not $Map.ContainsKey($k)) {
        $Map[$k] = [pscustomobject]@{
            Region = "$Region"; Group = "$Group"; Degraded = $false
            ReservedUnits = 0.0; RunningUnits = 0.0; DeallocatedUnits = 0.0
            DeallocatedCount = 0; Reservations = @()
            RunningSizes = @{}; RunningSubs = @{}; SizeRatios = @{}
            # Named rows + an unknown-power tally, so "you are running 9 machines" can be
            # audited against the portal instead of being taken on trust.
            RunningRows = @(); UnknownPower = 0; MissingRatio = @()
        }
    }
    return $Map[$k]
}

function Get-CoverageGroups {
    <# Fold instances + reservations into one row per (region x flexibility group). #>
    param($Instances, $Reservations, $Ratios)
    $groups = @{}

    foreach ($i in @($Instances)) {
        if (-not $i) { continue }
        $isf   = Get-SizeIsf $Ratios $i.Loc $i.Size
        $row   = Get-CoverageRow $groups $i.Loc $isf.Group
        $count = if ($i.Count) { [double]$i.Count } else { 1.0 }
        $units = $isf.Ratio * $count
        if ($isf.Degraded) { $row.Degraded = $true; $row.MissingRatio += "$($i.Size)" }
        if (Test-BillablePower $i.Power) {
            $row.RunningUnits += $units
            $row.RunningSizes["$($i.Size)"] = [double]$row.RunningSizes["$($i.Size)"] + $count
            $row.SizeRatios["$($i.Size)"] = $isf.Ratio
            $row.RunningSubs["$($i.Sub)"]   = [double]$row.RunningSubs["$($i.Sub)"] + $units
            $row.RunningRows += [pscustomobject]@{ Name = "$($i.Name)"; Size = "$($i.Size)"
                                                   Count = $count; Kind = "$($i.Kind)"; Id = "$($i.Id)" }
            if (-not "$($i.Power)") { $row.UnknownPower += [int]$count }
        } else {
            $row.DeallocatedUnits += $units
            $row.DeallocatedCount += [int]$count
        }
    }

    foreach ($r in @($Reservations)) {
        if (-not $r) { continue }
        $isf = Get-SizeIsf $Ratios $r.Loc $r.Sku
        $row = Get-CoverageRow $groups $r.Loc $isf.Group
        if ($isf.Degraded) { $row.Degraded = $true; $row.MissingRatio += "$($r.Sku)" }
        # Copy rather than Add-Member onto the caller's object: the fetcher may hand us
        # hashtables or pscustomobjects, and mutating its inputs is a nasty way to share state.
        $row.ReservedUnits += ($isf.Ratio * [double]$r.Quantity)
        $row.Reservations += [pscustomobject]@{
            Id = $r.Id; Name = $r.Name; Sku = $r.Sku; Loc = $r.Loc; Quantity = $r.Quantity
            Term = $r.Term; Expiry = $r.Expiry; ScopeType = $r.ScopeType; Scopes = @($r.Scopes)
            Isf = $r.Isf; Renew = $r.Renew; Util = $r.Util; UtilGrain = $r.UtilGrain
            Ratio = $isf.Ratio; Units = ($isf.Ratio * [double]$r.Quantity)
        }
    }

    return @($groups.Values)
}

function Format-Units {
    param([double]$N)
    if ([math]::Abs($N - [math]::Round($N)) -lt $script:EPS) { return "$([int][math]::Round($N))" }
    return "$([math]::Round($N, 2))"
}

function Get-BaseSku {
    <# The unit of account for a group's findings. "3 x Standard_D2lds_v5" is something a
       consultant can act on; "6 units" is an internal flexibility-ratio number nobody buys.
       Use the reservation they actually own with the largest quantity — that is the SKU they
       already think in (the user described their own estate as "8 x D2s_v5"). #>

    param($Group)
    $b = @($Group.Reservations | Sort-Object -Property Quantity -Descending | Select-Object -First 1)
    if ($b.Count -and $b[0].Sku) {
        $r = [double]$b[0].Ratio
        return [pscustomobject]@{ Sku = $b[0].Sku; Ratio = $(if ($r -gt 0) { $r } else { 1.0 }) }
    }
    # No reservation here yet — count in the size you run most of, so "short by 12 x
    # Standard_D4as_v5" still reads as something you could go and buy. Its REAL flexibility
    # ratio matters: hardcoding 1.0 reported 3 x D2s_v6 + 1 x D4s_v6 (10 ratio units) as
    # "10 x D2s_v6" instead of 5, inflating every unreserved family by its own ratio.
    $top = @($Group.RunningSizes.GetEnumerator() | Sort-Object -Property Value -Descending | Select-Object -First 1)
    if (-not $top.Count) { return $null }
    $sz = "$($top[0].Name)"
    $ratio = [double]$Group.SizeRatios[$sz]
    if ($ratio -le 0) { $ratio = 1.0 }
    return [pscustomobject]@{ Sku = $sz; Ratio = $ratio; Approx = $true }
}

function Short-Sku {
    <# "Standard_" is on every size and carries no information; details mention sizes a lot. #>
    param([string]$Sku)
    return ("$Sku" -replace '(?i)^Standard_', '')
}

function Format-Equiv {
    <# Units expressed as a count of the base SKU, e.g. "18 x Standard_D2lds_v5". #>
    param([double]$Units, $Base)
    if (-not $Base) { return "$(Format-Units $Units) units" }
    return "$(Format-Units ($Units / $Base.Ratio)) x $(Short-Sku $Base.Sku)"
}

function Format-Held {
    <# The reserved total in machine-equivalents, marked as an EQUIVALENT when the pool holds
       more than one size. Without that mark "reserved 7 x E2s_v5" reads as an inventory claim
       and is wrong when you actually own 5 x E2s_v5 + 1 x E4s_v5 - the exact confusion this
       check has already caused once. Costs 11 characters, and only when it matters. #>

    param($Group, $Base)
    $txt = Format-Equiv $Group.ReservedUnits $Base
    $skus = @(@($Group.Reservations) | ForEach-Object { "$($_.Sku)".ToLower() } | Sort-Object -Unique)
    if ($skus.Count -gt 1) { return "$txt-equivalent" }
    return $txt
}

function Format-Reserved {
    <# What you actually own in this group, NAMING the reservations.

       The equivalent total is a DERIVED number. Stating it alone ("You reserved 7 x
       Standard_E2s_v5") reads as a literal inventory claim and is flatly wrong whenever the
       group holds mixed SKUs — 5 x E2s_v5 plus 1 x E4s_v5 is also "7 x E2s_v5 equivalent",
       but you own six reservations of two sizes, not seven of one. Always list the real
       rows so the number can be reconciled against the portal. #>

    param($Group, $Base)
    $rs = @($Group.Reservations)
    if (-not $rs.Count) { return 'You have no reservation here' }
    $list = (@($rs | ForEach-Object {
        $n = if ($_.Name) { "$($_.Name): " } else { '' }
        "$n$(Format-Units ([double]$_.Quantity)) x $($_.Sku)" }) -join '; ')
    $skus = @($rs | ForEach-Object { "$($_.Sku)".ToLower() } | Sort-Object -Unique)
    if ($rs.Count -eq 1) { return "You have $list" }
    if ($skus.Count -eq 1) { return "You have $list, $(Format-Equiv $Group.ReservedUnits $Base) in total" }
    return "You have $list - mixed sizes, together the equivalent of $(Format-Equiv $Group.ReservedUnits $Base)"
}

function Get-ReservationFindings {
    <# SIMPLE MATH, deliberately.

       Per region x flexibility group: add up what you RESERVED, add up what you RUN, and
       report the difference. That is the whole model, and it matches how reservations
       actually behave — you buy a D2s_v5 and it becomes a POOL that any machine in that
       family can draw on, sized by the flexibility ratio.

       It is approximate by nature and that is fine: Azure applies the benefit hour by hour,
       first come first served, so no tool can say which individual machine was covered
       without the billing records. Earlier versions tried (per-instance billing attribution,
       measured hour aggregation) and produced answers that were harder to trust, not easier.
       Report the shortfall per family and let the consultant act on it.

       -InventoryOk / the per-group Degraded flag still gate everything: without real
       inventory or real flexibility ratios the comparison is withheld, never guessed. #>

    param($Groups, $Recommendations, [datetime]$Now = [datetime]::UtcNow,
          [bool]$InventoryOk = $true, [int]$MaxBuys = 10, [double]$MinSavings = 50,
          [bool]$SavingsPlansKnown = $true)
    $items = @()

    foreach ($g in @($Groups)) {
        $canCompare = ($InventoryOk -and -not $g.Degraded)
        $base = Get-BaseSku $g
        $where = "$($g.Group) in $($g.Region)"
        $resIds = @($g.Reservations | ForEach-Object { $_.Id } | Where-Object { $_ })
        # THE FAMILY IS THE SUBTASK, so the family is the identity. Not a reservation id —
        # Dsv5 holds two reservations and "the first one" depends on hashtable order, so the
        # key flipped between runs, orphaning the remediation state and minting a phantom
        # "verified remediated" row each time. Not the title either: renaming the group
        # (stripping the catalog's OS suffix) did exactly that to two families. A synthetic
        # key survives both, and survives the verdict changing from short to surplus.
        $famKey = "family:$($g.Region)/$($g.Group)".ToLower()
        $diff = $g.RunningUnits - $g.ReservedUnits           # +ve = short, -ve = surplus
        # Ignore dust: a fractional unit either way is not worth a finding. FLAT, never a
        # percentage of the pool. The old 2% arm scaled the blind spot with the estate, which
        # is backwards — 2% of a 118-unit Dsv5 pool on one customer tenant was 2.36 units, so a whole
        # missing D2s_v5 (59 reserved vs 58 running) went unreported in both directions until
        # the user found it by hand on 2026-08-14. The bigger the pool, the more a relative
        # floor hides, and a missing machine costs the same whatever it sits next to.
        $floor = 0.5
        $sizes = (($g.RunningSizes.GetEnumerator() | Sort-Object Name |
                   ForEach-Object { "$($_.Value)x $(Short-Sku $_.Name)" }) -join ', ')
        # "running 5 x D2s_v6 (5x D2s_v6)" says the same thing twice. The mix only earns its
        # space when it is NOT already the headline number — i.e. more than one size, or one
        # size that differs from the base SKU the sentence counts in.
        $oneSize = @($g.RunningSizes.Keys)
        if ($oneSize.Count -eq 1 -and $base -and "$($oneSize[0])".ToLower() -eq "$($base.Sku)".ToLower()) { $sizes = '' }
        # A savings plan is not a reservation and lives under a different provider. If we
        # could not read them, "nothing is reserved here" might simply be wrong.
        $spCaveat = if (-not $SavingsPlansKnown) {
            ' Savings plans unreadable, so this family may already be covered by one.'
        } else { '' }
        $dealloc = if ($g.DeallocatedCount -gt 0) { ", +$($g.DeallocatedCount) deallocated" } else { '' }

        # VERDICT FIRST, then the two numbers that justify it, then what it costs. The detail
        # shows inside the card, so a 450-character paragraph does not fit — and the reader is
        # scanning, not studying. "Reserved:" / "Running:" as labels beats a flowing sentence
        # for exactly that: the two numbers the verdict rests on are found without reading.
        $nres = @($g.Reservations).Count
        $plural = if ($nres -ne 1) { 's' } else { '' }
        $running = "Running: $(Format-Equiv $g.RunningUnits $base)$(if ($sizes) { " ($sizes)" })$dealloc."
        if ($canCompare -and $diff -gt $floor) {
            $have = if ($nres) { "Reserved: $(Format-Held $g $base) across $nres reservation$plural." }
                    else { 'Reserved: nothing in this family.' }
            # Name the consequence. "Short by 3" is a number; "3 bill at full price" is a reason
            # to act, and it is the sentence the consultant repeats to the customer.
            $costs = if ($nres) { "The $(Format-Equiv $diff $base) not covered bills at full pay-as-you-go price." }
                     else { 'All of it bills at full pay-as-you-go price.' }
            $items += [pscustomobject]@{
                Title    = $(if ($g.ReservedUnits -gt $script:EPS) { "Short on reservations: $($g.Group) in $($g.Region)" }
                             else { "No reservation for $($g.Group) in $($g.Region)" })
                Detail   = ("Buy $(Format-Equiv $diff $base). $have $running $costs" +
                            $(if ($g.ReservedUnits -le $script:EPS) { $spCaveat } else { '' }))
                Severity = 'medium'
                ResourceId = $famKey
            }
        }
        elseif ($canCompare -and $diff -lt (-1 * $floor) -and $g.ReservedUnits -gt $script:EPS) {
            $items += [pscustomobject]@{
                Title    = "Surplus reservation: $($g.Group) in $($g.Region)"
                Detail   = ("You are paying for $(Format-Equiv ([math]::Abs($diff)) $base) that nothing uses. " +
                            "Reserved: $(Format-Held $g $base) across $nres reservation$plural. $running " +
                            'Exchange it for a size or region you do run, or let it expire.')
                Severity = 'high'
                ResourceId = $famKey
            }
        }

        foreach ($r in @($g.Reservations)) {
            $tag = if ($r.Name) { "$($r.Name)" } else { "$($r.Sku) x$($r.Quantity)" }

            # Reservations for these series stopped being sold or RENEWED on 2026-07-01.
            $retired = Get-RetiredSeries $r.Sku $r.Term
            if ($retired) {
                $when = if ($r.Expiry) { " It expires $(([datetime]$r.Expiry).ToString('yyyy-MM-dd'))." } else { '' }
                $renew = if ("$($r.Renew)" -eq 'True') { ' Auto-renew is ON and will NOT work for this series - it fails silently.' } else { '' }
                $items += [pscustomobject]@{
                    Title    = "Reservation '$tag' is for a retired series and cannot be renewed"
                    Detail   = ("Move to a savings plan or a current series before it expires. " +
                                "$(Short-Sku $r.Sku) is in the retired $retired series - since 1 Jul 2026 Azure no " +
                                "longer sells or renews it.$when$renew Plan 6-12 months ahead.")
                    Severity = 'high'
                    ResourceId = $r.Id
                }
            }
            elseif ($r.Expiry) {
                $days = [int]([datetime]$r.Expiry - $Now).TotalDays
                if ($days -le 60 -and "$($r.Renew)" -ne 'True') {
                    $items += [pscustomobject]@{
                        Title    = "Reservation '$tag' expires with auto-renew off"
                        Detail   = ("Turn auto-renew on, or plan the loss. Expires " +
                                    "$(([datetime]$r.Expiry).ToString('yyyy-MM-dd')), in $days days. On that " +
                                    "day $(Format-Equiv $r.Units $base) goes back to full pay-as-you-go price.")
                        Severity = 'high'
                        ResourceId = $r.Id
                    }
                }
            }

            # Both of these break the POOL: the reservation stops being usable by anything
            # other than an exact match, which is the whole premise of the maths above.
            if ($canCompare -and "$($r.Isf)" -eq 'Off') {
                $other = @($g.RunningSizes.Keys | Where-Object { "$_".ToLower() -ne "$($r.Sku)".ToLower() })
                if ($other.Count) {
                    $items += [pscustomobject]@{
                        Title    = "Reservation '$tag' has instance size flexibility off"
                        Detail   = ("Turn instance size flexibility on - free, no redeploy. It only covers " +
                                    "$(Short-Sku $r.Sku) exactly, so $(@($other | ForEach-Object { Short-Sku $_ }) -join ', ') " +
                                    'in the same family cannot draw on it.')
                        Severity = 'medium'
                        ResourceId = $r.Id
                    }
                }
            }
            if ($canCompare -and "$($r.ScopeType)" -eq 'Single') {
                $inScope = 0.0
                foreach ($sc in @($r.Scopes)) {
                    $id = ("$sc" -split '/')[-1]
                    foreach ($k in $g.RunningSubs.Keys) {
                        if ("$k" -and "$id" -and "$k".ToLower() -eq "$id".ToLower()) { $inScope += [double]$g.RunningSubs[$k] }
                    }
                }
                if ($inScope -lt ($r.Units - $script:EPS)) {
                    $items += [pscustomobject]@{
                        Title    = "Reservation '$tag' is locked to one subscription"
                        Detail   = ("Switch it to Shared scope. It is locked to one subscription, and inside " +
                                    "that subscription there is only $(Format-Equiv $inScope $base) for it to " +
                                    "cover out of the $(Format-Equiv $r.Units $base) it holds - while " +
                                    "$(Format-Equiv $g.RunningUnits $base) runs across the family. The rest is " +
                                    'paid for and idle.')
                        Severity = 'high'
                        ResourceId = $r.Id
                    }
                }
            }
        }
    }

    # --- purchase candidates: ONE advisory item, stable title -----------------------------
    $ranked = @(@($Recommendations) | Where-Object { $_ } | ForEach-Object {
        $best = [math]::Max([double]$(if ($_.Savings1Y) { $_.Savings1Y } else { 0 }),
                            [double]$(if ($_.Savings3Y) { $_.Savings3Y } else { 0 }))
        $_ | Add-Member -NotePropertyName Best -NotePropertyValue $best -Force -PassThru
    } | Sort-Object -Property Best -Descending)
    $dead    = @($ranked | Where-Object { $null -ne $_.RegionActive -and -not $_.RegionActive })
    $live    = @($ranked | Where-Object { $null -eq $_.RegionActive -or $_.RegionActive })
    $trivial = @($live | Where-Object { $_.Best -lt $MinSavings })
    $worth   = @($live | Where-Object { $_.Best -ge $MinSavings })
    $shown   = @($worth | Select-Object -First $MaxBuys)

    # ⚠️ ONLY when there is something to buy (2026-08-18, user's call). This used to fire on
    # `$ranked.Count`, i.e. whenever Azure returned ANY recommendation — so a month where every
    # suggestion was filtered out as trivial or dead-region still produced a finding whose whole
    # text was "Azure suggested some purchases, but none are worth acting on. Not shown: 31 for
    # regions you no longer run anything in (...)". That is a finding that says there is no
    # finding: nothing to act on, counted against the tenant's score, and re-created on every
    # run. The drop counts still ride along with a real recommendation (the `Not shown:` tail),
    # and the always-emitted "source diagnostics" item records how many came back either way —
    # so suppressing this is not the silent-cap the check's own doctrine forbids.
    if ($shown.Count) {
        $lines = @()
        foreach ($rec in $shown) {
            $cur  = if ($rec.Currency) { "$($rec.Currency) " } else { '$' }
            $save = @()
            if ($rec.Savings1Y) { $save += "1yr ~$cur$([math]::Round([double]$rec.Savings1Y))" }
            if ($rec.Savings3Y) { $save += "3yr ~$cur$([math]::Round([double]$rec.Savings3Y))" }
            $line = "$($rec.Quantity) x $(Short-Sku $rec.Sku) in $($rec.Loc) ($($save -join ' / '), $($rec.Scope))"
            if ($rec.Savings3Y -and -not $rec.Savings1Y) { $line += ' [3yr only - check it is not temporary]' }
            $lines += $line
        }
        $why = @()
        $overflow = $worth.Count - $shown.Count
        if ($overflow -gt 0)      { $why += "$overflow that save less than these" }
        if ($trivial.Count -gt 0) {
            # Name the currency. "under 50" alone is a bare number the reader has to guess at,
            # and these lists mix currencies by subscription.
            $tcur = @($trivial | ForEach-Object { $_.Currency } | Where-Object { $_ } | Select-Object -First 1)
            $why += "$($trivial.Count) saving less than $(if ($tcur.Count) { "$($tcur[0]) " } else { '$' })$MinSavings over the term"
        }
        if ($dead.Count -gt 0) {
            $why += "$($dead.Count) for regions you no longer run anything in (" +
                    ((@($dead | ForEach-Object { $_.Loc } | Sort-Object -Unique) -join ', ')) + ')'
        }
        $head = 'Buy, best first: ' + ($lines -join '; ') + '.'
        $tail = if ($why.Count) { ' Not shown: ' + ($why -join ', ') + '.' } else { '' }
        $items += [pscustomobject]@{
            Title = 'Reservation purchase opportunities'; Detail = "$head$tail"
            Severity = 'medium'; ResourceId = $null
        }
    }

    return @($items)
}