Modules/Private/Get-S2DHealthConfig.ps1

#Requires -Version 7.0
Set-StrictMode -Version Latest
$ErrorActionPreference = 'Stop'

function Get-S2DHealthConfig {
    <#
    .SYNOPSIS
        Returns the active health-check configuration (check definitions, weights, thresholds).
    .DESCRIPTION
        Returns the in-memory override config when Import-S2DHealthConfig has been called;
        otherwise loads and caches the default config/health-checks.json shipped with the module.
        Private helper — not exported.
    #>

    [CmdletBinding()]
    [OutputType([System.Collections.IDictionary])]
    param()

    # Return the in-memory override when set by Import-S2DHealthConfig
    if ($Script:S2DHealthConfig -and $Script:S2DHealthConfig.Count -gt 0) {
        return $Script:S2DHealthConfig
    }

    # Load and cache the default config shipped with the module
    if ($null -eq $Script:S2DHealthConfigDefault) {
        # Primary: use the loaded module's ModuleBase (most reliable, works after Install-Module)
        $modInfo    = Get-Module S2DCartographer -ErrorAction SilentlyContinue
        $moduleRoot = if ($modInfo) { $modInfo.ModuleBase } else { Split-Path -Parent (Split-Path -Parent $PSScriptRoot) }
        $configPath = Join-Path $moduleRoot 'config\health-checks.json'
        if (-not (Test-Path -Path $configPath -PathType Leaf)) {
            # Fallback: walk up from PSScriptRoot (Private -> Modules -> module root)
            $moduleRoot = Split-Path -Parent (Split-Path -Parent $PSScriptRoot)
            $configPath = Join-Path $moduleRoot 'config\health-checks.json'
        }
        if (Test-Path -Path $configPath -PathType Leaf) {
            $raw = Get-Content -Path $configPath -Raw | ConvertFrom-Json -Depth 20
            $Script:S2DHealthConfigDefault = ConvertTo-S2DHealthConfigHashtable -InputObject $raw
        } else {
            Write-Warning "S2DCartographer: health-checks.json not found at '$configPath'. Using empty config — all checks will score with default weight 1."
            $Script:S2DHealthConfigDefault = [ordered]@{
                version        = '1.0.0'
                scoreThresholds = [ordered]@{ excellent = 80; good = 60; fair = 40; needsImprovement = 0 }
                weighting       = [ordered]@{ warnFactor = 0.5 }
                checks          = @()
            }
        }
    }

    return $Script:S2DHealthConfigDefault
}

function ConvertTo-S2DHealthConfigHashtable {
    <#
    .SYNOPSIS
        Converts a PSCustomObject (from ConvertFrom-Json) to a nested ordered hashtable.
    .DESCRIPTION
        Private helper used by Get-S2DHealthConfig and Import-S2DHealthConfig to ensure
        the config is always an IDictionary (not a PSCustomObject) for consistent access.
    #>

    [CmdletBinding()]
    param(
        [Parameter(Mandatory = $true)]
        [AllowNull()]
        $InputObject
    )

    if ($null -eq $InputObject) { return $null }
    if ($InputObject -is [System.Collections.IDictionary]) { return $InputObject }

    if ($InputObject -is [System.Collections.IEnumerable] -and $InputObject -isnot [string]) {
        $list = New-Object System.Collections.Generic.List[object]
        foreach ($item in $InputObject) {
            $list.Add((ConvertTo-S2DHealthConfigHashtable -InputObject $item))
        }
        return $list.ToArray()
    }

    if ($InputObject -is [PSCustomObject]) {
        $ht = [ordered]@{}
        foreach ($prop in $InputObject.PSObject.Properties) {
            $ht[$prop.Name] = ConvertTo-S2DHealthConfigHashtable -InputObject $prop.Value
        }
        return $ht
    }

    return $InputObject
}

function Get-S2DCheckDefinition {
    <#
    .SYNOPSIS
        Returns the check definition hashtable for a given CheckName from the active config.
    .DESCRIPTION
        Private helper used by the scoring engine in Get-S2DHealthStatus. Returns $null
        when no matching definition is found (check will score with defaults: weight=1).
    #>

    [CmdletBinding()]
    param(
        [Parameter(Mandatory = $true)]
        [string]$CheckName
    )

    $config = Get-S2DHealthConfig
    $checks = $config['checks']
    if (-not $checks) { return $null }
    foreach ($c in $checks) {
        if ($c -is [System.Collections.IDictionary]) {
            if ($c['id'] -eq $CheckName) { return $c }
        } elseif ($c.PSObject) {
            if ($c.id -eq $CheckName) { return $c }
        }
    }
    return $null
}

