Common/Export-FrameworkCatalog.ps1

function Export-FrameworkCatalog {
    <#
    .SYNOPSIS
        Produces framework-specific catalog output from assessment findings.
    .DESCRIPTION
        Dispatches on the framework's scoring method to parse controlId strings into
        structural groups and compute coverage/pass rates per group. Returns a uniform
        GroupedResult structure for Grouped mode.
    .PARAMETER Findings
        Array of finding objects from the assessment collectors.
    .PARAMETER Framework
        Framework hashtable from Import-FrameworkDefinitions (includes scoringMethod,
        scoringData, profiles, totalControls, etc.).
    .PARAMETER ControlRegistry
        Hashtable of checkId -> registry entry, used as fallback for framework mapping.
    .PARAMETER Mode
        Rendering mode: Inline (embed in report), Grouped (return data structure),
        or Standalone (full HTML page).
    .PARAMETER OutputPath
        Output file path for Standalone mode.
    .PARAMETER TenantName
        Tenant display name for Standalone mode headers.
    .OUTPUTS
        System.Collections.Hashtable
        GroupedResult with Groups array and Summary for Grouped mode.
        System.String placeholder for Inline and Standalone modes.
    #>

    [CmdletBinding()]
    param(
        [Parameter(Mandatory)][PSCustomObject[]]$Findings,
        [Parameter(Mandatory)][hashtable]$Framework,
        [Parameter(Mandatory)][hashtable]$ControlRegistry,
        [Parameter(Mandatory)][ValidateSet('Inline','Grouped','Standalone')][string]$Mode,
        [Parameter()][string]$OutputPath,
        [Parameter()][string]$TenantName
    )

    # --- Common: resolve framework mappings and score ---
    $scoredResult = Invoke-FrameworkScoring -Findings $Findings -Framework $Framework -ControlRegistry $ControlRegistry

    if ($Mode -eq 'Grouped') {
        return $scoredResult
    }

    if ($Mode -eq 'Inline') {
        return ConvertTo-CatalogInlineHtml -Framework $Framework -ScoredResult $scoredResult -MappedFindings $scoredResult.MappedFindings
    }

    # --- Standalone mode: write complete HTML file ---
    if (-not $OutputPath) {
        throw 'Standalone mode requires the -OutputPath parameter.'
    }
    if (-not $TenantName) {
        $TenantName = 'Unknown'
    }
    $standaloneContent = ConvertTo-CatalogStandaloneHtml -Framework $Framework -ScoredResult $scoredResult -MappedFindings $scoredResult.MappedFindings -TenantName $TenantName
    $standaloneContent | Set-Content -Path $OutputPath -Encoding UTF8 -Force
    return $OutputPath
}

# ---------------------------------------------------------------------------
# Private: run scoring engine and return GroupedResult + MappedFindings
# ---------------------------------------------------------------------------
function Invoke-FrameworkScoring {
    [CmdletBinding()]
    param(
        [PSCustomObject[]]$Findings,
        [hashtable]$Framework,
        [hashtable]$ControlRegistry
    )

    $fwId = $Framework.frameworkId
    $scoringMethod = $Framework.scoringMethod
    Write-Verbose "Export-FrameworkCatalog: Processing '$fwId' with scoring method '$scoringMethod'"

    # Resolve framework mapping for each finding
    $mappedFindings = [System.Collections.Generic.List[hashtable]]::new()
    foreach ($finding in $Findings) {
        $fwMapping = $null

        if ($finding.Frameworks -and $finding.Frameworks.PSObject.Properties.Name -contains $fwId) {
            $fwMapping = $finding.Frameworks.$fwId
        }
        elseif ($finding.Frameworks -is [hashtable] -and $finding.Frameworks.ContainsKey($fwId)) {
            $fwMapping = $finding.Frameworks[$fwId]
        }

        if (-not $fwMapping -and $finding.CheckId -and $ControlRegistry.ContainsKey($finding.CheckId)) {
            $regEntry = $ControlRegistry[$finding.CheckId]
            if ($regEntry.frameworks -and $regEntry.frameworks.PSObject.Properties.Name -contains $fwId) {
                $fwMapping = $regEntry.frameworks.$fwId
            }
            elseif ($regEntry.frameworks -is [hashtable] -and $regEntry.frameworks.ContainsKey($fwId)) {
                $fwMapping = $regEntry.frameworks[$fwId]
            }
        }

        if ($fwMapping) {
            $controlId = ''
            if ($fwMapping -is [hashtable] -and $fwMapping.ContainsKey('controlId')) {
                $controlId = [string]$fwMapping.controlId
            }
            elseif ($fwMapping.PSObject.Properties.Name -contains 'controlId') {
                $controlId = [string]$fwMapping.controlId
            }

            $profiles = @()
            if ($fwMapping -is [hashtable] -and $fwMapping.ContainsKey('profiles')) {
                $profiles = @($fwMapping.profiles)
            }
            elseif ($fwMapping.PSObject -and $fwMapping.PSObject.Properties.Name -contains 'profiles') {
                $profiles = @($fwMapping.profiles)
            }

            $mappedFindings.Add(@{
                Finding   = $finding
                ControlId = $controlId
                Profiles  = $profiles
            })
        }
    }

    Write-Verbose "Export-FrameworkCatalog: Mapped $($mappedFindings.Count) of $($Findings.Count) findings to '$fwId'"

    # Validate scoring method
    $validMethods = @(
        'profile-compliance', 'function-coverage', 'control-coverage',
        'technique-coverage', 'maturity-level', 'severity-coverage',
        'requirement-compliance', 'criteria-coverage', 'policy-compliance'
    )
    if (-not $scoringMethod -or $scoringMethod -notin $validMethods) {
        Write-Warning "Unknown scoring method '$scoringMethod' for framework '$fwId'; falling back to control-coverage."
        $scoringMethod = 'control-coverage'
    }

    # Dispatch to scoring handler
    $groups = switch ($scoringMethod) {
        'profile-compliance'      { Invoke-ProfileCompliance -Framework $Framework -MappedFindings $mappedFindings }
        'function-coverage'       { Invoke-FunctionCoverage -Framework $Framework -MappedFindings $mappedFindings }
        'control-coverage'        { Invoke-ControlCoverage -Framework $Framework -MappedFindings $mappedFindings }
        'technique-coverage'      { Invoke-TechniqueCoverage -Framework $Framework -MappedFindings $mappedFindings }
        'maturity-level'          { Invoke-MaturityLevel -Framework $Framework -MappedFindings $mappedFindings }
        'severity-coverage'       { Invoke-SeverityCoverage -Framework $Framework -MappedFindings $mappedFindings }
        'requirement-compliance'  { Invoke-RequirementCompliance -Framework $Framework -MappedFindings $mappedFindings }
        'criteria-coverage'       { Invoke-CriteriaCoverage -Framework $Framework -MappedFindings $mappedFindings }
        'policy-compliance'       { Invoke-PolicyCompliance -Framework $Framework -MappedFindings $mappedFindings }
    }

    # Sort groups by key for consistent display order
    # Scoring data key order maps: numeric (1,2,3), alpha-numeric (L1,L2,ML1,ML2), Roman (CAT-I,CAT-II)
    $groups = @($groups | Sort-Object -Property { Get-GroupSortKey -Key $_.Key })

    # Build summary
    $totalMapped = ($mappedFindings | ForEach-Object { $_.Finding.CheckId } | Select-Object -Unique).Count
    $totalPassed = ($mappedFindings | Where-Object { $_.Finding.Status -eq 'Pass' } |
        ForEach-Object { $_.Finding.CheckId } | Select-Object -Unique).Count
    $passRate = if ($totalMapped -gt 0) { [math]::Round($totalPassed / $totalMapped, 2) } else { 0 }
    # Deduplicate covered controls across all groups by unique framework controlId
    # (profiles overlap -- E5-L1 includes E3-L1 -- so summing per-group would double-count)
    $allCoveredIds = [System.Collections.Generic.HashSet[string]]::new()
    foreach ($mf in $mappedFindings) {
        if ($mf.ControlId) {
            foreach ($cid in ($mf.ControlId -split ';')) {
                [void]$allCoveredIds.Add($cid.Trim())
            }
        }
    }
    $totalCovered = $allCoveredIds.Count

    return @{
        Groups         = @($groups)
        Summary        = @{
            TotalControls  = [int]$Framework.totalControls
            MappedControls = $totalMapped
            CoveredControls = $totalCovered
            PassRate       = $passRate
        }
        MappedFindings = $mappedFindings
    }
}

