Private/ConvertTo-DhRagThresholds.ps1

function ConvertTo-DhRagThresholds {
    <#
    .SYNOPSIS
        Normalises a Rag hashtable into the legacy Thresholds array shape.

    .DESCRIPTION
        v1.4 introduces a unified -Rag parameter shape that maps cleanly onto the
        IT-infrastructure KPI dashboard specification's GREEN / AMBER / RED / GREY
        bands:

            $rag = @{
                Green = @{ Max = 70 } # cell-ok
                Amber = @{ Min = 70; Max = 90 } # cell-warn
                Red = @{ Min = 90 } # cell-danger
                NoData = $true # cell-nodata for null/missing
            }

        This helper converts that shape into the existing per-column /
        per-tile Thresholds array shape:

            @(
                @{ Max = 70; Class = 'cell-ok' }
                @{ Min = 70; Max = 90; Class = 'cell-warn' }
                @{ Min = 90; Class = 'cell-danger' }
            )

        So downstream code (Export-DhDashboard JSON emission, JS threshold
        matcher) stays unchanged — only the Add-* normalisers need to call this
        helper when a user supplies -Rag.

        Returns a PSCustomObject:
            Thresholds (object[]) — the converted threshold array (may be empty)
            NoData (bool) — whether to colour null/missing values
                                     with cell-nodata at render time

    .PARAMETER Rag
        A hashtable / ordered dict with any subset of these keys:
          Green, Amber, Red — each a hashtable with Min and/or Max numeric bounds
          NoData — boolean opt-in for cell-nodata on missing values

    .EXAMPLE
        $result = ConvertTo-DhRagThresholds -Rag @{
            Green = @{ Max = 70 }
            Amber = @{ Min = 70; Max = 90 }
            Red = @{ Min = 90 }
            NoData = $true
        }
        $result.Thresholds # → array of three Min/Max/Class hashtables
        $result.NoData # → $true
    #>

    [CmdletBinding()]
    [OutputType([PSCustomObject])]
    param(
        [Parameter(Mandatory)] [object] $Rag,

        # Caller passes its own label-of-thing-being-validated (column id, tile label, etc.)
        # so error messages are useful at the call site.
        [string] $Context = 'Rag'
    )

    if ($Rag -isnot [hashtable] -and $Rag -isnot [System.Collections.Specialized.OrderedDictionary]) {
        throw "${Context}: -Rag must be a hashtable. Got: $($Rag.GetType().Name)"
    }

    # Reject any unknown keys early so typos surface as errors
    $allowed = @('Green','Amber','Red','NoData')
    foreach ($k in $Rag.Keys) {
        if ($k -notin $allowed) {
            throw "${Context}: unknown -Rag key '$k'. Allowed: Green, Amber, Red, NoData."
        }
    }

    # Band → class binding
    $bandToClass = [ordered]@{
        Green = 'cell-ok'
        Amber = 'cell-warn'
        Red   = 'cell-danger'
    }

    $thresholds = @()
    foreach ($band in $bandToClass.Keys) {
        if (-not $Rag.Contains($band)) { continue }
        $spec = $Rag[$band]
        if ($spec -isnot [hashtable] -and $spec -isnot [System.Collections.Specialized.OrderedDictionary]) {
            throw "${Context}: -Rag.$band must be a hashtable (e.g. @{ Min=70; Max=90 }). Got: $($spec.GetType().Name)"
        }
        # Reject unknown band-level keys
        foreach ($bk in $spec.Keys) {
            if ($bk -notin @('Min','Max')) {
                throw "${Context}: -Rag.$band has unknown key '$bk'. Allowed: Min, Max."
            }
        }
        $row = @{ Class = $bandToClass[$band] }
        foreach ($bk in 'Min','Max') {
            if ($spec.Contains($bk) -and $null -ne $spec[$bk]) {
                $n = 0.0
                $ok = [double]::TryParse(
                    [string]$spec[$bk],
                    [System.Globalization.NumberStyles]::Float -bor [System.Globalization.NumberStyles]::AllowThousands,
                    [System.Globalization.CultureInfo]::InvariantCulture,
                    [ref]$n)
                if (-not $ok) {
                    throw "${Context}: -Rag.$band.$bk must be a number, got: $($spec[$bk])"
                }
                $row[$bk] = $n
            }
        }
        # A band with neither Min nor Max is a catch-all — still valid (acts as default colour)
        $thresholds += $row
    }

    $noData = $false
    if ($Rag.Contains('NoData') -and $null -ne $Rag['NoData']) {
        $noData = [bool]$Rag['NoData']
    }

    return [PSCustomObject]@{
        Thresholds = @($thresholds)
        NoData     = $noData
    }
}