function Invoke-S2DHealthCheckScoring {
    <#
    .SYNOPSIS
        Applies graduated scoring to an S2DHealthCheck using the active config definition.
    .DESCRIPTION
        Looks up the check definition by CheckName. Maps the check's Status to the matching
        threshold entry (Pass/Warn/Fail) and awards points accordingly. Populates the
        Weight, MaxPoints, AwardedPoints, ScoreBand, and ScorePercent fields on the check
        object in-place. Returns the same object for chaining.

        Backward compatibility: existing callers that only read CheckName/Severity/Status/
        Details/Remediation are unaffected — those fields are unchanged.
    #>

    [CmdletBinding()]
    param(
        [Parameter(Mandatory = $true)]
        [S2DHealthCheck]$Check
    )

    $def  = Get-S2DCheckDefinition -CheckName $Check.CheckName
    $config = Get-S2DHealthConfig

    # Resolve weight from definition, fall back to 1
    $weight = 1
    if ($def -and $null -ne $def['weight']) {
        $weight = [int]$def['weight']
    }
    $Check.Weight    = $weight
    $Check.MaxPoints = $weight

    # Map Status to threshold entry and award points
    $awarded = 0.0
    $band    = 'Needs Improvement'
    $warnFactor = 0.5
    if ($config['weighting'] -and $null -ne $config['weighting']['warnFactor']) {
        $warnFactor = [double]$config['weighting']['warnFactor']
    }

    if ($def -and $def['thresholds']) {
        $matched = $null
        foreach ($t in $def['thresholds']) {
            $tStatus = if ($t -is [System.Collections.IDictionary]) { $t['status'] } else { $t.status }
            if ($tStatus -eq $Check.Status) { $matched = $t; break }
        }
        if ($matched) {
            $pts  = if ($matched -is [System.Collections.IDictionary]) { $matched['points'] } else { $matched.points }
            $lbl  = if ($matched -is [System.Collections.IDictionary]) { $matched['label']  } else { $matched.label }
            $awarded = [double]$pts
            $band    = if ($lbl) { [string]$lbl } else { [string]$Check.Status }
        } else {
            # No matching threshold — use warnFactor for Warn, 0 for Fail, full for Pass
            $awarded = switch ($Check.Status) {
                'Pass' { [double]$weight }
                'Warn' { [double]$weight * $warnFactor }
                default { 0.0 }
            }
            $band = [string]$Check.Status
        }
    } else {
        # No definition found — apply default scoring
        $awarded = switch ($Check.Status) {
            'Pass' { [double]$weight }
            'Warn' { [double]$weight * $warnFactor }
            default { 0.0 }
        }
        $band = [string]$Check.Status
    }

    $Check.AwardedPoints = $awarded
    $Check.ScorePercent  = if ($weight -gt 0) { [math]::Round($awarded / $weight * 100, 1) } else { 0.0 }
    $Check.ScoreBand     = $band

    return $Check
}

function Invoke-S2DHealthScoreRollup {
    <#
    .SYNOPSIS
        Computes the weighted overall health score from a set of scored S2DHealthCheck objects.
    .DESCRIPTION
        Sums AwardedPoints and MaxPoints across all checks. Returns an ordered hashtable with:
          OverallScore — integer 0-100 (weighted percentage)
          ScoreStatus — Excellent | Good | Fair | Needs Improvement
          TotalAwarded — sum of weighted awarded points
          TotalMax — sum of weighted max points
          OverallHealth — legacy string: Healthy | Warning | Critical (unchanged)
        Private helper used by Get-S2DHealthStatus.
    #>

    [CmdletBinding()]
    param(
        [Parameter(Mandatory = $true)]
        [S2DHealthCheck[]]$Checks,

        [Parameter(Mandatory = $true)]
        [string]$OverallHealth
    )

    $config = Get-S2DHealthConfig
    $thresh = [ordered]@{ excellent = 80; good = 60; fair = 40; needsImprovement = 0 }
    if ($config['scoreThresholds']) {
        foreach ($k in @('excellent','good','fair','needsImprovement')) {
            if ($null -ne $config['scoreThresholds'][$k]) {
                $thresh[$k] = [int]$config['scoreThresholds'][$k]
            }
        }
    }

    $totalAwarded = 0.0
    $totalMax     = 0.0
    foreach ($c in $Checks) {
        $totalAwarded += [double]$c.AwardedPoints
        $totalMax     += [double]$c.MaxPoints
    }
    $score = if ($totalMax -gt 0) { [int][math]::Round($totalAwarded / $totalMax * 100) } else { 0 }

    $status = if ($score -ge $thresh.excellent)    { 'Excellent' }
              elseif ($score -ge $thresh.good)      { 'Good' }
              elseif ($score -ge $thresh.fair)      { 'Fair' }
              else                                   { 'Needs Improvement' }

    return [ordered]@{
        OverallScore   = $score
        ScoreStatus    = $status
        TotalAwarded   = [math]::Round($totalAwarded, 2)
        TotalMax       = [math]::Round($totalMax, 2)
        OverallHealth  = $OverallHealth
    }
}