# ---------------------------------------------------------------------------
# Private: render Inline HTML fragment for a single framework catalog
# ---------------------------------------------------------------------------
function ConvertTo-CatalogInlineHtml {
    [CmdletBinding()]
    param(
        [hashtable]$Framework,
        [hashtable]$ScoredResult,
        [System.Collections.Generic.List[hashtable]]$MappedFindings
    )

    $fwId = $Framework.frameworkId
    $fwLabel = $Framework.label
    $fwCss = if ($Framework.css) { $Framework.css } else { 'fw-default' }
    $summary = $ScoredResult.Summary
    $groups = $ScoredResult.Groups

    $html = [System.Text.StringBuilder]::new(4096)

    # Outer collapsible section
    $null = $html.AppendLine("<details class='section catalog-section' data-fw='$fwId'>")
    $null = $html.AppendLine("<summary><h3><span class='fw-tag $fwCss'>$fwLabel</span> Framework Catalog</h3></summary>")

    # Zero-mapped placeholder
    if ($summary.MappedControls -eq 0) {
        $null = $html.AppendLine("<p class='catalog-empty'>No assessed findings map to this framework.</p>")
        $null = $html.AppendLine("</details>")
        return $html.ToString()
    }

    # Overall summary bar
    $passRatePct = [math]::Round($summary.PassRate * 100, 1)
    $passClass = if ($passRatePct -ge 80) { 'success' } elseif ($passRatePct -ge 60) { 'warning' } else { 'danger' }
    $coveredCount = if ($summary.CoveredControls) { $summary.CoveredControls } else { $summary.MappedControls }
    $coveragePct = if ($summary.TotalControls -gt 0) { [math]::Min(100, [math]::Round(($coveredCount / $summary.TotalControls) * 100, 0)) } else { 0 }

    $null = $html.AppendLine("<div class='catalog-summary'>")
    $null = $html.AppendLine("<div class='catalog-stats'>")
    $null = $html.AppendLine("<span class='catalog-stat'><strong>Pass Rate:</strong> <span class='badge badge-$passClass'>$passRatePct%</span></span>")
    if ($summary.TotalControls -gt 0) {
        $null = $html.AppendLine("<span class='catalog-stat'><strong>Coverage:</strong> $coveredCount of $($summary.TotalControls) controls</span>")
    }
    $null = $html.AppendLine("<span class='catalog-stat'><strong>Findings:</strong> $($summary.MappedControls) assessed</span>")
    $null = $html.AppendLine("<span class='catalog-stat'><strong>Scoring:</strong> $($Framework.scoringMethod)</span>")
    $null = $html.AppendLine("</div>")
    if ($summary.TotalControls -gt 0) {
        $null = $html.AppendLine("<div class='coverage-bar'><div class='coverage-fill' style='width: $coveragePct%'></div></div>")
        $null = $html.AppendLine("<div class='coverage-label'>$coveragePct% coverage</div>")
    }
    $null = $html.AppendLine("</div>")

    # Group breakdown table
    $null = $html.AppendLine("<table class='catalog-groups'><thead><tr>")
    $null = $html.AppendLine("<th>Group</th><th>Label</th><th>Coverage</th><th>Findings</th><th>Passed</th><th>Failed</th><th>Other</th><th>Pass Rate</th>")
    $null = $html.AppendLine("</tr></thead><tbody>")

    foreach ($group in $groups) {
        $grpPassRate = if ($group.Mapped -gt 0) { [math]::Round(($group.Passed / $group.Mapped) * 100, 1) } else { 0 }
        $grpClass = if ($group.Mapped -eq 0) { '' } elseif ($grpPassRate -ge 80) { 'success' } elseif ($grpPassRate -ge 60) { 'warning' } else { 'danger' }

        $null = $html.AppendLine("<tr>")
        $null = $html.AppendLine("<td><span class='fw-tag $fwCss'>$($group.Key)</span></td>")
        $null = $html.AppendLine("<td>$($group.Label)</td>")
        $coverageDisplay = if ($group.Total -gt 0) { "$($group.Covered)/$($group.Total)" } else { "$($group.Covered)" }
        $null = $html.AppendLine("<td>$coverageDisplay</td>")
        $null = $html.AppendLine("<td>$($group.Mapped)</td>")
        $null = $html.AppendLine("<td>$($group.Passed)</td>")
        $null = $html.AppendLine("<td>$($group.Failed)</td>")
        $null = $html.AppendLine("<td>$($group.Other)</td>")
        $passDisplay = if ($group.Mapped -gt 0) { "$grpPassRate%" } else { '&mdash;' }
        $badgeCss = switch ($grpClass) { 'success' { 'badge-success' } 'warning' { 'badge-warning' } 'danger' { 'badge-failed' } default { 'badge-neutral' } }
        $null = $html.AppendLine("<td><span class='badge $badgeCss'>$passDisplay</span></td>")
        $null = $html.AppendLine("</tr>")
    }

    $null = $html.AppendLine("</tbody></table>")

    # Findings detail table (collapsible)
    $null = $html.AppendLine("<details class='catalog-findings-detail'>")
    $null = $html.AppendLine("<summary><strong>Detailed Findings ($($summary.MappedControls) mapped)</strong></summary>")
    $null = $html.AppendLine("<table class='cis-table catalog-findings'><thead><tr>")
    $null = $html.AppendLine("<th>Status</th><th>Check ID</th><th>Setting</th><th>Control ID</th><th>Severity</th>")
    $null = $html.AppendLine("</tr></thead><tbody>")

    foreach ($mf in $MappedFindings) {
        $finding = $mf.Finding
        $statusBadge = switch ($finding.Status) {
            'Pass'    { 'badge-success' }
            'Fail'    { 'badge-failed' }
            'Warning' { 'badge-warning' }
            'Review'  { 'badge-info' }
            'Info'    { 'badge-neutral' }
            default   { 'badge-neutral' }
        }
        $severityBadge = switch ($finding.RiskSeverity) {
            'Critical' { 'badge-critical' }
            'High'     { 'badge-failed' }
            'Medium'   { 'badge-warning' }
            'Low'      { 'badge-info' }
            default    { 'badge-neutral' }
        }
        $controlDisplay = $mf.ControlId -replace ';', '; '
        $rowClass = if ($finding.Status -eq 'Pass') { 'cis-row-pass' } elseif ($finding.Status -eq 'Fail') { 'cis-row-fail' } else { '' }

        $null = $html.AppendLine("<tr class='$rowClass'>")
        $null = $html.AppendLine("<td><span class='badge $statusBadge'>$($finding.Status)</span></td>")
        $null = $html.AppendLine("<td class='cis-id'>$($finding.CheckId)</td>")
        $null = $html.AppendLine("<td>$($finding.Setting)</td>")
        $null = $html.AppendLine("<td><span class='fw-tag $fwCss'>$controlDisplay</span></td>")
        $null = $html.AppendLine("<td><span class='badge $severityBadge'>$($finding.RiskSeverity)</span></td>")
        $null = $html.AppendLine("</tr>")
    }

    $null = $html.AppendLine("</tbody></table>")
    $null = $html.AppendLine("</details>")
    $null = $html.AppendLine("</details>")

    return $html.ToString()
}

