Public/New-TenantLensReport.ps1

function New-TenantLensReport {
    <#
    .SYNOPSIS
        Renders findings and documentation into one self-contained offline HTML report.
    .DESCRIPTION
        No CDN links, no external assets - CSS/JS inline, charts as inline SVG.
        The report can be archived or mailed and opens anywhere. Accepts findings
        from the pipeline (Test-TenantLens | New-TenantLensReport) or evaluates
        the rule catalog itself when only -SnapshotPath is given.
    .EXAMPLE
        Test-TenantLens -SnapshotPath .\out\contoso_2026-07-23_1430 | New-TenantLensReport -Open
    #>

    [CmdletBinding()]
    param(
        [Parameter(ValueFromPipeline)]
        [object[]]$Finding,

        [string]$SnapshotPath,

        # Target file (defaults to report.html in the snapshot run folder).
        [string]$OutputPath,

        [string]$Title,

        # Open the report in the default browser after writing it.
        [switch]$Open
    )

    begin {
        $allFindings = [System.Collections.Generic.List[object]]::new()
    }
    process {
        foreach ($item in @($Finding)) {
            if ($null -ne $item) { $allFindings.Add($item) }
        }
    }
    end {
        if (-not $SnapshotPath -and $allFindings.Count -gt 0 -and $allFindings[0].PSObject.Properties['SnapshotPath']) {
            $SnapshotPath = [string]$allFindings[0].SnapshotPath
        }
        if (-not $SnapshotPath) { throw 'Provide -SnapshotPath or pipe findings from Test-TenantLens.' }

        $snapshot = Read-TLSnapshot -Path $SnapshotPath
        if ($allFindings.Count -eq 0) {
            foreach ($item in @(Test-TenantLens -SnapshotPath $SnapshotPath)) { $allFindings.Add($item) }
        }

        function Get-TLEncoded { param([object]$Text) [System.Net.WebUtility]::HtmlEncode([string]$Text) }

        $severityRank = @{ Critical = 0; High = 1; Medium = 2; Low = 3; Info = 4 }
        $statusRank = @{ Fail = 0; Error = 1; Manual = 2; Skipped = 3; Pass = 4 }
        $sorted = @($allFindings | Sort-Object -Property `
            @{ Expression = { $statusRank[[string]$_.Status] } },
            @{ Expression = { $severityRank[[string]$_.Severity] } },
            @{ Expression = { $_.RuleId } })

        # --- Score (pass/fail ratio; Manual/Skipped/Error excluded) ----------
        $passCount = @($sorted | Where-Object { $_.Status -eq 'Pass' }).Count
        $failCount = @($sorted | Where-Object { $_.Status -eq 'Fail' }).Count
        $evaluated = $passCount + $failCount
        $scoreValue = 'n/a'
        $scoreDash = 0
        $scoreClass = 'score-poor'
        if ($evaluated -gt 0) {
            $score = [Math]::Round(100.0 * (Get-TLScoreWeight -Findings $sorted))
            $scoreValue = "$score"
            $scoreDash = [Math]::Round(326.73 * $score / 100.0, 1)
            $scoreClass = $(if ($score -ge 85) { 'score-good' } elseif ($score -ge 60) { 'score-fair' } else { 'score-poor' })
        }
        $scoreCaption = "Score &middot; $passCount of $evaluated passed"

        # --- Summary tiles ----------------------------------------------------
        $tiles = [System.Text.StringBuilder]::new()
        foreach ($severity in @('Critical', 'High', 'Medium', 'Low', 'Info')) {
            $count = @($sorted | Where-Object { $_.Status -eq 'Fail' -and $_.Severity -eq $severity }).Count
            [void]$tiles.AppendLine(('<div class="tile"><div class="num {0}">{1}</div><div class="lbl">{2} findings</div></div>' -f `
                        $severity.ToLowerInvariant(), $count, $severity))
        }
        foreach ($status in @('Pass', 'Manual', 'Skipped')) {
            $count = @($sorted | Where-Object { $_.Status -eq $status }).Count
            [void]$tiles.AppendLine(('<div class="tile"><div class="num {0}">{1}</div><div class="lbl">{2}</div></div>' -f `
                        $status.ToLowerInvariant(), $count, $status))
        }

        # --- Area tiles -------------------------------------------------------
        $areaTiles = [System.Text.StringBuilder]::new()
        if ($snapshot.PSObject.Properties['_Areas'] -and $snapshot._Areas) {
            foreach ($areaProperty in $snapshot._Areas.PSObject.Properties) {
                $areaName = $areaProperty.Name
                if ($areaProperty.Value.Skipped) {
                    [void]$areaTiles.AppendLine(('<div class="area skipped"><b>{0}</b> &middot; skipped ({1})</div>' -f `
                                (Get-TLEncoded -Text $areaName), (Get-TLEncoded -Text $areaProperty.Value.SkipReason)))
                }
                else {
                    $areaFail = @($sorted | Where-Object { $_.Area -eq $areaName -and $_.Status -eq 'Fail' }).Count
                    $areaPass = @($sorted | Where-Object { $_.Area -eq $areaName -and $_.Status -eq 'Pass' }).Count
                    [void]$areaTiles.AppendLine(('<div class="area"><b>{0}</b> &middot; <span class="ok">{1} pass</span> / <span class="bad">{2} fail</span></div>' -f `
                                (Get-TLEncoded -Text $areaName), $areaPass, $areaFail))
                }
            }
        }

        # --- Findings rows ----------------------------------------------------
        $rows = [System.Text.StringBuilder]::new()
        foreach ($item in $sorted) {
            $sevClass = ([string]$item.Severity).ToLowerInvariant()
            $statusClass = ([string]$item.Status).ToLowerInvariant()
            $searchText = ("{0} {1} {2}" -f $item.RuleId, $item.Title, $item.Area).ToLowerInvariant()
            [void]$rows.AppendLine(('<tr class="f" data-status="{0}" data-severity="{1}" data-area="{2}" data-text="{3}">' -f `
                        $item.Status, $item.Severity, (Get-TLEncoded -Text $item.Area), (Get-TLEncoded -Text $searchText)))
            [void]$rows.AppendLine(('<td><span class="chev">&#9656;</span></td><td class="mono">{0}</td><td>{1}</td><td>{2}</td><td><span class="badge {3}">{4}</span></td><td><span class="badge {5}">{6}</span></td></tr>' -f `
                        (Get-TLEncoded -Text $item.RuleId), (Get-TLEncoded -Text $item.Title), (Get-TLEncoded -Text $item.Area),
                    $sevClass, $item.Severity, $statusClass, $item.Status))

            [void]$rows.AppendLine('<tr class="detail"><td colspan="6"><div class="detail-grid">')
            if ($item.Rationale) {
                [void]$rows.AppendLine(('<p><span class="k">Why:</span>{0}</p>' -f (Get-TLEncoded -Text $item.Rationale)))
            }
            if ($item.Remediation) {
                [void]$rows.AppendLine(('<p><span class="k">Remediation:</span>{0}</p>' -f (Get-TLEncoded -Text $item.Remediation)))
            }
            $evidenceItems = @($item.Evidence | Where-Object { $_ })
            if ($evidenceItems.Count -gt 0) {
                [void]$rows.AppendLine('<p class="k">Evidence:</p><ul>')
                foreach ($evidence in $evidenceItems) {
                    [void]$rows.AppendLine(('<li>{0}</li>' -f (Get-TLEncoded -Text $evidence)))
                }
                [void]$rows.AppendLine('</ul>')
            }
            $references = @($item.References | Where-Object { $_ })
            if ($references.Count -gt 0) {
                $links = @(foreach ($reference in $references) {
                        if ($reference -match '^https?://') {
                            '<a href="{0}" target="_blank" rel="noopener">{0}</a>' -f (Get-TLEncoded -Text $reference)
                        }
                        else { Get-TLEncoded -Text $reference }
                    })
                [void]$rows.AppendLine(('<p><span class="k">References:</span>{0}</p>' -f ($links -join ' &middot; ')))
            }
            [void]$rows.AppendLine('</div></td></tr>')
        }

        # --- Documentation + About tabs --------------------------------------
        $documentationHtml = ConvertTo-TLHtml -Markdown (Format-TLDocumentation -Snapshot $snapshot)

        $about = [System.Text.StringBuilder]::new()
        [void]$about.AppendLine('<h2>Coverage</h2><table><thead><tr><th>Area</th><th>Status</th><th>Notes</th></tr></thead><tbody>')
        if ($snapshot.PSObject.Properties['_Areas'] -and $snapshot._Areas) {
            foreach ($areaProperty in $snapshot._Areas.PSObject.Properties) {
                $statusText = $(if ($areaProperty.Value.Skipped) { 'skipped' } else { 'collected' })
                [void]$about.AppendLine(('<tr><td>{0}</td><td>{1}</td><td>{2}</td></tr>' -f `
                            (Get-TLEncoded -Text $areaProperty.Name), $statusText, (Get-TLEncoded -Text $areaProperty.Value.SkipReason)))
            }
        }
        [void]$about.AppendLine('</tbody></table>')
        $manifest = $snapshot._Manifest
        if ($manifest) {
            [void]$about.AppendLine('<h2>Snapshot</h2><table><tbody>')
            [void]$about.AppendLine(('<tr><th>Tool version</th><td>{0}</td></tr>' -f (Get-TLEncoded -Text $manifest.version)))
            [void]$about.AppendLine(('<tr><th>Generated (UTC)</th><td>{0}</td></tr>' -f (Get-TLEncoded -Text $manifest.generatedUtc)))
            if ($manifest.PSObject.Properties['tenant'] -and $manifest.tenant) {
                [void]$about.AppendLine(('<tr><th>Tenant id</th><td class="mono">{0}</td></tr>' -f (Get-TLEncoded -Text $manifest.tenant.id)))
                [void]$about.AppendLine(('<tr><th>Auth type</th><td>{0}</td></tr>' -f (Get-TLEncoded -Text $manifest.tenant.authType)))
            }
            [void]$about.AppendLine(('<tr><th>Redacted</th><td>{0}</td></tr>' -f ([bool]$manifest.redacted)))
            [void]$about.AppendLine(('<tr><th>Scopes</th><td class="mono">{0}</td></tr>' -f (Get-TLEncoded -Text (@($manifest.scopes) -join ', '))))
            [void]$about.AppendLine('</tbody></table>')
            if ($manifest.PSObject.Properties['files'] -and $manifest.files) {
                [void]$about.AppendLine('<h2>Integrity (SHA-256)</h2><table><thead><tr><th>File</th><th>SHA-256</th></tr></thead><tbody>')
                foreach ($file in @($manifest.files)) {
                    [void]$about.AppendLine(('<tr><td>{0}</td><td class="mono">{1}</td></tr>' -f `
                                (Get-TLEncoded -Text $file.name), (Get-TLEncoded -Text $file.sha256)))
                }
                [void]$about.AppendLine('</tbody></table>')
            }
        }

        # --- Assemble ---------------------------------------------------------
        $tenantLabel = 'tenant'
        $headerParts = [System.Collections.Generic.List[string]]::new()
        if ($manifest -and $manifest.PSObject.Properties['tenant'] -and $manifest.tenant) {
            $tenantLabel = [string]$manifest.tenant.label
            $headerParts.Add(('tenant id {0}' -f $manifest.tenant.id))
        }
        if ($manifest) { $headerParts.Add(('generated {0}' -f $manifest.generatedUtc)) }
        $headerParts.Add(('{0} rules evaluated' -f $sorted.Count))
        if (-not $Title) { $Title = "TenantLens report - $tenantLabel" }

        $templatePath = Join-Path -Path (Join-Path -Path $script:TLModuleRoot -ChildPath 'Templates') -ChildPath 'report.html'
        $html = Get-Content -Path $templatePath -Raw
        $html = $html.Replace('{{TITLE}}', (Get-TLEncoded -Text $Title))
        $html = $html.Replace('{{TENANT_LABEL}}', (Get-TLEncoded -Text $tenantLabel))
        $html = $html.Replace('{{HEADER_META}}', (($headerParts | ForEach-Object { '<span>{0}</span>' -f (Get-TLEncoded -Text $_) }) -join ''))
        $html = $html.Replace('{{SCORE_VALUE}}', $scoreValue)
        $html = $html.Replace('{{SCORE_DASH}}', "$scoreDash")
        $html = $html.Replace('{{SCORE_CLASS}}', $scoreClass)
        $html = $html.Replace('{{SCORE_CAPTION}}', $scoreCaption)
        $html = $html.Replace('{{SUMMARY_TILES}}', $tiles.ToString())
        $html = $html.Replace('{{AREA_TILES}}', $areaTiles.ToString())
        $html = $html.Replace('{{FINDINGS_ROWS}}', $rows.ToString())
        $html = $html.Replace('{{DOC_HTML}}', $documentationHtml)
        $html = $html.Replace('{{ABOUT_HTML}}', $about.ToString())
        $html = $html.Replace('{{TOOL_VERSION}}', (Get-TLEncoded -Text $script:TLVersion))

        if (-not $OutputPath) {
            $runFolder = $snapshot._Path
            if ((Split-Path -Path $runFolder -Leaf) -eq 'snapshot') { $runFolder = Split-Path -Path $runFolder -Parent }
            $OutputPath = Join-Path -Path $runFolder -ChildPath 'report.html'
        }
        Write-TLFile -Path $OutputPath -Content $html
        Write-Verbose ("Report written to '{0}'." -f $OutputPath)

        if ($Open) { Invoke-Item -Path $OutputPath }
        return Get-Item -Path $OutputPath
    }
}