modules/shared/AuditorReportBuilder.ps1

#Requires -Version 7.4
<#
.SYNOPSIS
    Auditor-driven report builder (Track F / issue #434) - SKELETON ONLY.
 
.DESCRIPTION
    Track F redesigns azure-analyzer's report from a finding-centric view
    into a control-centric, auditor-grade view: audit-style executive
    summary, per-control-domain sections (CIS, NIST, MCSB, ISO 27001),
    "Ready to remediate" appendix grouped by Remediation, evidence export,
    and diff vs. previous run.
 
    THIS FILE IS A SKELETON. Every public function throws
    [System.NotImplementedException]. Implementation is held until the
    dependency tracks land:
 
      - Track A (#428) - attack paths
      - Track B (#429) - resilience / blast-radius
      - Track C (#431) - policy coverage vs. ALZ reference
      - Track D (#432) - tool-output fidelity (ComplianceMappings, Pillar,
                         Impact, Effort, RemediationSnippets, DeepLinkUrl)
      - Track E (#433 / #466 / #462) - LLM triage verdicts
      - Track V (#430 / #467) + foundation (#435) - tier picker and
                         report-manifest.json schema
 
    Function signatures here are FROZEN by the design doc at
    docs/design/track-f-auditor-redesign.md. A future implementation PR
    fills the bodies, drops the -Skip placeholders on the tests, and
    flips the wire-up in Invoke-AzureAnalyzer.ps1 (-Profile Auditor).
 
    Pester baseline is preserved by this skeleton (no callers, no tests
    other than the skip-placeholders that assert NotImplementedException).
 
.NOTES
    See docs/design/track-f-auditor-redesign.md for the full architecture,
    layout sketches, mock JSON shapes, tier matrix, and test strategy.
#>


Set-StrictMode -Version Latest
$ErrorActionPreference = 'Stop'

$script:AuditorReportBuilderVersion = '0.0.1-skeleton'

function Build-AuditorReport {
    [CmdletBinding()]
    param(
        [Parameter(Mandatory)] [string] $InputPath,
        [Parameter(Mandatory)] [string] $EntitiesPath,
        [Parameter(Mandatory)] [string] $ManifestPath,
        [string]   $TriagePath = '',
        [string]   $PreviousRunPath = '',
        [Parameter(Mandatory)] [string] $OutputDirectory,
        [ValidateSet('auditor')] [string] $Profile = 'auditor',
        [string[]] $ControlFrameworks = @('CIS','NIST','MCSB','ISO27001'),
        [ValidateSet('PureJson','EmbeddedSqlite','SidecarSqlite','PodeViewer')]
        [string]   $Tier,
        [ValidateSet('inline','footnote','workpaper')] [string] $CitationStyle = 'workpaper',
        [switch]   $PassThru
    )
    
    $sectionErrors = @()
    
    try {
        $context = Resolve-AuditorContext `
            -InputPath $InputPath `
            -EntitiesPath $EntitiesPath `
            -ManifestPath $ManifestPath `
            -TriagePath $TriagePath `
            -PreviousRunPath $PreviousRunPath `
            -Tier $Tier
    } catch {
        $sectionErrors += "Resolve-AuditorContext failed: $_"
        throw
    }
    
    try {
        $summary = Get-AuditorExecutiveSummary `
            -Findings $context.Findings `
            -PreviousFindings $context.PreviousFindings `
            -ControlFrameworks $ControlFrameworks
        $context['Summary'] = $summary
    } catch {
        $sectionErrors += "Get-AuditorExecutiveSummary failed: $_"
    }
    
    try {
        $controlSections = Get-AuditorControlDomainSections `
            -Findings $context.Findings `
            -Frameworks $ControlFrameworks
        $context['ControlDomainSections'] = $controlSections
    } catch {
        $sectionErrors += "Get-AuditorControlDomainSections failed: $_"
    }
    
    try {
        $attackPath = Get-AuditorAttackPathSection -Entities $context.Entities -Tier $context.Tier
        $context['AttackPathSection'] = $attackPath
    } catch {
        $sectionErrors += "Get-AuditorAttackPathSection failed: $_"
    }
    
    try {
        $resilience = Get-AuditorResilienceSection -Entities $context.Entities -Tier $context.Tier
        $context['ResilienceSection'] = $resilience
    } catch {
        $sectionErrors += "Get-AuditorResilienceSection failed: $_"
    }
    
    try {
        $policyCoverage = Get-AuditorPolicyCoverageSection -Entities $context.Entities -Tier $context.Tier
        $context['PolicyCoverageSection'] = $policyCoverage
    } catch {
        $sectionErrors += "Get-AuditorPolicyCoverageSection failed: $_"
    }
    
    try {
        $annotated = Get-AuditorTriageAnnotations `
            -Findings $context.Findings `
            -TriagePath $TriagePath
        $context['Findings'] = $annotated.AnnotatedFindings
        $context['TriagePresent'] = $annotated.TriagePresent
    } catch {
        $sectionErrors += "Get-AuditorTriageAnnotations failed: $_"
    }
    
    try {
        $remediation = Get-AuditorRemediationAppendix -Findings $context.Findings
        $context['RemediationAppendix'] = $remediation
    } catch {
        $sectionErrors += "Get-AuditorRemediationAppendix failed: $_"
    }
    
    $evidencePath = $null
    try {
        $evidence = Get-AuditorEvidenceExport `
            -Findings $context.Findings `
            -OutputDirectory $OutputDirectory
        $evidencePath = $evidence.ExportPath
    } catch {
        $sectionErrors += "Get-AuditorEvidenceExport failed: $_"
    }
    
    try {
        $null = Write-AuditorRenderTier `
            -Context $context `
            -OutputDirectory $OutputDirectory `
            -Tier $context.Tier
    } catch {
        $sectionErrors += "Write-AuditorRenderTier failed: $_"
    }
    
    $result = @{
        HtmlPath = Join-Path $OutputDirectory 'audit-report.html'
        MdPath = Join-Path $OutputDirectory 'audit-report.md'
        EvidencePath = $evidencePath
        Manifest = $context.Manifest
        SectionErrors = $sectionErrors
    }
    
    if ($PassThru) {
        return $result
    }
}

function Resolve-AuditorContext {
    [CmdletBinding()]
    param(
        [Parameter(Mandatory)] [string] $InputPath,
        [Parameter(Mandatory)] [string] $EntitiesPath,
        [Parameter(Mandatory)] [string] $ManifestPath,
        [string] $TriagePath = '',
        [string] $PreviousRunPath = '',
        [string] $Tier
    )
    
    $findings = Get-Content -Path $InputPath -Raw | ConvertFrom-Json
    $entities = Get-Content -Path $EntitiesPath -Raw | ConvertFrom-Json
    $manifest = Get-Content -Path $ManifestPath -Raw | ConvertFrom-Json
    
    $resolvedTier = if ($manifest.tier) { $manifest.tier } else { $Tier }
    
    $frameworks = if ($manifest.profile.auditor.frameworks) {
        $manifest.profile.auditor.frameworks
    } else {
        @('CIS', 'NIST', 'MCSB', 'ISO27001')
    }
    
    $context = @{
        Findings = $findings
        Entities = $entities
        Manifest = $manifest
        Tier = $resolvedTier
        Frameworks = $frameworks
    }
    
    if ($TriagePath -and (Test-Path $TriagePath)) {
        $context.TriageData = Get-Content -Path $TriagePath -Raw | ConvertFrom-Json
    }
    
    if ($PreviousRunPath -and (Test-Path $PreviousRunPath)) {
        $context.PreviousFindings = Get-Content -Path $PreviousRunPath -Raw | ConvertFrom-Json
    }
    
    return $context
}

function Get-AuditorExecutiveSummary {
    [CmdletBinding()]
    param(
        [Parameter(Mandatory)] [object[]] $Findings,
        [object[]] $PreviousFindings = @(),
        [string[]] $ControlFrameworks = @('CIS','NIST','MCSB','ISO27001')
    )
    
    $severityGroups = $Findings | Group-Object -Property Severity
    $severityCounts = @{}
    foreach ($group in $severityGroups) {
        $severityCounts[$group.Name] = $group.Count
    }
    
    $frameworkCoverage = @{}
    foreach ($framework in $ControlFrameworks) {
        $totalFindings = $Findings.Count
        $covered = 0
        
        foreach ($finding in $Findings) {
            if ($finding.ComplianceMappings) {
                foreach ($mapping in $finding.ComplianceMappings) {
                    if ($mapping -match "^$framework\s") {
                        $covered++
                        break
                    }
                }
            }
        }
        
        $pct = if ($totalFindings -gt 0) {
            [math]::Round(($covered / $totalFindings) * 100, 1)
        } else {
            0
        }
        
        $frameworkCoverage[$framework] = @{
            covered = $covered
            total = $totalFindings
            pct = $pct
        }
    }
    
    $summary = @{
        severityCounts = $severityCounts
        frameworkCoverage = $frameworkCoverage
        collectedAt = (Get-Date).ToUniversalTime().ToString('o')
        scope = 'Tenant scope placeholder'
    }
    
    if ($PreviousFindings.Count -gt 0) {
        $currentIds = $Findings | ForEach-Object { $_.FindingId } | Where-Object { $_ }
        $previousIds = $PreviousFindings | ForEach-Object { $_.FindingId } | Where-Object { $_ }
        
        $added = ($currentIds | Where-Object { $_ -notin $previousIds }).Count
        $resolved = ($previousIds | Where-Object { $_ -notin $currentIds }).Count
        
        $changedSeverity = 0
        $currentLookup = @{}
        foreach ($f in $Findings) {
            if ($f.FindingId) {
                $currentLookup[$f.FindingId] = $f.Severity
            }
        }
        
        foreach ($prevFinding in $PreviousFindings) {
            if ($prevFinding.FindingId -and $currentLookup.ContainsKey($prevFinding.FindingId)) {
                if ($prevFinding.Severity -ne $currentLookup[$prevFinding.FindingId]) {
                    $changedSeverity++
                }
            }
        }
        
        $summary.diffSummary = @{
            added = $added
            resolved = $resolved
            changedSeverity = $changedSeverity
        }
    }
    
    return $summary
}

function Get-AuditorControlDomainSections {
    [CmdletBinding()]
    param(
        [Parameter(Mandatory)] [object[]] $Findings,
        [string[]] $Frameworks = @('CIS','NIST','MCSB','ISO27001')
    )
    
    $sections = @()
    
    foreach ($framework in $Frameworks) {
        $controlGroups = @{}
        
        foreach ($finding in $Findings) {
            $mappings = if ($finding.PSObject.Properties['ComplianceMappings']) { $finding.ComplianceMappings } else { $null }
            if (-not $mappings) { continue }
            
            foreach ($mapping in $mappings) {
                if ([string]::IsNullOrWhiteSpace($mapping)) { continue }
                
                $mappingStr = [string]$mapping
                if ($mappingStr -match "^$framework\s+(.+)$") {
                    $controlId = $Matches[1].Trim()
                    
                    if (-not $controlGroups.ContainsKey($controlId)) {
                        $controlGroups[$controlId] = @()
                    }
                    $controlGroups[$controlId] += $finding
                }
            }
        }
        
        foreach ($controlId in ($controlGroups.Keys | Sort-Object)) {
            $findingsList = $controlGroups[$controlId]
            $sections += [PSCustomObject]@{
                Framework = $framework
                ControlId = $controlId
                FindingCount = $findingsList.Count
                Findings = @($findingsList)
            }
        }
    }
    
    return $sections
}

function ConvertTo-AuditorControlDomainSectionsHtml {
    [CmdletBinding()]
    param(
        [Parameter(Mandatory)] [object[]] $Sections
    )
    
    $html = New-Object System.Text.StringBuilder
    [void]$html.AppendLine('<div class="control-domain-sections">')
    
    $groupedByFramework = $Sections | Group-Object -Property Framework
    
    foreach ($fwGroup in $groupedByFramework) {
        [void]$html.AppendLine("<h3>$($fwGroup.Name)</h3>")
        [void]$html.AppendLine('<table class="control-domain-table">')
        [void]$html.AppendLine('<thead><tr><th>Control ID</th><th>Finding Count</th></tr></thead>')
        [void]$html.AppendLine('<tbody>')
        
        foreach ($section in ($fwGroup.Group | Sort-Object ControlId)) {
            $encodedControlId = ConvertTo-AuditorHtmlSafe $section.ControlId
            $encodedCount = ConvertTo-AuditorHtmlSafe $section.FindingCount
            [void]$html.AppendLine("<tr><td>$encodedControlId</td><td>$encodedCount</td></tr>")
        }
        
        [void]$html.AppendLine('</tbody></table>')
    }
    
    [void]$html.AppendLine('</div>')
    return $html.ToString()
}

function ConvertTo-AuditorControlDomainSectionsMd {
    [CmdletBinding()]
    param(
        [Parameter(Mandatory)] [object[]] $Sections
    )
    
    $md = New-Object System.Text.StringBuilder
    [void]$md.AppendLine('## Control Domain Sections')
    [void]$md.AppendLine()
    
    $groupedByFramework = $Sections | Group-Object -Property Framework
    
    foreach ($fwGroup in $groupedByFramework) {
        [void]$md.AppendLine("### $($fwGroup.Name)")
        [void]$md.AppendLine()
        [void]$md.AppendLine('| Control ID | Finding Count |')
        [void]$md.AppendLine('|------------|---------------|')
        
        foreach ($section in ($fwGroup.Group | Sort-Object ControlId)) {
            [void]$md.AppendLine("| $($section.ControlId) | $($section.FindingCount) |")
        }
        
        [void]$md.AppendLine()
    }
    
    return $md.ToString()
}

function Get-AuditorAttackPathSection {
    [CmdletBinding()]
    param(
        [Parameter(Mandatory)] [object] $Entities,
        [Parameter(Mandatory)] [string] $Tier
    )
    
    $attackPathEdges = @()
    $criticalPaths = 0
    
    if ($Entities.PSObject.Properties['edges']) {
        $attackPathRelations = @('HasFederatedCredential', 'AuthenticatesAs', 'UsesSecret', 'HasRoleOn', 'DeploysTo', 'TriggeredBy')
        
        foreach ($edge in $Entities.edges) {
            if ($null -eq $edge) { continue }
            $relation = if ($edge.PSObject.Properties['relation']) { [string]$edge.relation } else { '' }
            
            if ($relation -in $attackPathRelations) {
                $attackPathEdges += $edge
                
                $criticality = ''
                if ($edge.PSObject.Properties['metadata'] -and $edge.metadata.PSObject.Properties['attackPathCriticality']) {
                    $criticality = [string]$edge.metadata.attackPathCriticality
                }
                if ($criticality -ieq 'Critical') {
                    $criticalPaths++
                }
            }
        }
    }
    
    $renderingMode = switch ($Tier) {
        'PureJson' { 'inline' }
        'EmbeddedSqlite' { 'inline' }
        'SidecarSqlite' { 'paginated' }
        'PodeViewer' { 'deepLink' }
        default { 'inline' }
    }
    
    $result = @{
        RenderingMode = $renderingMode
        TotalPaths = $attackPathEdges.Count
        CriticalPaths = $criticalPaths
        HtmlSnippet = $null
        DeepLinkUrl = $null
    }
    
    if ($renderingMode -eq 'deepLink') {
        $result.DeepLinkUrl = '/viewer/attack-paths'
    } elseif ($renderingMode -eq 'inline' -and $attackPathEdges.Count -gt 0) {
        $result.HtmlSnippet = "<div class='attack-path-graph'><p>Cytoscape graph placeholder: $($attackPathEdges.Count) attack paths</p></div>"
    }
    
    return $result
}

function Get-AuditorResilienceSection {
    [CmdletBinding()]
    param(
        [Parameter(Mandatory)] [object] $Entities,
        [Parameter(Mandatory)] [string] $Tier
    )
    
    $resourcesWithScores = @()
    $totalEntities = 0
    
    foreach ($key in $Entities.PSObject.Properties.Name) {
        $entity = $Entities.$key
        if ($null -eq $entity) { continue }
        if ($key -eq 'edges' -or $key -eq 'policyGaps') { continue }
        
        $totalEntities++
        
        if ($entity.PSObject.Properties['properties'] -and 
            $entity.properties.PSObject.Properties['blastRadiusScore']) {
            $score = [double]$entity.properties.blastRadiusScore
            $displayName = if ($entity.PSObject.Properties['displayName']) { [string]$entity.displayName } else { $key }
            
            $resourcesWithScores += [PSCustomObject]@{
                EntityId = $key
                DisplayName = $displayName
                BlastRadiusScore = $score
            }
        }
    }
    
    $topResources = @($resourcesWithScores | Sort-Object -Property BlastRadiusScore -Descending | Select-Object -First 10)
    
    $renderingMode = switch ($Tier) {
        'PureJson' { 'inline' }
        'EmbeddedSqlite' { 'inline' }
        'SidecarSqlite' { 'paginated' }
        'PodeViewer' { 'deepLink' }
        default { 'inline' }
    }
    
    return @{
        RenderingMode = $renderingMode
        TopResources = $topResources
        TotalEntities = $totalEntities
    }
}

function Get-AuditorPolicyCoverageSection {
    [CmdletBinding()]
    param(
        [Parameter(Mandatory)] [object] $Entities,
        [Parameter(Mandatory)] [object[]] $Findings
    )
    
    $assignedCount = 0
    $missingCount = 0
    $gapSuggestions = @()
    $azAdvertizerLinks = @()
    
    if ($Entities.PSObject.Properties['policyGaps']) {
        $gaps = @($Entities.policyGaps)
        $missingCount = $gaps.Count
        
        foreach ($gap in $gaps) {
            if ($null -eq $gap) { continue }
            
            $policyId = if ($gap.PSObject.Properties['policyId']) { [string]$gap.policyId } else { '' }
            $displayName = if ($gap.PSObject.Properties['displayName']) { [string]$gap.displayName } else { 'Unknown Policy' }
            $scope = if ($gap.PSObject.Properties['scope']) { [string]$gap.scope } else { '' }
            
            if (-not [string]::IsNullOrWhiteSpace($policyId)) {
                $gapSuggestions += [PSCustomObject]@{
                    PolicyId = $policyId
                    DisplayName = $displayName
                    Scope = $scope
                }
                
                $azAdvertizerLinks += "https://www.azadvertizer.net/azpolicyadvertizer/$policyId.html"
            }
        }
    }
    
    $totalFindings = @($Findings).Count
    if ($totalFindings -gt 0 -and $missingCount -lt $totalFindings) {
        $assignedCount = $totalFindings - $missingCount
    }
    
    return @{
        AssignedCount = $assignedCount
        MissingCount = $missingCount
        GapSuggestions = @($gapSuggestions)
        AzAdvertizerLinks = @($azAdvertizerLinks)
    }
}

function Get-AuditorTriageAnnotations {
    [CmdletBinding()]
    param(
        [Parameter(Mandatory)] [object[]] $Findings,
        [string] $TriagePath = ''
    )
    
    if ([string]::IsNullOrWhiteSpace($TriagePath) -or -not (Test-Path $TriagePath)) {
        return @{
            AnnotatedFindings = @($Findings)
            TriagePresent = $false
        }
    }
    
    $triageData = Get-Content -Path $TriagePath -Raw | ConvertFrom-Json
    $triageByFindingId = @{}
    
    foreach ($verdict in $triageData) {
        if ($null -eq $verdict) { continue }
        $findingId = if ($verdict.PSObject.Properties['FindingId']) { [string]$verdict.FindingId } else { '' }
        if (-not [string]::IsNullOrWhiteSpace($findingId)) {
            $triageByFindingId[$findingId] = $verdict
        }
    }
    
    $annotatedFindings = @()
    foreach ($finding in $Findings) {
        if ($null -eq $finding) { continue }
        
        $findingId = if ($finding.PSObject.Properties['FindingId']) { [string]$finding.FindingId } else { '' }
        
        $annotated = [PSCustomObject]@{}
        foreach ($prop in $finding.PSObject.Properties) {
            $annotated | Add-Member -MemberType NoteProperty -Name $prop.Name -Value $prop.Value
        }
        
        if (-not [string]::IsNullOrWhiteSpace($findingId) -and $triageByFindingId.ContainsKey($findingId)) {
            $verdict = $triageByFindingId[$findingId]
            
            $verdictValue = if ($verdict.PSObject.Properties['Verdict']) { [string]$verdict.Verdict } else { $null }
            $rationaleValue = if ($verdict.PSObject.Properties['Rationale']) { [string]$verdict.Rationale } else { $null }
            
            $annotated | Add-Member -MemberType NoteProperty -Name 'Verdict' -Value $verdictValue -Force
            $annotated | Add-Member -MemberType NoteProperty -Name 'Rationale' -Value $rationaleValue -Force
            
            if ($verdict.PSObject.Properties['SuggestedSuppression']) {
                $annotated | Add-Member -MemberType NoteProperty -Name 'SuggestedSuppression' -Value ([string]$verdict.SuggestedSuppression) -Force
            }
        } else {
            $annotated | Add-Member -MemberType NoteProperty -Name 'Verdict' -Value $null -Force
            $annotated | Add-Member -MemberType NoteProperty -Name 'Rationale' -Value $null -Force
        }
        
        $annotatedFindings += $annotated
    }
    
    return @{
        AnnotatedFindings = @($annotatedFindings)
        TriagePresent = $true
    }
}

function Get-AuditorRemediationAppendix {
    [CmdletBinding()]
    param(
        [Parameter(Mandatory)] [object[]] $Findings
    )
    
    $sanitizePath = Join-Path (Split-Path $PSScriptRoot -Parent) 'shared' 'Sanitize.ps1'
    if (Test-Path $sanitizePath) { . $sanitizePath }
    
    $remediationGroups = @{}
    
    foreach ($finding in $Findings) {
        if ($null -eq $finding) { continue }
        
        $remediation = if ($finding.PSObject.Properties['Remediation']) { [string]$finding.Remediation } else { '' }
        
        if ([string]::IsNullOrWhiteSpace($remediation)) { continue }
        
        if (-not $remediationGroups.ContainsKey($remediation)) {
            $remediationGroups[$remediation] = @()
        }
        $remediationGroups[$remediation] += $finding
    }
    
    $severityWeights = @{
        'Critical' = 4
        'High' = 3
        'Medium' = 2
        'Low' = 1
        'Info' = 0
    }
    
    $groups = @()
    foreach ($key in $remediationGroups.Keys) {
        $groupFindings = $remediationGroups[$key]
        $maxWeight = 0
        $maxSeverity = 'Info'
        
        foreach ($f in $groupFindings) {
            $sev = if ($f.PSObject.Properties['Severity']) { [string]$f.Severity } else { 'Info' }
            $weight = if ($severityWeights.ContainsKey($sev)) { $severityWeights[$sev] } else { 0 }
            if ($weight -gt $maxWeight) {
                $maxWeight = $weight
                $maxSeverity = $sev
            }
        }
        
        $groups += [PSCustomObject]@{
            RemediationText = $key
            Findings = @($groupFindings)
            TotalCount = $groupFindings.Count
            MaxSeverity = $maxSeverity
            Weight = $maxWeight
        }
    }
    
    $sortedGroups = @($groups | Sort-Object -Property Weight -Descending)
    
    return @{
        RemediationGroups = $sortedGroups
    }
}

function Get-AuditorEvidenceExport {
    [CmdletBinding()]
    param(
        [Parameter(Mandatory)] [object[]] $Findings,
        [Parameter(Mandatory)] [string]   $OutputDirectory,
        [string[]] $Formats = @('csv','json')
    )
    
    $sanitizePath = Join-Path (Split-Path $PSScriptRoot -Parent) 'shared' 'Sanitize.ps1'
    if (Test-Path $sanitizePath) { . $sanitizePath }
    
    $evidenceDir = Join-Path $OutputDirectory 'audit-evidence'
    if (-not (Test-Path $evidenceDir)) {
        New-Item -Path $evidenceDir -ItemType Directory -Force | Out-Null
    }
    
    $sanitizedFindings = @()
    foreach ($finding in $Findings) {
        if ($null -eq $finding) { continue }
        
        $sanitizedFinding = [PSCustomObject]@{}
        foreach ($prop in $finding.PSObject.Properties) {
            $value = $prop.Value
            if ($value -is [string]) {
                $sanitizedFinding | Add-Member -MemberType NoteProperty -Name $prop.Name -Value (Remove-Credentials -Text $value)
            } else {
                $sanitizedFinding | Add-Member -MemberType NoteProperty -Name $prop.Name -Value $value
            }
        }
        $sanitizedFindings += $sanitizedFinding
    }
    
    $exportedFiles = @()
    
    $csvPath = Join-Path $evidenceDir 'findings.csv'
    $sanitizedFindings | Export-Csv -Path $csvPath -NoTypeInformation -Encoding UTF8
    $exportedFiles += $csvPath
    
    $jsonPath = Join-Path $evidenceDir 'findings.json'
    $sanitizedFindings | ConvertTo-Json -Depth 10 | Out-File -FilePath $jsonPath -Encoding UTF8
    $exportedFiles += $jsonPath
    
    $importExcelAvailable = $null -ne (Get-Module -ListAvailable -Name ImportExcel)
    if ($importExcelAvailable) {
        $xlsxPath = Join-Path $evidenceDir 'findings.xlsx'
        try {
            $sanitizedFindings | Export-Excel -Path $xlsxPath -AutoSize -TableName 'Findings' -WorksheetName 'Findings' -ErrorAction Stop
            $exportedFiles += $xlsxPath
        } catch {
            Write-Warning "ImportExcel module available but export failed: $_"
        }
    }
    
    return @{
        ExportedFiles = @($exportedFiles)
    }
}

function ConvertTo-AuditorHtmlSafe {
    [CmdletBinding()]
    param(
        [Parameter(ValueFromPipeline)] [string] $Text
    )
    
    process {
        if ([string]::IsNullOrEmpty($Text)) {
            return ''
        }
        
        # Manual HTML entity encoding (defense-in-depth)
        $Text -replace '&', '&amp;' `
              -replace '<', '&lt;' `
              -replace '>', '&gt;' `
              -replace '"', '&quot;' `
              -replace "'", '&#39;'
    }
}

function Write-AuditorRenderTier {
    [CmdletBinding()]
    param(
        [Parameter(Mandatory)] [hashtable] $Context,
        [Parameter(Mandatory)] [string]    $OutputDirectory,
        [Parameter(Mandatory)]
        [ValidateSet('PureJson','EmbeddedSqlite','SidecarSqlite','PodeViewer')]
        [string] $Tier
    )
    
    if (-not (Test-Path $OutputDirectory)) {
        New-Item -Path $OutputDirectory -ItemType Directory -Force | Out-Null
    }
    
    $tierNumber = switch ($Tier) {
        'PureJson' { 1 }
        'EmbeddedSqlite' { 2 }
        'SidecarSqlite' { 3 }
        'PodeViewer' { 4 }
        default { 1 }
    }
    
    $renderingMode = switch ($tierNumber) {
        1 { 'Tier1Full' }
        2 { 'Tier2Full' }
        3 { 'Tier3Headline' }
        4 { 'Tier4KPIs' }
        default { 'Tier1Full' }
    }
    
    $findings = if ($Context.ContainsKey('Findings')) { @($Context.Findings) } else { @() }
    $summary = if ($Context.ContainsKey('Summary')) { $Context.Summary } else { $null }
    
    $htmlPath = Join-Path $OutputDirectory 'audit-report.html'
    $mdPath = Join-Path $OutputDirectory 'audit-report.md'
    
    $htmlContent = @"
<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>Azure Analyzer Audit Report</title>
    <style>
        body { font-family: system-ui, -apple-system, sans-serif; margin: 2em; line-height: 1.6; }
        h1, h2, h3 { color: #0078d4; }
        table { border-collapse: collapse; width: 100%; margin: 1em 0; }
        th, td { border: 1px solid #ddd; padding: 8px; text-align: left; }
        th { background-color: #f4f4f4; }
        .critical { color: #d13438; font-weight: bold; }
        .high { color: #ff8c00; font-weight: bold; }
        .medium { color: #fcd116; font-weight: bold; }
        .low { color: #107c10; }
        .info { color: #0078d4; }
        .kpi-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); gap: 1em; margin: 1em 0; }
        .kpi-tile { border: 1px solid #ddd; padding: 1em; background: #f9f9f9; }
        .kpi-value { font-size: 2em; font-weight: bold; color: #0078d4; }
        @media print {
            body { margin: 1cm; }
            .no-print { display: none; }
            table { page-break-inside: avoid; }
            h1, h2, h3 { page-break-after: avoid; }
        }
    </style>
</head>
<body>
"@


    if ($tierNumber -le 2) {
        $htmlContent += @"
    <h1>Azure Analyzer Audit Report</h1>
    <h2>Executive Summary</h2>
    <p>Total findings: $($findings.Count)</p>
     
    <h2>Findings</h2>
    <table>
        <thead>
            <tr>
                <th>Finding ID</th>
                <th>Severity</th>
                <th>Title</th>
                <th>Entity ID</th>
            </tr>
        </thead>
        <tbody>
"@

        foreach ($f in $findings) {
            $sevClass = switch ($f.Severity) {
                'Critical' { 'critical' }
                'High' { 'high' }
                'Medium' { 'medium' }
                'Low' { 'low' }
                default { 'info' }
            }
            $htmlContent += @"
            <tr>
                <td>$(ConvertTo-AuditorHtmlSafe $f.FindingId)</td>
                <td class="$sevClass">$(ConvertTo-AuditorHtmlSafe $f.Severity)</td>
                <td>$(ConvertTo-AuditorHtmlSafe $f.Title)</td>
                <td>$(ConvertTo-AuditorHtmlSafe $f.EntityId)</td>
            </tr>
"@

        }
        $htmlContent += @"
        </tbody>
    </table>
"@

    } elseif ($tierNumber -eq 3) {
        $htmlContent += @"
    <h1>Azure Analyzer Audit Report - Executive View</h1>
    <h2>Key Findings Summary</h2>
    <p>Total findings: $($findings.Count)</p>
    <details>
        <summary>View detailed findings (click to expand)</summary>
        <ul>
"@

        foreach ($f in $findings) {
            $encodedId = ConvertTo-AuditorHtmlSafe $f.FindingId
            $encodedTitle = ConvertTo-AuditorHtmlSafe $f.Title
            $htmlContent += " <li><a href='#finding-$encodedId'>$encodedTitle</a></li>`n"
        }
        $htmlContent += @"
        </ul>
    </details>
"@

    } else {
        $htmlContent += @"
    <h1>Azure Analyzer Audit Report - KPI Dashboard</h1>
    <div class="kpi-grid">
        <div class="kpi-tile">
            <div class="kpi-value">$($findings.Count)</div>
            <div>Total Findings</div>
        </div>
        <div class="kpi-tile">
            <div class="kpi-value">$(($findings | Where-Object { $_.Severity -eq 'Critical' }).Count)</div>
            <div>Critical</div>
        </div>
        <div class="kpi-tile">
            <div class="kpi-value">$(($findings | Where-Object { $_.Severity -eq 'High' }).Count)</div>
            <div>High</div>
        </div>
    </div>
    <p><a href="/viewer/findings">View detailed findings</a></p>
"@

    }
    
    $htmlContent += @"
</body>
</html>
"@

    
    if (Get-Command Remove-Credentials -ErrorAction SilentlyContinue) {
        $htmlContent = Remove-Credentials -Text $htmlContent
    }
    Set-Content -Path $htmlPath -Value $htmlContent -Encoding UTF8 -NoNewline:$false
    
    $mdContent = @"
# Azure Analyzer Audit Report
 
## Summary
 
Total findings: $($findings.Count)
 
## Findings
 
"@

    
    foreach ($f in $findings) {
        $mdContent += "- **$($f.FindingId)** [$($f.Severity)] $($f.Title)`n"
    }
    
    if (Get-Command Remove-Credentials -ErrorAction SilentlyContinue) {
        $mdContent = Remove-Credentials -Text $mdContent
    }
    Set-Content -Path $mdPath -Value $mdContent -Encoding UTF8 -NoNewline:$false
    
    return @{
        HtmlPath = $htmlPath
        MdPath = $mdPath
        RenderingMode = $renderingMode
    }
}

function New-AuditorCitation {
    [CmdletBinding()]
    param(
        [Parameter(Mandatory)] [object] $Finding,
        [ValidateSet('inline','footnote','workpaper')] [string] $Style = 'workpaper'
    )
    
    $sanitizePath = Join-Path (Split-Path $PSScriptRoot -Parent) 'shared' 'Sanitize.ps1'
    if (Test-Path $sanitizePath) { . $sanitizePath }
    
    $segments = @()
    
    $source = if ($Finding.PSObject.Properties['Source']) { [string]$Finding.Source } else { '' }
    $rulePin = if ($Finding.PSObject.Properties['RulePin']) { [string]$Finding.RulePin } else { '' }
    
    if (-not [string]::IsNullOrWhiteSpace($source)) {
        if (-not [string]::IsNullOrWhiteSpace($rulePin)) {
            $segments += "[$source $rulePin]"
        } else {
            $segments += "[$source]"
        }
    }
    
    $id = if ($Finding.PSObject.Properties['Id']) { [string]$Finding.Id } else { '' }
    $title = if ($Finding.PSObject.Properties['Title']) { ([string]$Finding.Title).Replace("`n", ' ').Replace("`r", ' ') } else { '' }
    
    if (-not [string]::IsNullOrWhiteSpace($id) -and -not [string]::IsNullOrWhiteSpace($title)) {
        $segments += "$id`: $title."
    } elseif (-not [string]::IsNullOrWhiteSpace($id)) {
        $segments += "$id."
    } elseif (-not [string]::IsNullOrWhiteSpace($title)) {
        $segments += "$title."
    }
    
    $canonicalId = if ($Finding.PSObject.Properties['CanonicalId']) { [string]$Finding.CanonicalId } elseif ($Finding.PSObject.Properties['EntityId']) { [string]$Finding.EntityId } else { '' }
    if (-not [string]::IsNullOrWhiteSpace($canonicalId)) {
        $segments += "Resource: $canonicalId."
    }
    
    $severity = if ($Finding.PSObject.Properties['Severity']) { [string]$Finding.Severity } else { '' }
    if (-not [string]::IsNullOrWhiteSpace($severity)) {
        $segments += "Severity: $severity."
    }
    
    $collectedAt = if ($Finding.PSObject.Properties['CollectedAtUtc']) { [string]$Finding.CollectedAtUtc } elseif ($Finding.PSObject.Properties['CollectedAt']) { [string]$Finding.CollectedAt } else { '' }
    if (-not [string]::IsNullOrWhiteSpace($collectedAt)) {
        $segments += "Collected $collectedAt."
    }
    
    $ruleUrl = if ($Finding.PSObject.Properties['RuleUrl']) { [string]$Finding.RuleUrl } else { '' }
    if (-not [string]::IsNullOrWhiteSpace($ruleUrl)) {
        $segments += "Rule: $ruleUrl."
    }
    
    $docsUrl = if ($Finding.PSObject.Properties['DocsUrl']) { [string]$Finding.DocsUrl } else { '' }
    if (-not [string]::IsNullOrWhiteSpace($docsUrl)) {
        $segments += "Docs: $docsUrl."
    }
    
    $citation = [string]::Join(' ', @($segments | Where-Object { -not [string]::IsNullOrWhiteSpace($_) }))
    
    if (Get-Command Remove-Credentials -ErrorAction SilentlyContinue) {
        $citation = Remove-Credentials -Text $citation
    }
    
    return $citation
}