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) }
        function Format-TLUtc {
            param([object]$Value)
            if ($Value -is [DateTime]) { return $Value.ToUniversalTime().ToString('yyyy-MM-dd HH:mm') + ' UTC' }
            return [string]$Value
        }

        $severities = @('Critical', 'High', 'Medium', 'Low', 'Info')
        $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 } })

        $failBySeverity = @{}
        foreach ($severity in $severities) {
            $failBySeverity[$severity] = @($sorted | Where-Object { $_.Status -eq 'Fail' -and $_.Severity -eq $severity }).Count
        }
        $statusCounts = @{}
        foreach ($statusName in @('Pass', 'Fail', 'Manual', 'Skipped', 'Error')) {
            $statusCounts[$statusName] = @($sorted | Where-Object { $_.Status -eq $statusName }).Count
        }
        $failCount = $statusCounts['Fail']
        $passCount = $statusCounts['Pass']
        $evaluated = $passCount + $failCount

        # --- score (severity-weighted) ----------------------------------------
        $scoreValue = 'n/a'
        $scoreDash = '0'
        $scoreClass = 'band-neutral'
        $scoreDot = 'dot-neutral'
        $scoreStatusLabel = 'No evaluated rules'
        if ($evaluated -gt 0) {
            $score = [Math]::Round(100.0 * (Get-TLScoreWeight -Findings $sorted))
            $scoreValue = "$score"
            $scoreDash = [string][Math]::Round(343.5 * $score / 100.0, 1)
            if ($score -ge 85) { $scoreClass = 'band-good'; $scoreDot = 'dot-good'; $scoreStatusLabel = 'Healthy' }
            elseif ($score -ge 60) { $scoreClass = 'band-warn'; $scoreDot = 'dot-warn'; $scoreStatusLabel = 'Needs attention' }
            elseif ($score -ge 40) { $scoreClass = 'band-serious'; $scoreDot = 'dot-serious'; $scoreStatusLabel = 'At risk' }
            else { $scoreClass = 'band-critical'; $scoreDot = 'dot-critical'; $scoreStatusLabel = 'Critical exposure' }
        }
        $scoreCaption = "Weighted score &middot; $passCount of $evaluated passed"

        # --- score trend from sibling runs of the same tenant -----------------
        $trendHtml = ''
        try {
            $currentRunFolder = $snapshot._Path
            if ((Split-Path -Path $currentRunFolder -Leaf) -eq 'snapshot') {
                $currentRunFolder = Split-Path -Path $currentRunFolder -Parent
            }
            $currentLeaf = Split-Path -Path $currentRunFolder -Leaf
            if ($scoreValue -ne 'n/a' -and $currentLeaf -match '^(?<label>.+)_\d{4}-\d{2}-\d{2}_\d{4}$') {
                $tenantPrefix = $Matches['label']
                $outputRoot = Split-Path -Path $currentRunFolder -Parent
                $siblingPattern = '^' + [regex]::Escape($tenantPrefix) + '_\d{4}-\d{2}-\d{2}_\d{4}$'
                $history = @(Get-ChildItem -Path $outputRoot -Directory |
                        Where-Object {
                            $_.Name -ne $currentLeaf -and $_.Name -match $siblingPattern -and
                            (Test-Path -Path (Join-Path -Path $_.FullName -ChildPath 'findings.json'))
                        } | Sort-Object -Property Name)
                $points = [System.Collections.Generic.List[object]]::new()
                foreach ($run in ($history | Select-Object -Last 11)) {
                    $historicScore = Get-TLFindingsScore -Payload (Get-Content -Path (Join-Path $run.FullName 'findings.json') -Raw | ConvertFrom-Json)
                    if ($null -ne $historicScore) {
                        $points.Add([pscustomobject]@{ Name = $run.Name; Score = [int]$historicScore })
                    }
                }
                if ($points.Count -gt 0) {
                    $previousRun = $points[$points.Count - 1]
                    $delta = [int]$scoreValue - $previousRun.Score
                    $deltaClass = $(if ($delta -gt 0) { 'up' } elseif ($delta -lt 0) { 'down' } else { 'flat' })
                    $deltaText = $(if ($delta -gt 0) { "+$delta" } elseif ($delta -eq 0) { '&plusmn;0' } else { "$delta" })
                    $previousStamp = $(if ($previousRun.Name -match '(\d{4}-\d{2}-\d{2})_\d{4}$') { $Matches[1] } else { 'previous run' })

                    $allScores = @($points | ForEach-Object { $_.Score }) + @([int]$scoreValue)
                    $stepWidth = $(if ($allScores.Count -gt 1) { 92.0 / ($allScores.Count - 1) } else { 92.0 })
                    $coordinates = for ($i = 0; $i -lt $allScores.Count; $i++) {
                        '{0},{1}' -f [Math]::Round(2 + $i * $stepWidth, 1), [Math]::Round(24 - ($allScores[$i] / 100.0 * 20), 1)
                    }
                    $lastPoint = ($coordinates[-1] -split ',')
                    $trendHtml = ('<div class="trend"><svg width="96" height="26" viewBox="0 0 96 26" aria-hidden="true"><polyline points="{0}"/><circle cx="{1}" cy="{2}" r="3"/></svg><span class="trend-delta {3}">{4} since {5}</span></div>' -f `
                        ($coordinates -join ' '), $lastPoint[0], $lastPoint[1], $deltaClass, $deltaText, (Get-TLEncoded -Text $previousStamp))
                }
            }
        }
        catch {
            Write-Verbose ("Score trend unavailable: {0}" -f $_.Exception.Message)
        }

        # --- severity tiles + meter -------------------------------------------
        $tiles = [System.Text.StringBuilder]::new()
        foreach ($severity in $severities) {
            $count = $failBySeverity[$severity]
            $zeroClass = $(if ($count -eq 0) { ' zero' } else { '' })
            [void]$tiles.AppendLine(('<div class="tile{0}"><div class="tile-top"><span class="dot sev-{1}"></span>{2}</div><div class="tile-value">{3}</div></div>' -f `
                        $zeroClass, $severity.ToLowerInvariant(), $severity, $count))
        }

        $meter = [System.Text.StringBuilder]::new()
        if ($failCount -eq 0) {
            [void]$meter.Append('<span class="seg s-good" style="width:100%"></span>')
        }
        else {
            foreach ($severity in $severities) {
                $count = $failBySeverity[$severity]
                if ($count -eq 0) { continue }
                $width = [Math]::Round(100.0 * $count / $failCount, 1)
                [void]$meter.Append(('<span class="seg s-{0}" style="width:{1}%"></span>' -f $severity.ToLowerInvariant(), $width))
            }
        }

        $chips = [System.Text.StringBuilder]::new()
        [void]$chips.Append(('<span class="chip"><span class="ic pass">&#10003;</span>{0} passed</span>' -f $statusCounts['Pass']))
        if ($statusCounts['Manual'] -gt 0) {
            [void]$chips.Append(('<span class="chip"><span class="ic manual">?</span>{0} manual review</span>' -f $statusCounts['Manual']))
        }
        if ($statusCounts['Skipped'] -gt 0) {
            [void]$chips.Append(('<span class="chip"><span class="ic skipped">&ndash;</span>{0} not collected</span>' -f $statusCounts['Skipped']))
        }
        if ($statusCounts['Error'] -gt 0) {
            [void]$chips.Append(('<span class="chip"><span class="ic error">!</span>{0} errors</span>' -f $statusCounts['Error']))
        }

        # --- area cards --------------------------------------------------------
        $areaCards = [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]$areaCards.AppendLine(('<div class="area-card skipped" title="{0}"><span class="area-name">{1}</span><span class="area-counts">not collected</span></div>' -f `
                                (Get-TLEncoded -Text $areaProperty.Value.SkipReason), (Get-TLEncoded -Text $areaName)))
                    continue
                }
                $areaPass = @($sorted | Where-Object { $_.Area -eq $areaName -and $_.Status -eq 'Pass' }).Count
                $areaFail = @($sorted | Where-Object { $_.Area -eq $areaName -and $_.Status -eq 'Fail' }).Count
                $meterHtml = ''
                $countsText = 'no rules evaluated'
                if (($areaPass + $areaFail) -gt 0) {
                    $meterHtml = ('<span class="am-pass" style="flex:{0}"></span><span class="am-fail" style="flex:{1}"></span>' -f $areaPass, $areaFail)
                    $countsText = "$areaPass pass &middot; $areaFail fail"
                }
                [void]$areaCards.AppendLine(('<button type="button" class="area-card" data-area="{0}"><span class="area-name">{0}</span><div class="area-meter">{1}</div><span class="area-counts">{2}</span></button>' -f `
                            (Get-TLEncoded -Text $areaName), $meterHtml, $countsText))
            }
        }

        # --- filter controls ---------------------------------------------------
        $statusFilters = [System.Text.StringBuilder]::new()
        [void]$statusFilters.Append(('<button data-v="" class="on">All<span class="cnt">{0}</span></button>' -f $sorted.Count))
        foreach ($statusName in @('Fail', 'Pass', 'Manual', 'Skipped', 'Error')) {
            if ($statusCounts[$statusName] -eq 0) { continue }
            [void]$statusFilters.Append(('<button data-v="{0}">{0}<span class="cnt">{1}</span></button>' -f $statusName, $statusCounts[$statusName]))
        }
        $severityFilters = [System.Text.StringBuilder]::new()
        [void]$severityFilters.Append('<button data-v="" class="on">Any severity</button>')
        foreach ($severity in $severities) {
            $count = @($sorted | Where-Object { $_.Severity -eq $severity }).Count
            if ($count -eq 0) { continue }
            [void]$severityFilters.Append(('<button data-v="{0}">{0}<span class="cnt">{1}</span></button>' -f $severity, $count))
        }

        # --- findings rows -----------------------------------------------------
        $statusIcons = @{ Pass = '&#10003;'; Fail = '&#10005;'; Manual = '?'; Skipped = '&ndash;'; Error = '!' }
        $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 class="c-chev"><span class="chev">&#9656;</span></td><td class="c-rule"><span class="rule-id">{0}</span></td><td class="c-title">{1}</td><td class="c-area"><span class="area-chip">{2}</span></td><td class="c-sev"><span class="badge"><span class="dot sev-{3}"></span>{4}</span></td><td class="c-status"><span class="badge st-{5}"><span class="b-ic">{6}</span>{7}</span></td></tr>' -f `
                        (Get-TLEncoded -Text $item.RuleId), (Get-TLEncoded -Text $item.Title), (Get-TLEncoded -Text $item.Area),
                    $sevClass, $item.Severity, $statusClass, $statusIcons[[string]$item.Status], $item.Status))

            [void]$rows.AppendLine('<tr class="detail"><td colspan="6"><div class="detail-body">')
            [void]$rows.AppendLine('<div>')
            if ($item.Rationale) {
                [void]$rows.AppendLine(('<h4>Why it matters</h4><p>{0}</p>' -f (Get-TLEncoded -Text $item.Rationale)))
            }
            if ($item.Remediation) {
                [void]$rows.AppendLine(('<h4>Remediation</h4><p>{0}</p>' -f (Get-TLEncoded -Text $item.Remediation)))
            }
            [void]$rows.AppendLine('</div><div>')
            $evidenceItems = @($item.Evidence | Where-Object { $_ })
            if ($evidenceItems.Count -gt 0) {
                [void]$rows.AppendLine('<h4>Evidence</h4><ul>')
                foreach ($evidence in $evidenceItems) {
                    [void]$rows.AppendLine(('<li>{0}</li>' -f (Get-TLEncoded -Text $evidence)))
                }
                [void]$rows.AppendLine('</ul>')
            }
            [void]$rows.AppendLine('</div>')
            $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(('<div class="d-refs">References: {0}</div>' -f ($links -join ' &middot; ')))
            }
            [void]$rows.AppendLine('</div></td></tr>')
        }

        # --- documentation + about --------------------------------------------
        $documentationHtml = ConvertTo-TLHtml -Markdown (Format-TLDocumentation -Snapshot $snapshot)

        $manifest = $snapshot._Manifest
        $about = [System.Text.StringBuilder]::new()

        [void]$about.AppendLine('<section class="card about-card"><h2>Snapshot</h2>')
        if ($manifest) {
            [void]$about.AppendLine('<dl>')
            [void]$about.AppendLine(('<dt>Tool version</dt><dd>{0}</dd>' -f (Get-TLEncoded -Text $manifest.version)))
            [void]$about.AppendLine(('<dt>Generated</dt><dd>{0}</dd>' -f (Get-TLEncoded -Text (Format-TLUtc -Value $manifest.generatedUtc))))
            if ($manifest.PSObject.Properties['tenant'] -and $manifest.tenant) {
                [void]$about.AppendLine(('<dt>Tenant id</dt><dd class="mono">{0}</dd>' -f (Get-TLEncoded -Text $manifest.tenant.id)))
                [void]$about.AppendLine(('<dt>Auth type</dt><dd>{0}</dd>' -f (Get-TLEncoded -Text $manifest.tenant.authType)))
            }
            [void]$about.AppendLine(('<dt>Redacted</dt><dd>{0}</dd>' -f ([bool]$manifest.redacted)))
            [void]$about.AppendLine('</dl>')
            [void]$about.AppendLine('<h2>Graph scopes used</h2><div class="scope-chips">')
            foreach ($scope in @($manifest.scopes)) {
                [void]$about.AppendLine(('<span>{0}</span>' -f (Get-TLEncoded -Text $scope)))
            }
            [void]$about.AppendLine('</div>')
        }
        else {
            [void]$about.AppendLine('<p>No manifest.json found in this snapshot.</p>')
        }
        [void]$about.AppendLine('</section>')

        [void]$about.AppendLine('<section class="card about-card"><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></section>')

        if ($manifest -and $manifest.PSObject.Properties['files'] -and $manifest.files) {
            [void]$about.AppendLine('<section class="card about-card about-wide"><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></section>')
        }

        # --- 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 {0}' -f $manifest.tenant.id))
        }
        if ($manifest) { $headerParts.Add((Format-TLUtc -Value $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_DOT}}', $scoreDot)
        $html = $html.Replace('{{SCORE_STATUS_LABEL}}', $scoreStatusLabel)
        $html = $html.Replace('{{SCORE_CAPTION}}', $scoreCaption)
        $html = $html.Replace('{{TREND_HTML}}', $trendHtml)
        $html = $html.Replace('{{SEVERITY_TILES}}', $tiles.ToString())
        $html = $html.Replace('{{SEVERITY_METER}}', $meter.ToString())
        $html = $html.Replace('{{STATUS_CHIPS}}', $chips.ToString())
        $html = $html.Replace('{{AREA_CARDS}}', $areaCards.ToString())
        $html = $html.Replace('{{STATUS_FILTERS}}', $statusFilters.ToString())
        $html = $html.Replace('{{SEVERITY_FILTERS}}', $severityFilters.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
    }
}