# ---------------------------------------------------------------------------
# Private: render Standalone HTML document for a single framework catalog
# ---------------------------------------------------------------------------
function ConvertTo-CatalogStandaloneHtml {
    [CmdletBinding()]
    param(
        [hashtable]$Framework,
        [hashtable]$ScoredResult,
        [System.Collections.Generic.List[hashtable]]$MappedFindings,
        [string]$TenantName
    )

    $fwLabel = $Framework.label
    $fwCss = if ($Framework.css) { $Framework.css } else { 'fw-default' }
    $summary = $ScoredResult.Summary
    $groups = $ScoredResult.Groups
    $assessmentDate = Get-Date -Format 'yyyy-MM-dd HH:mm'

    # Get the inline body content (reuse the inline renderer's table logic)
    $passRatePct = [math]::Round($summary.PassRate * 100, 1)
    $passClass = if ($passRatePct -ge 80) { 'success' } elseif ($passRatePct -ge 60) { 'warning' } else { 'danger' }
    $coveredCount = if ($summary.CoveredControls) { $summary.CoveredControls } else { $summary.MappedControls }
    $coveragePct = if ($summary.TotalControls -gt 0) { [math]::Min(100, [math]::Round(($coveredCount / $summary.TotalControls) * 100, 0)) } else { 0 }

    $body = [System.Text.StringBuilder]::new(8192)

    # Cover / header section
    $null = $body.AppendLine("<div class='catalog-header'>")
    $null = $body.AppendLine("<h1><span class='fw-tag $fwCss' style='font-size: 0.9em; padding: 4px 12px;'>$fwLabel</span> Framework Catalog</h1>")
    $null = $body.AppendLine("<p class='catalog-meta'>Tenant: <strong>$TenantName</strong> &bull; Generated: $assessmentDate &bull; Scoring: $($Framework.scoringMethod)</p>")
    $null = $body.AppendLine("</div>")

    # Summary stats
    $null = $body.AppendLine("<div class='catalog-summary'>")
    $null = $body.AppendLine("<div class='catalog-stats'>")
    $null = $body.AppendLine("<span class='catalog-stat'><strong>Pass Rate:</strong> <span class='badge badge-$passClass'>$passRatePct%</span></span>")
    if ($summary.TotalControls -gt 0) {
        $null = $body.AppendLine("<span class='catalog-stat'><strong>Coverage:</strong> $coveredCount of $($summary.TotalControls) controls</span>")
    }
    $null = $body.AppendLine("<span class='catalog-stat'><strong>Findings:</strong> $($summary.MappedControls) assessed</span>")
    $null = $body.AppendLine("</div>")
    if ($summary.TotalControls -gt 0) {
        $null = $body.AppendLine("<div class='coverage-bar'><div class='coverage-fill' style='width: $coveragePct%'></div></div>")
        $null = $body.AppendLine("<div class='coverage-label'>$coveragePct% coverage</div>")
    }
    $null = $body.AppendLine("</div>")

    # Group breakdown table
    $null = $body.AppendLine("<h2>Group Breakdown</h2>")
    $null = $body.AppendLine("<table class='catalog-groups'><thead><tr>")
    $null = $body.AppendLine("<th>Group</th><th>Label</th><th>Coverage</th><th>Findings</th><th>Passed</th><th>Failed</th><th>Other</th><th>Pass Rate</th>")
    $null = $body.AppendLine("</tr></thead><tbody>")

    foreach ($group in $groups) {
        $grpPassRate = if ($group.Mapped -gt 0) { [math]::Round(($group.Passed / $group.Mapped) * 100, 1) } else { 0 }
        $grpClass = if ($group.Mapped -eq 0) { '' } elseif ($grpPassRate -ge 80) { 'success' } elseif ($grpPassRate -ge 60) { 'warning' } else { 'danger' }

        $null = $body.AppendLine("<tr>")
        $null = $body.AppendLine("<td><span class='fw-tag $fwCss'>$($group.Key)</span></td>")
        $null = $body.AppendLine("<td>$($group.Label)</td>")
        $coverageDisplay = if ($group.Total -gt 0) { "$($group.Covered)/$($group.Total)" } else { "$($group.Covered)" }
        $null = $body.AppendLine("<td>$coverageDisplay</td>")
        $null = $body.AppendLine("<td>$($group.Mapped)</td>")
        $null = $body.AppendLine("<td>$($group.Passed)</td>")
        $null = $body.AppendLine("<td>$($group.Failed)</td>")
        $null = $body.AppendLine("<td>$($group.Other)</td>")
        $passDisplay = if ($group.Mapped -gt 0) { "$grpPassRate%" } else { '&mdash;' }
        $badgeCss = switch ($grpClass) { 'success' { 'badge-success' } 'warning' { 'badge-warning' } 'danger' { 'badge-failed' } default { 'badge-neutral' } }
        $null = $body.AppendLine("<td><span class='badge $badgeCss'>$passDisplay</span></td>")
        $null = $body.AppendLine("</tr>")
    }
    $null = $body.AppendLine("</tbody></table>")

    # Findings detail table
    if ($MappedFindings.Count -gt 0) {
        $null = $body.AppendLine("<h2>Detailed Findings ($($summary.MappedControls) mapped)</h2>")
        $null = $body.AppendLine("<table class='cis-table catalog-findings'><thead><tr>")
        $null = $body.AppendLine("<th>Status</th><th>Check ID</th><th>Setting</th><th>Control ID</th><th>Severity</th>")
        $null = $body.AppendLine("</tr></thead><tbody>")

        foreach ($mf in $MappedFindings) {
            $finding = $mf.Finding
            $statusBadge = switch ($finding.Status) {
                'Pass'    { 'badge-success' }
                'Fail'    { 'badge-failed' }
                'Warning' { 'badge-warning' }
                'Review'  { 'badge-info' }
                'Info'    { 'badge-neutral' }
                default   { 'badge-neutral' }
            }
            $severityBadge = switch ($finding.RiskSeverity) {
                'Critical' { 'badge-critical' }
                'High'     { 'badge-failed' }
                'Medium'   { 'badge-warning' }
                'Low'      { 'badge-info' }
                default    { 'badge-neutral' }
            }
            $controlDisplay = $mf.ControlId -replace ';', '; '
            $rowClass = if ($finding.Status -eq 'Pass') { 'cis-row-pass' } elseif ($finding.Status -eq 'Fail') { 'cis-row-fail' } else { '' }

            $null = $body.AppendLine("<tr class='$rowClass'>")
            $null = $body.AppendLine("<td><span class='badge $statusBadge'>$($finding.Status)</span></td>")
            $null = $body.AppendLine("<td class='cis-id'>$($finding.CheckId)</td>")
            $null = $body.AppendLine("<td>$($finding.Setting)</td>")
            $null = $body.AppendLine("<td><span class='fw-tag $fwCss'>$controlDisplay</span></td>")
            $null = $body.AppendLine("<td><span class='badge $severityBadge'>$($finding.RiskSeverity)</span></td>")
            $null = $body.AppendLine("</tr>")
        }
        $null = $body.AppendLine("</tbody></table>")
    }
    else {
        $null = $body.AppendLine("<p class='catalog-empty'>No assessed findings map to this framework.</p>")
    }

    $bodyContent = $body.ToString()

    # Assemble full HTML document with embedded CSS
    return @"
<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>$fwLabel Catalog - $TenantName</title>
    <link rel="preconnect" href="https://fonts.googleapis.com">
    <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
    <link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;600;700&display=swap" rel="stylesheet">
    <style>
        :root {
            --m365a-primary: #2563EB;
            --m365a-dark-primary: #1D4ED8;
            --m365a-accent: #60A5FA;
            --m365a-dark: #0F172A;
            --m365a-dark-gray: #1E293B;
            --m365a-medium-gray: #64748B;
            --m365a-light-gray: #F1F5F9;
            --m365a-border: #CBD5E1;
            --m365a-white: #ffffff;
            --m365a-success: #2ecc71;
            --m365a-warning: #f39c12;
            --m365a-danger: #e74c3c;
            --m365a-info: #3498db;
            --m365a-success-bg: #d4edda;
            --m365a-warning-bg: #fff3cd;
            --m365a-danger-bg: #f8d7da;
            --m365a-info-bg: #d1ecf1;
            --m365a-neutral: #6b7280;
            --m365a-neutral-bg: #f3f4f6;
            --m365a-body-bg: #ffffff;
            --m365a-text: #1E293B;
            --m365a-card-bg: #ffffff;
            --m365a-hover-bg: #e8f4f8;
        }
        body.dark-theme {
            --m365a-primary: #60A5FA;
            --m365a-dark-primary: #93C5FD;
            --m365a-accent: #3B82F6;
            --m365a-dark: #F1F5F9;
            --m365a-dark-gray: #E2E8F0;
            --m365a-medium-gray: #94A3B8;
            --m365a-light-gray: #1E293B;
            --m365a-border: #334155;
            --m365a-white: #0F172A;
            --m365a-body-bg: #0F172A;
            --m365a-text: #E2E8F0;
            --m365a-card-bg: #1E293B;
            --m365a-hover-bg: #1E3A5F;
            --m365a-success: #34D399;
            --m365a-warning: #FBBF24;
            --m365a-danger: #F87171;
            --m365a-info: #60A5FA;
            --m365a-success-bg: #064E3B;
            --m365a-warning-bg: #78350F;
            --m365a-danger-bg: #7F1D1D;
            --m365a-info-bg: #1E3A5F;
            --m365a-neutral: #9ca3af;
            --m365a-neutral-bg: #374151;
        }
        * { margin: 0; padding: 0; box-sizing: border-box; }
        body {
            font-family: 'Inter', 'Segoe UI', Arial, sans-serif;
            font-size: 13pt;
            line-height: 1.5;
            color: var(--m365a-text);
            background: var(--m365a-body-bg);
            padding: 40px;
            max-width: 1200px;
            margin: 0 auto;
        }
        h1 { font-size: 1.8em; margin-bottom: 8px; color: var(--m365a-dark); }
        h2 { font-size: 1.3em; margin: 24px 0 12px; color: var(--m365a-dark); border-bottom: 2px solid var(--m365a-primary); padding-bottom: 6px; }
        table { width: 100%; border-collapse: collapse; margin-bottom: 20px; font-size: 10pt; }
        th { background: var(--m365a-dark); color: #fff; padding: 10px 12px; text-align: left; font-weight: 600; font-size: 9pt; }
        td { padding: 8px 12px; border-bottom: 1px solid var(--m365a-border); vertical-align: top; }
        tr:nth-child(even) { background: var(--m365a-light-gray); }
        tr:hover { background: var(--m365a-hover-bg); }
        .badge { display: inline-block; padding: 3px 10px; border-radius: 12px; font-size: 8.5pt; font-weight: 600; text-transform: uppercase; letter-spacing: 0.5px; }
        .badge-success { background: var(--m365a-success-bg); color: #155724; }
        .badge-failed { background: var(--m365a-danger-bg); color: #721c24; }
        .badge-warning { background: var(--m365a-warning-bg); color: #856404; }
        .badge-info { background: var(--m365a-info-bg); color: #0c5460; }
        .badge-neutral { background-color: var(--m365a-neutral-bg); color: var(--m365a-neutral); }
        .badge-critical { background: #991b1b; color: #fef2f2; }
        .fw-tag { display: inline-block; padding: 1px 5px; margin: 1px; border-radius: 3px; font-size: 0.72em; font-family: 'Consolas', 'Courier New', monospace; }
        .fw-cis { background: #e8f0fe; color: #1a56db; }
        .fw-cis-l2 { background: #dbeafe; color: #1e40af; }
        .fw-nist { background: #e8f0fe; color: #1a56db; }
        .fw-nist-high { background: #dbeafe; color: #1e40af; }
        .fw-nist-privacy { background: #ede9fe; color: #5b21b6; }
        .fw-csf { background: #fef3c7; color: #92400e; }
        .fw-iso { background: #ecfdf5; color: #065f46; }
        .fw-stig { background: #f3e8ff; color: #6b21a8; }
        .fw-pci { background: #fef2f2; color: #991b1b; }
        .fw-cmmc { background: #f0fdfa; color: #134e4a; }
        .fw-hipaa { background: #fdf2f8; color: #9d174d; }
        .fw-scuba { background: #fff7ed; color: #9a3412; }
        .fw-soc2 { background: #eff6ff; color: #1e3a5f; }
        .fw-fedramp { background: #fef3c7; color: #78350f; }
        .fw-essential8 { background: #ecfdf5; color: #14532d; }
        .fw-mitre { background: #fef2f2; color: #7f1d1d; }
        .fw-cisv8 { background: #e0f2fe; color: #0c4a6e; }
        .fw-default { background: #e2e8f0; color: #334155; }
        .cis-id { font-family: 'Consolas', 'Courier New', monospace; font-size: 0.9em; white-space: nowrap; }
        .cis-row-pass { border-left: 3px solid var(--m365a-success); background-color: var(--m365a-success-bg); }
        .cis-row-fail { border-left: 3px solid var(--m365a-danger); background-color: var(--m365a-danger-bg); }
        .cis-row-pass:nth-child(even), .cis-row-fail:nth-child(even) { background-image: linear-gradient(rgba(0,0,0,0.06), rgba(0,0,0,0.06)); }
        .coverage-bar { margin-top: 6px; background: var(--m365a-border); border-radius: 4px; height: 6px; overflow: hidden; }
        .coverage-fill { height: 100%; border-radius: 4px; transition: width 0.3s; }
        .catalog-summary .coverage-fill { background: var(--m365a-primary); }
        .coverage-label { font-size: 0.65em; color: var(--m365a-medium-gray); margin-top: 2px; }
        .catalog-header { margin-bottom: 24px; }
        .catalog-meta { font-size: 0.85em; color: var(--m365a-medium-gray); }
        .catalog-summary { margin-bottom: 20px; padding: 16px; background: var(--m365a-card-bg); border: 1px solid var(--m365a-border); border-radius: 6px; }
        .catalog-stats { display: flex; gap: 20px; flex-wrap: wrap; margin-bottom: 4px; }
        .catalog-stat { font-size: 0.9em; }
        .catalog-empty { color: var(--m365a-medium-gray); font-style: italic; padding: 20px; }
        .theme-toggle { position: fixed; top: 16px; right: 16px; background: var(--m365a-card-bg); border: 1px solid var(--m365a-border); border-radius: 50%; width: 36px; height: 36px; cursor: pointer; display: flex; align-items: center; justify-content: center; font-size: 18px; z-index: 100; }
        .theme-toggle:hover { transform: scale(1.1); }
        body:not(.dark-theme) .theme-icon-dark { display: none; }
        body.dark-theme .theme-icon-light { display: none; }
        body.dark-theme th { background: #1E3A5F; color: #E2E8F0; }
        body.dark-theme .badge-success { background: #065F46; color: #6EE7B7; }
        body.dark-theme .badge-failed { background: #7F1D1D; color: #FCA5A5; }
        body.dark-theme .badge-warning { background: #78350F; color: #FCD34D; }
        body.dark-theme .badge-info { background: #1E3A5F; color: #93C5FD; }
        body.dark-theme .badge-neutral { background-color: var(--m365a-neutral-bg); color: var(--m365a-neutral); }
        body.dark-theme .fw-cis { background: #1E3A5F; color: #93C5FD; }
        body.dark-theme .fw-cis-l2 { background: #1E3A5F; color: #60A5FA; }
        body.dark-theme .fw-nist { background: #1E3A5F; color: #93C5FD; }
        body.dark-theme .fw-nist-high { background: #1E3A5F; color: #60A5FA; }
        body.dark-theme .fw-nist-privacy { background: #2E1065; color: #C4B5FD; }
        body.dark-theme .fw-csf { background: #78350F; color: #FCD34D; }
        body.dark-theme .fw-iso { background: #064E3B; color: #6EE7B7; }
        body.dark-theme .fw-stig { background: #3B0764; color: #C4B5FD; }
        body.dark-theme .fw-pci { background: #7F1D1D; color: #FCA5A5; }
        body.dark-theme .fw-cmmc { background: #134E4A; color: #5EEAD4; }
        body.dark-theme .fw-hipaa { background: #831843; color: #F9A8D4; }
        body.dark-theme .fw-scuba { background: #7C2D12; color: #FDBA74; }
        body.dark-theme .fw-soc2 { background: #1E3A5F; color: #60A5FA; }
        body.dark-theme .fw-fedramp { background: #78350F; color: #FCD34D; }
        body.dark-theme .fw-essential8 { background: #064E3B; color: #6EE7B7; }
        body.dark-theme .fw-mitre { background: #7F1D1D; color: #FCA5A5; }
        body.dark-theme .fw-cisv8 { background: #164E63; color: #67E8F9; }
        body.dark-theme .fw-default { background: #334155; color: #94A3B8; }
        @media print { .theme-toggle { display: none; } body { padding: 20px; } }
    </style>
</head>
<body>
    <button class="theme-toggle" onclick="document.body.classList.toggle('dark-theme')" title="Toggle dark theme">
        <span class="theme-icon-light">&#9790;</span>
        <span class="theme-icon-dark">&#9788;</span>
    </button>
    $bodyContent
    <footer style="margin-top: 40px; padding-top: 16px; border-top: 1px solid var(--m365a-border); font-size: 0.75em; color: var(--m365a-medium-gray);">
        Generated by M365-Assess Framework Catalog Engine
    </footer>
</body>
</html>
"@

}

# ---------------------------------------------------------------------------
# Private helper: build a group hashtable from a bucket of findings
# ---------------------------------------------------------------------------
function New-ScoringGroup {
    [CmdletBinding()]
    param(
        [string]$Key,
        [string]$Label,
        [int]$Total,
        [System.Collections.Generic.List[PSCustomObject]]$GroupFindings,
        [int]$Covered = -1
    )

    $unique = @($GroupFindings | Select-Object -Property CheckId -Unique)
    $passed = @($GroupFindings | Where-Object { $_.Status -eq 'Pass' } |
        Select-Object -Property CheckId -Unique)
    $failed = @($GroupFindings | Where-Object { $_.Status -eq 'Fail' } |
        Select-Object -Property CheckId -Unique)
    $other = $unique.Count - $passed.Count - $failed.Count
    if ($other -lt 0) { $other = 0 }

    # Covered = unique framework controls with findings (if tracked by scorer)
    # Falls back to Mapped when scorer doesn't track coverage
    $coveredCount = if ($Covered -ge 0) { $Covered } else { $unique.Count }

    @{
        Key      = $Key
        Label    = $Label
        Total    = $Total
        Mapped   = $unique.Count
        Covered  = $coveredCount
        Passed   = $passed.Count
        Failed   = $failed.Count
        Other    = $other
        Findings = @($GroupFindings)
    }
}

# ---------------------------------------------------------------------------
# Private helper: resolve the scoring data sub-object by trying common keys
# ---------------------------------------------------------------------------
function Get-ScoringSubObject {
    [CmdletBinding()]
    param(
        [hashtable]$Framework,
        [string]$Key
    )

    $sd = $Framework.scoringData
    if (-not $sd) { return $null }

    # scoringData is a hashtable; try direct key lookup
    if ($sd -is [hashtable] -and $sd.ContainsKey($Key)) {
        $val = $sd[$Key]
    }
    elseif ($sd.PSObject -and $sd.PSObject.Properties.Name -contains $Key) {
        $val = $sd.$Key
    }
    else {
        return $null
    }

    # Convert PSCustomObject to hashtable for consistent .Keys usage
    if ($val -is [System.Management.Automation.PSCustomObject]) {
        $ht = @{}
        foreach ($prop in $val.PSObject.Properties) {
            $ht[$prop.Name] = $prop.Value
        }
        return $ht
    }
    return $val
}

# ---------------------------------------------------------------------------
# Private helper: generate a sortable key for group ordering
# Handles: numeric (1,2,3), alpha-numeric (L1,L2,ML1,GV,ID,PR), Roman (CAT-I,CAT-II)
# ---------------------------------------------------------------------------
function Get-GroupSortKey {
    [CmdletBinding()]
    param([string]$Key)

    # Roman numeral suffix (CAT-I, CAT-II, CAT-III)
    $romanMap = @{ 'I' = 1; 'II' = 2; 'III' = 3; 'IV' = 4; 'V' = 5 }
    if ($Key -match '-([IV]+)$') {
        $prefix = $Key -replace '-[IV]+$', ''
        $romanVal = if ($romanMap.ContainsKey($Matches[1])) { $romanMap[$Matches[1]] } else { 99 }
        return '{0}-{1:D3}' -f $prefix, $romanVal
    }

    # Alpha prefix + numeric suffix (L1, L2, ML1, ML2, IG1, IG2)
    if ($Key -match '^([A-Za-z]+)(\d+)$') {
        return '{0}{1:D3}' -f $Matches[1], [int]$Matches[2]
    }

    # Pure numeric (5, 6, 7, 8)
    if ($Key -match '^\d+$') {
        return '{0:D5}' -f [int]$Key
    }

    # CSF function order (canonical: GV=1, ID=2, PR=3, DE=4, RS=5, RC=6)
    $csfOrder = @{ 'GV' = 1; 'ID' = 2; 'PR' = 3; 'DE' = 4; 'RS' = 5; 'RC' = 6 }
    if ($csfOrder.ContainsKey($Key)) {
        return '{0:D3}' -f $csfOrder[$Key]
    }

    # Fallback: alphabetic
    return $Key
}

# ---------------------------------------------------------------------------
# 1. profile-compliance
# ---------------------------------------------------------------------------
function Invoke-ProfileCompliance {
    [CmdletBinding()]
    param([hashtable]$Framework, [System.Collections.Generic.List[hashtable]]$MappedFindings)

    $profileDefs = $Framework.profiles
    if (-not $profileDefs -or $profileDefs.Count -eq 0) {
        # Fallback: try scoringData.profiles
        $profileDefs = Get-ScoringSubObject -Framework $Framework -Key 'profiles'
    }
    if (-not $profileDefs -or $profileDefs.Count -eq 0) {
        return @(New-ScoringGroup -Key 'All' -Label 'All Controls' -Total ([int]$Framework.totalControls) -GroupFindings ([System.Collections.Generic.List[PSCustomObject]]::new()))
    }

    $groups = [System.Collections.Generic.List[hashtable]]::new()
    foreach ($profileKey in $profileDefs.Keys) {
        $profileInfo = $profileDefs[$profileKey]
        $label = if ($profileInfo -is [hashtable] -and $profileInfo.ContainsKey('label')) { $profileInfo.label } else { $profileKey }
        $controlCount = if ($profileInfo -is [hashtable] -and $profileInfo.ContainsKey('controlCount')) { [int]$profileInfo.controlCount } else { 0 }

        $bucket = [System.Collections.Generic.List[PSCustomObject]]::new()
        $coveredControlIds = [System.Collections.Generic.HashSet[string]]::new()
        foreach ($mf in $MappedFindings) {
            # If finding has profiles array, check membership; otherwise include in all profiles
            $inProfile = $false
            if ($mf.Profiles -and $mf.Profiles.Count -gt 0) {
                if ($profileKey -in $mf.Profiles) { $inProfile = $true }
            }
            else {
                $inProfile = $true
            }

            if ($inProfile) {
                $bucket.Add($mf.Finding)
                # Track unique framework controlIds (e.g. CIS "1.1.1") not CheckIds
                # to avoid inflating coverage when multiple checks map to same control
                if ($mf.ControlId) {
                    foreach ($cid in ($mf.ControlId -split ';')) {
                        [void]$coveredControlIds.Add($cid.Trim())
                    }
                }
            }
        }

        $groups.Add((New-ScoringGroup -Key $profileKey -Label $label -Total $controlCount -GroupFindings $bucket -Covered $coveredControlIds.Count))
    }
    return @($groups)
}

# ---------------------------------------------------------------------------
# 2. function-coverage (NIST CSF)
# ---------------------------------------------------------------------------
function Invoke-FunctionCoverage {
    [CmdletBinding()]
    param([hashtable]$Framework, [System.Collections.Generic.List[hashtable]]$MappedFindings)

    $functions = Get-ScoringSubObject -Framework $Framework -Key 'functions'
    if (-not $functions) {
        return @(New-ScoringGroup -Key 'All' -Label 'All Functions' -Total ([int]$Framework.totalControls) -GroupFindings ([System.Collections.Generic.List[PSCustomObject]]::new()))
    }

    # Build buckets keyed by function code + track unique controlIds per group
    $buckets = @{}
    $coveredIds = @{}
    foreach ($key in $functions.Keys) {
        $buckets[$key] = [System.Collections.Generic.List[PSCustomObject]]::new()
        $coveredIds[$key] = [System.Collections.Generic.HashSet[string]]::new()
    }

    foreach ($mf in $MappedFindings) {
        $parts = $mf.ControlId -split ';'
        foreach ($part in $parts) {
            $trimmed = $part.Trim()
            if ($trimmed -match '^([A-Z]{2})\.') {
                $funcKey = $Matches[1]
                if ($buckets.ContainsKey($funcKey)) {
                    $buckets[$funcKey].Add($mf.Finding)
                    [void]$coveredIds[$funcKey].Add($trimmed)
                }
            }
        }
    }

    $groups = [System.Collections.Generic.List[hashtable]]::new()
    foreach ($key in $functions.Keys) {
        $funcInfo = $functions[$key]
        $label = if ($funcInfo.label) { $funcInfo.label } else { $key }
        $total = if ($funcInfo.subcategories) { [int]$funcInfo.subcategories } else { 0 }
        $groups.Add((New-ScoringGroup -Key $key -Label $label -Total $total -GroupFindings $buckets[$key] -Covered $coveredIds[$key].Count))
    }
    return @($groups)
}

# ---------------------------------------------------------------------------
# 3. control-coverage (ISO 27001)
# ---------------------------------------------------------------------------
function Invoke-ControlCoverage {
    [CmdletBinding()]
    param([hashtable]$Framework, [System.Collections.Generic.List[hashtable]]$MappedFindings)

    $themes = Get-ScoringSubObject -Framework $Framework -Key 'themes'
    if (-not $themes) {
        # Generic fallback: single group
        $bucket = [System.Collections.Generic.List[PSCustomObject]]::new()
        foreach ($mf in $MappedFindings) { $bucket.Add($mf.Finding) }
        return @(New-ScoringGroup -Key 'All' -Label 'All Controls' -Total ([int]$Framework.totalControls) -GroupFindings $bucket)
    }

    $buckets = @{}
    $coveredIds = @{}
    foreach ($key in $themes.Keys) {
        $buckets[$key] = [System.Collections.Generic.List[PSCustomObject]]::new()
        $coveredIds[$key] = [System.Collections.Generic.HashSet[string]]::new()
    }

    foreach ($mf in $MappedFindings) {
        $parts = $mf.ControlId -split ';'
        foreach ($part in $parts) {
            $trimmed = $part.Trim()
            # Pattern: A.{clause}.{control} -- extract clause number at index 1
            $segments = $trimmed -split '\.'
            if ($segments.Count -ge 2) {
                $clauseKey = $segments[1]
                if ($buckets.ContainsKey($clauseKey)) {
                    $buckets[$clauseKey].Add($mf.Finding)
                    [void]$coveredIds[$clauseKey].Add($trimmed)
                }
            }
        }
    }

    $groups = [System.Collections.Generic.List[hashtable]]::new()
    foreach ($key in $themes.Keys) {
        $themeInfo = $themes[$key]
        $label = if ($themeInfo.label) { $themeInfo.label } else { $key }
        $total = if ($themeInfo.controlCount) { [int]$themeInfo.controlCount } else { 0 }
        $groups.Add((New-ScoringGroup -Key $key -Label $label -Total $total -GroupFindings $buckets[$key] -Covered $coveredIds[$key].Count))
    }
    return @($groups)
}

# ---------------------------------------------------------------------------
# 4. technique-coverage (MITRE ATT&CK)
# ---------------------------------------------------------------------------
function Invoke-TechniqueCoverage {
    [CmdletBinding()]
    param([hashtable]$Framework, [System.Collections.Generic.List[hashtable]]$MappedFindings)

    $tactics = Get-ScoringSubObject -Framework $Framework -Key 'tactics'

    # Load technique-to-tactic map
    $mapPath = Join-Path -Path $PSScriptRoot -ChildPath '../controls/mitre-technique-map.json'
    $techMap = @{}
    if (Test-Path -Path $mapPath) {
        $mapRaw = Get-Content -Path $mapPath -Raw | ConvertFrom-Json
        if ($mapRaw.map) {
            foreach ($prop in $mapRaw.map.PSObject.Properties) {
                $techMap[$prop.Name] = $prop.Value
            }
        }
    }
    else {
        Write-Warning "MITRE technique map not found at: $mapPath"
    }

    if (-not $tactics) {
        $bucket = [System.Collections.Generic.List[PSCustomObject]]::new()
        foreach ($mf in $MappedFindings) { $bucket.Add($mf.Finding) }
        return @(New-ScoringGroup -Key 'All' -Label 'All Techniques' -Total ([int]$Framework.totalControls) -GroupFindings $bucket)
    }

    $buckets = @{}
    $coveredIds = @{}
    foreach ($key in $tactics.Keys) {
        $buckets[$key] = [System.Collections.Generic.List[PSCustomObject]]::new()
        $coveredIds[$key] = [System.Collections.Generic.HashSet[string]]::new()
    }
    $buckets['Unmapped'] = [System.Collections.Generic.List[PSCustomObject]]::new()

    foreach ($mf in $MappedFindings) {
        $parts = $mf.ControlId -split ';'
        foreach ($part in $parts) {
            $trimmed = $part.Trim()
            if ($techMap.ContainsKey($trimmed)) {
                $tacticCode = $techMap[$trimmed]
                if ($buckets.ContainsKey($tacticCode)) {
                    $buckets[$tacticCode].Add($mf.Finding)
                    [void]$coveredIds[$tacticCode].Add($trimmed)
                }
                else {
                    $buckets['Unmapped'].Add($mf.Finding)
                }
            }
            else {
                $buckets['Unmapped'].Add($mf.Finding)
            }
        }
    }

    $groups = [System.Collections.Generic.List[hashtable]]::new()
    foreach ($key in $tactics.Keys) {
        $tacticInfo = $tactics[$key]
        $label = if ($tacticInfo.label) { $tacticInfo.label } else { $key }
        $groups.Add((New-ScoringGroup -Key $key -Label $label -Total 0 -GroupFindings $buckets[$key] -Covered $coveredIds[$key].Count))
    }

    # Add Unmapped group only if it has findings
    if ($buckets['Unmapped'].Count -gt 0) {
        $groups.Add((New-ScoringGroup -Key 'Unmapped' -Label 'Unmapped Techniques' -Total 0 -GroupFindings $buckets['Unmapped']))
    }

    return @($groups)
}

# ---------------------------------------------------------------------------
# 5. maturity-level (Essential Eight, CMMC)
# ---------------------------------------------------------------------------
function Invoke-MaturityLevel {
    [CmdletBinding()]
    param([hashtable]$Framework, [System.Collections.Generic.List[hashtable]]$MappedFindings)

    $levels = Get-ScoringSubObject -Framework $Framework -Key 'maturityLevels'
    if (-not $levels) {
        $bucket = [System.Collections.Generic.List[PSCustomObject]]::new()
        foreach ($mf in $MappedFindings) { $bucket.Add($mf.Finding) }
        return @(New-ScoringGroup -Key 'All' -Label 'All Levels' -Total ([int]$Framework.totalControls) -GroupFindings $bucket)
    }

    $fwId = $Framework.frameworkId
    $buckets = @{}
    $coveredIds = @{}
    foreach ($key in $levels.Keys) {
        $buckets[$key] = [System.Collections.Generic.List[PSCustomObject]]::new()
        $coveredIds[$key] = [System.Collections.Generic.HashSet[string]]::new()
    }

    if ($fwId -eq 'essential-eight') {
        foreach ($mf in $MappedFindings) {
            $parts = $mf.ControlId -split ';'
            foreach ($part in $parts) {
                $trimmed = $part.Trim()
                $levelKey = ($trimmed -split '-')[0]
                if ($buckets.ContainsKey($levelKey)) {
                    $buckets[$levelKey].Add($mf.Finding)
                    [void]$coveredIds[$levelKey].Add($trimmed)
                }
            }
        }
    }
    elseif ($fwId -eq 'cmmc') {
        # Cumulative: all findings count toward each level
        foreach ($key in $levels.Keys) {
            foreach ($mf in $MappedFindings) {
                $buckets[$key].Add($mf.Finding)
                $parts = $mf.ControlId -split ';'
                foreach ($part in $parts) { [void]$coveredIds[$key].Add($part.Trim()) }
            }
        }
    }
    else {
        foreach ($mf in $MappedFindings) {
            $parts = $mf.ControlId -split ';'
            foreach ($part in $parts) {
                $trimmed = $part.Trim()
                $levelKey = ($trimmed -split '-')[0]
                if ($buckets.ContainsKey($levelKey)) {
                    $buckets[$levelKey].Add($mf.Finding)
                    [void]$coveredIds[$levelKey].Add($trimmed)
                }
            }
        }
    }

    $groups = [System.Collections.Generic.List[hashtable]]::new()
    foreach ($key in $levels.Keys) {
        $levelInfo = $levels[$key]
        $label = if ($levelInfo.label) { $levelInfo.label } else { $key }
        $total = if ($levelInfo.practiceCount) { [int]$levelInfo.practiceCount } else { 0 }
        $groups.Add((New-ScoringGroup -Key $key -Label $label -Total $total -GroupFindings $buckets[$key] -Covered $coveredIds[$key].Count))
    }
    return @($groups)
}

# ---------------------------------------------------------------------------
# 6. severity-coverage (STIG)
# ---------------------------------------------------------------------------
function Invoke-SeverityCoverage {
    [CmdletBinding()]
    param([hashtable]$Framework, [System.Collections.Generic.List[hashtable]]$MappedFindings)

    $categories = Get-ScoringSubObject -Framework $Framework -Key 'categories'
    if (-not $categories -or $categories.Count -eq 0) {
        # Single "All" group
        $bucket = [System.Collections.Generic.List[PSCustomObject]]::new()
        foreach ($mf in $MappedFindings) { $bucket.Add($mf.Finding) }
        return @(New-ScoringGroup -Key 'All' -Label 'All Findings' -Total ([int]$Framework.totalControls) -GroupFindings $bucket)
    }

    # STIG V-numbers don't encode severity category, so distribute to all categories
    $buckets = @{}
    foreach ($key in $categories.Keys) {
        $buckets[$key] = [System.Collections.Generic.List[PSCustomObject]]::new()
        foreach ($mf in $MappedFindings) {
            $buckets[$key].Add($mf.Finding)
        }
    }

    $groups = [System.Collections.Generic.List[hashtable]]::new()
    foreach ($key in $categories.Keys) {
        $catInfo = $categories[$key]
        $label = if ($catInfo.label) { $catInfo.label } else { $key }
        $groups.Add((New-ScoringGroup -Key $key -Label $label -Total 0 -GroupFindings $buckets[$key]))
    }
    return @($groups)
}

# ---------------------------------------------------------------------------
# 7. requirement-compliance (PCI DSS)
# ---------------------------------------------------------------------------
function Invoke-RequirementCompliance {
    [CmdletBinding()]
    param([hashtable]$Framework, [System.Collections.Generic.List[hashtable]]$MappedFindings)

    $requirements = Get-ScoringSubObject -Framework $Framework -Key 'requirements'
    if (-not $requirements) {
        $bucket = [System.Collections.Generic.List[PSCustomObject]]::new()
        foreach ($mf in $MappedFindings) { $bucket.Add($mf.Finding) }
        return @(New-ScoringGroup -Key 'All' -Label 'All Requirements' -Total ([int]$Framework.totalControls) -GroupFindings $bucket)
    }

    $buckets = @{}
    $coveredIds = @{}
    foreach ($key in $requirements.Keys) {
        $buckets[$key] = [System.Collections.Generic.List[PSCustomObject]]::new()
        $coveredIds[$key] = [System.Collections.Generic.HashSet[string]]::new()
    }

    foreach ($mf in $MappedFindings) {
        $parts = $mf.ControlId -split ';'
        foreach ($part in $parts) {
            $trimmed = $part.Trim()
            $segments = $trimmed -split '\.'
            if ($segments.Count -ge 1) {
                $reqKey = $segments[0]
                if ($buckets.ContainsKey($reqKey)) {
                    $buckets[$reqKey].Add($mf.Finding)
                    [void]$coveredIds[$reqKey].Add($trimmed)
                }
            }
        }
    }

    $groups = [System.Collections.Generic.List[hashtable]]::new()
    foreach ($key in $requirements.Keys) {
        $reqInfo = $requirements[$key]
        $label = if ($reqInfo.label) { $reqInfo.label } else { "Requirement $key" }
        $groups.Add((New-ScoringGroup -Key $key -Label $label -Total 0 -GroupFindings $buckets[$key] -Covered $coveredIds[$key].Count))
    }
    return @($groups)
}

# ---------------------------------------------------------------------------
# 8. criteria-coverage (SOC 2, HIPAA)
# ---------------------------------------------------------------------------
function Invoke-CriteriaCoverage {
    [CmdletBinding()]
    param([hashtable]$Framework, [System.Collections.Generic.List[hashtable]]$MappedFindings)

    $criteria = Get-ScoringSubObject -Framework $Framework -Key 'criteria'
    if (-not $criteria) {
        $bucket = [System.Collections.Generic.List[PSCustomObject]]::new()
        foreach ($mf in $MappedFindings) { $bucket.Add($mf.Finding) }
        return @(New-ScoringGroup -Key 'All' -Label 'All Criteria' -Total ([int]$Framework.totalControls) -GroupFindings $bucket)
    }

    $fwId = $Framework.frameworkId
    $buckets = @{}
    $coveredIds = @{}
    foreach ($key in $criteria.Keys) {
        $buckets[$key] = [System.Collections.Generic.List[PSCustomObject]]::new()
        $coveredIds[$key] = [System.Collections.Generic.HashSet[string]]::new()
    }

    foreach ($mf in $MappedFindings) {
        $parts = $mf.ControlId -split ';'
        foreach ($part in $parts) {
            $trimmed = $part.Trim()

            if ($fwId -eq 'soc2') {
                if ($buckets.ContainsKey($trimmed)) {
                    $buckets[$trimmed].Add($mf.Finding)
                    [void]$coveredIds[$trimmed].Add($trimmed)
                }
                else {
                    foreach ($cKey in $criteria.Keys) {
                        if ($trimmed.StartsWith($cKey) -or $cKey.StartsWith($trimmed)) {
                            $buckets[$cKey].Add($mf.Finding)
                            [void]$coveredIds[$cKey].Add($trimmed)
                        }
                    }
                }
            }
            elseif ($fwId -eq 'hipaa') {
                $section = ($trimmed -split '\(')[0]
                if ($buckets.ContainsKey($section)) {
                    $buckets[$section].Add($mf.Finding)
                    [void]$coveredIds[$section].Add($trimmed)
                }
            }
            else {
                if ($buckets.ContainsKey($trimmed)) {
                    $buckets[$trimmed].Add($mf.Finding)
                    [void]$coveredIds[$trimmed].Add($trimmed)
                }
            }
        }
    }

    $groups = [System.Collections.Generic.List[hashtable]]::new()
    foreach ($key in $criteria.Keys) {
        $critInfo = $criteria[$key]
        $label = if ($critInfo.label) { $critInfo.label } else { $key }
        $groups.Add((New-ScoringGroup -Key $key -Label $label -Total 0 -GroupFindings $buckets[$key] -Covered $coveredIds[$key].Count))
    }
    return @($groups)
}

# ---------------------------------------------------------------------------
# 9. policy-compliance (CISA SCuBA)
# ---------------------------------------------------------------------------
function Invoke-PolicyCompliance {
    [CmdletBinding()]
    param([hashtable]$Framework, [System.Collections.Generic.List[hashtable]]$MappedFindings)

    $products = Get-ScoringSubObject -Framework $Framework -Key 'products'
    if (-not $products) {
        $bucket = [System.Collections.Generic.List[PSCustomObject]]::new()
        foreach ($mf in $MappedFindings) { $bucket.Add($mf.Finding) }
        return @(New-ScoringGroup -Key 'All' -Label 'All Products' -Total ([int]$Framework.totalControls) -GroupFindings $bucket)
    }

    $buckets = @{}
    $coveredIds = @{}
    foreach ($key in $products.Keys) {
        $buckets[$key] = [System.Collections.Generic.List[PSCustomObject]]::new()
        $coveredIds[$key] = [System.Collections.Generic.HashSet[string]]::new()
    }

    foreach ($mf in $MappedFindings) {
        $parts = $mf.ControlId -split ';'
        foreach ($part in $parts) {
            $trimmed = $part.Trim()
            $segments = $trimmed -split '\.'
            if ($segments.Count -ge 2) {
                $productKey = $segments[1]
                if ($buckets.ContainsKey($productKey)) {
                    $buckets[$productKey].Add($mf.Finding)
                    [void]$coveredIds[$productKey].Add($trimmed)
                }
            }
        }
    }

    $groups = [System.Collections.Generic.List[hashtable]]::new()
    foreach ($key in $products.Keys) {
        $prodInfo = $products[$key]
        $label = if ($prodInfo.label) { $prodInfo.label } else { $key }
        $groups.Add((New-ScoringGroup -Key $key -Label $label -Total 0 -GroupFindings $buckets[$key] -Covered $coveredIds[$key].Count))
    }
    return @($groups)
}