Private/Model/ConvertTo-PpaNormalized.ps1

# ConvertTo-PpaNormalized.ps1 - the assemble stage of the pipeline (PLAN.md section 2).
# Takes the collected/analyzed pieces (meta, licensing, and per-section findings)
# and produces the single normalized object that both the HTML renderer
# and the JSON export consume. Counts and the at-a-glance headline are computed here,
# never hand-authored. ASCII-only source. Depends on PpaStatus.ps1.

Set-StrictMode -Off

function Get-PpaCanonicalSectionOrder {
    # THE canonical solution order (Wave 5 cleanup Part 5, Option B signed off).
    # Single source for the report body and - via the first-appearance grouping
    # below - the Solutions Summary; the coverage matrix column axis is defined in
    # Get-PpaCoverageModel and pinned to this sequence by the three-surface
    # guardrail test in Tests/Coverage.Tests.ps1. Section IDs only - titles are
    # never touched here.
    return @(
        'Sensitivity_Labels'
        'Data_Loss_Prevention'
        'DSPM_for_AI'
        'Retention'
        'Insider_Risk'
        'Communication_Compliance'
        'Audit'
        'eDiscovery'
    )
}

function ConvertTo-PpaNormalized {
    [CmdletBinding()]
    param(
        [Parameter(Mandatory = $true)] $Meta,
        [Parameter(Mandatory = $true)] $Licensing,
        [Parameter(Mandatory = $true)] $Sections,
        # Wave 4 Part D: the CoverageModel (pure projection); null renders no matrix.
        $Coverage = $null
    )

    $order    = Get-PpaStatusOrder
    $sections = @($Sections)

    # Wave 5 cleanup Part 5: canonical DISPLAY order, applied at assemble time only.
    # The orchestrator hands the analyze-order sections to New-PpaSnapshotModel
    # before this function runs, so snapshot content (findings order, sectionsRun)
    # never reorders - a pure display change stays out of delta mode. Sections with
    # ids outside the canonical list (error stubs, tests) keep arrival order after
    # the canonical ones; subsets (run profiles) keep canonical relative order.
    $canon   = @(Get-PpaCanonicalSectionOrder)
    $known   = New-Object System.Collections.Generic.List[object]
    $unknown = New-Object System.Collections.Generic.List[object]
    foreach ($id in $canon) {
        foreach ($sec in $sections) { if ([string]$sec.id -eq $id) { $known.Add($sec) } }
    }
    foreach ($sec in $sections) { if ($canon -notcontains [string]$sec.id) { $unknown.Add($sec) } }
    $sections = @($known.ToArray() + $unknown.ToArray())

    # Per-section: resolve glance (explicit status wins; otherwise the precedence
    # headline) and attach computed counts.
    $secOut = New-Object System.Collections.Generic.List[object]
    foreach ($sec in $sections) {
        $counts = Get-PpaSectionCounts $sec
        $g      = $sec.glance

        $glanceStatus = if ($g -and $g.status) { [string]$g.status } else { Get-PpaGlanceHeadline $sec }
        $glanceName   = if ($g -and $g.name)   { [string]$g.name }   else { [string]$sec.title }
        $glanceMetric = if ($g) { [string]$g.metric } else { '' }
        $glanceSub    = if ($g) { [string]$g.sub }    else { '' }

        $secObj = [ordered]@{
            id        = [string]$sec.id
            title     = [string]$sec.title
            group     = [string]$sec.group
            groupIcon = [string]$sec.groupIcon
        }
        if ($sec.groupTag) { $secObj.groupTag = [string]$sec.groupTag }
        $secObj.glance    = [pscustomobject][ordered]@{ status = $glanceStatus; name = $glanceName; metric = $glanceMetric; sub = $glanceSub }
        $secObj.counts    = [pscustomobject]$counts
        $secObj.findings  = @($sec.findings)

        $secOut.Add([pscustomobject]$secObj)
    }

    # Grouped summary (group order = first appearance) + all-solutions totals.
    $groupOrder = New-Object System.Collections.Generic.List[string]
    $groupMap   = @{}
    $groupMeta  = @{}
    $totals     = [ordered]@{ 'OK'=0; 'Improvement'=0; 'Recommendation'=0; 'Informational'=0; 'Verify manually'=0 }

    foreach ($sec in $secOut) {
        $gname = [string]$sec.group
        if (-not $groupMap.ContainsKey($gname)) {
            $groupMap[$gname]  = New-Object System.Collections.Generic.List[object]
            $groupMeta[$gname] = [pscustomobject]@{ icon = [string]$sec.groupIcon; tag = [string]$sec.groupTag }
            $groupOrder.Add($gname)
        }
        $groupMap[$gname].Add($sec)
        foreach ($st in $order) { $totals[$st] = [int]$totals[$st] + [int]$sec.counts.$st }
    }

    $groupsOut = New-Object System.Collections.Generic.List[object]
    foreach ($gname in $groupOrder) {
        $children = New-Object System.Collections.Generic.List[object]
        foreach ($sec in $groupMap[$gname]) {
            $children.Add([pscustomobject][ordered]@{ id = $sec.id; title = $sec.title; counts = $sec.counts })
        }
        $groupsOut.Add([pscustomobject][ordered]@{
            name     = $gname
            icon     = $groupMeta[$gname].icon
            tag      = $groupMeta[$gname].tag
            sections = $children.ToArray()
        })
    }

    return [pscustomobject][ordered]@{
        meta         = $Meta
        licensing    = $Licensing
        coverage     = $Coverage
        summary      = [pscustomobject][ordered]@{ totals = [pscustomobject]$totals; groups = $groupsOut.ToArray() }
        sections     = $secOut.ToArray()
    }
}