Private/Write-CwHtmlReport.ps1

function Write-CwHtmlReport {
    <#
    .SYNOPSIS
        Renders one or more snapshots into a self-contained HTML report string.
    .DESCRIPTION
        No CDN, no external assets: inline CSS, vanilla JS and hand-built SVG.
        Light/dark theme via prefers-color-scheme, validated status palette,
        icon+label severity badges (never color alone), hover tooltips on the
        expiry timeline, sortable/filterable tables with remaining-lifetime
        meters, print styles. Sections: summary tiles, 90-day expiry timeline,
        optional trend vs. baseline, per-server certificate tables (grouped by
        tenant), CRL and CA health panels.
    #>

    [CmdletBinding()]
    [OutputType([string])]
    param(
        [Parameter(Mandatory)]
        [object[]]$Snapshot,

        [object]$Trend,

        [string]$Title = 'cert-watchtower report'
    )

    function Encode([object]$Value) {
        [System.Net.WebUtility]::HtmlEncode([string]$Value)
    }

    function FormatDate([object]$Value) {
        if ($null -eq $Value -or $Value -eq '') { return '' }
        try { ([datetime]$Value).ToString('yyyy-MM-dd HH:mm') } catch { [string]$Value }
    }

    function Prop([object]$Object, [string]$Name) {
        # StrictMode-safe property access; older snapshot JSONs may lack fields
        if ($Object.PSObject.Properties[$Name]) { $Object.$Name } else { $null }
    }

    # 12px stroke icons (feather-style), inherit currentColor from the badge class
    $icons = @{
        OK       = "<svg viewBox='0 0 24 24' fill='none' stroke='currentColor' stroke-width='3' stroke-linecap='round' stroke-linejoin='round'><path d='M20 6L9 17l-5-5'/></svg>"
        WARNING  = "<svg viewBox='0 0 24 24' fill='none' stroke='currentColor' stroke-width='2.6' stroke-linecap='round' stroke-linejoin='round'><path d='M10.29 3.86L1.82 18a2 2 0 0 0 1.71 3h16.94a2 2 0 0 0 1.71-3L13.71 3.86a2 2 0 0 0-3.42 0z'/><line x1='12' y1='9' x2='12' y2='13'/><line x1='12' y1='17' x2='12.01' y2='17'/></svg>"
        CRITICAL = "<svg viewBox='0 0 24 24' fill='none' stroke='currentColor' stroke-width='2.6' stroke-linecap='round' stroke-linejoin='round'><circle cx='12' cy='12' r='10'/><line x1='15' y1='9' x2='9' y2='15'/><line x1='9' y1='9' x2='15' y2='15'/></svg>"
        UNKNOWN  = "<svg viewBox='0 0 24 24' fill='none' stroke='currentColor' stroke-width='2.6' stroke-linecap='round' stroke-linejoin='round'><circle cx='12' cy='12' r='10'/><path d='M9.09 9a3 3 0 0 1 5.83 1c0 2-3 3-3 3'/><line x1='12' y1='17' x2='12.01' y2='17'/></svg>"
    }

    function Badge([object]$Severity) {
        $sev = if ($Severity) { [string]$Severity } else { 'UNKNOWN' }
        if ($sev -notin 'OK', 'WARNING', 'CRITICAL', 'UNKNOWN') { $sev = 'UNKNOWN' }
        $cls = @{ OK = 'b-ok'; WARNING = 'b-warn'; CRITICAL = 'b-crit'; UNKNOWN = 'b-unk' }[$sev]
        "<span class='badge $cls'><i class='ic'>$($icons[$sev])</i>$sev</span>"
    }

    function Dot([object]$Severity) {
        $cls = @{ OK = 'd-ok'; WARNING = 'd-warn'; CRITICAL = 'd-crit'; UNKNOWN = 'd-unk' }[[string]$Severity]
        if (-not $cls) { $cls = 'd-unk' }
        "<i class='dot $cls'></i>"
    }

    $sevRank = @{ CRITICAL = 3; WARNING = 2; UNKNOWN = 1; OK = 0 }
    $now = Get-Date
    $inv = [cultureinfo]::InvariantCulture

    # ---------------------------------------------------------------- data ---
    $allCerts = foreach ($snap in $Snapshot) {
        foreach ($cert in @($snap.Certificates)) {
            [pscustomobject]@{
                Tenant        = if ((Prop $snap 'Tenant')) { $snap.Tenant } else { 'default' }
                Host          = $snap.Host
                Subject       = $cert.Subject
                Role          = $cert.Role
                NotAfter      = [datetime]$cert.NotAfter
                DaysRemaining = [double]$cert.DaysRemaining
                Severity      = if ($cert.Severity) { [string]$cert.Severity } else { 'OK' }
            }
        }
    }
    $allCerts = @($allCerts)

    $critCount = @($allCerts | Where-Object Severity -eq 'CRITICAL').Count
    $warnCount = @($allCerts | Where-Object Severity -eq 'WARNING').Count
    $next = $allCerts | Sort-Object DaysRemaining | Select-Object -First 1

    $allCrls = @($Snapshot | ForEach-Object { @($_.Crls) } | Where-Object { $_ })
    $crlUnhealthy = @($allCrls | Where-Object { $_.Severity -in 'WARNING', 'CRITICAL' }).Count
    $crlLight = if ($allCrls.Count -eq 0) { @('UNKNOWN', 'no CRL data') }
    elseif ($crlUnhealthy -eq 0) { @('OK', "$($allCrls.Count) CRLs fresh") }
    else { @('CRITICAL', "$crlUnhealthy of $($allCrls.Count) unhealthy") }

    $allCas = @($Snapshot | ForEach-Object { $_.CaHealth } | Where-Object { $_ -and $_.IsCa })
    $caUnhealthy = @($allCas | Where-Object { $_.Severity -in 'WARNING', 'CRITICAL' }).Count
    $caLight = if ($allCas.Count -eq 0) { @('UNKNOWN', 'no CA in scope') }
    elseif ($caUnhealthy -eq 0) { @('OK', "$($allCas.Count) CA(s) healthy") }
    else { @('CRITICAL', "$caUnhealthy of $($allCas.Count) unhealthy") }

    $score = if ($critCount -gt 0 -or $crlLight[0] -eq 'CRITICAL' -or $caLight[0] -eq 'CRITICAL') { 'CRITICAL' }
    elseif ($warnCount -gt 0 -or $crlLight[0] -eq 'WARNING' -or $caLight[0] -eq 'WARNING') { 'WARNING' } else { 'OK' }

    # ------------------------------------------------- SVG expiry timeline ---
    # Stacked weekly columns, CRITICAL anchored at the baseline. Mark specs:
    # <=24px columns, 4px rounded cap on the top segment, 2px surface gaps.
    $weeks = for ($w = 0; $w -lt 13; $w++) {
        $lo = $w * 7; $hi = $lo + 7
        $bucket = @($allCerts | Where-Object { $_.DaysRemaining -ge $lo -and $_.DaysRemaining -lt $hi })
        [pscustomobject]@{
            Start = $now.AddDays($lo)
            Crit  = @($bucket | Where-Object Severity -eq 'CRITICAL').Count
            Warn  = @($bucket | Where-Object Severity -eq 'WARNING').Count
            Ok    = @($bucket | Where-Object Severity -eq 'OK').Count
        }
    }
    $maxTotal = (@($weeks | ForEach-Object { $_.Crit + $_.Warn + $_.Ok }) | Measure-Object -Maximum).Maximum
    if (-not $maxTotal) { $maxTotal = 0 }

    $svgW = 780; $svgH = 216; $padL = 34; $padR = 8; $padT = 14; $padB = 26
    $innerH = $svgH - $padT - $padB
    $baseY = $svgH - $padB
    $bandW = ($svgW - $padL - $padR) / 13
    $barW = [math]::Min(24, [math]::Floor($bandW - 16))

    # nice y-scale: smallest nice step giving <= 4 gridlines
    $step = 1
    foreach ($candidate in 1, 2, 5, 10, 20, 25, 50, 100, 200, 500) {
        if ([math]::Ceiling(([math]::Max($maxTotal, 1)) / $candidate) -le 4) { $step = $candidate; break }
    }
    $niceMax = [math]::Max($step, [math]::Ceiling(([math]::Max($maxTotal, 1)) / $step) * $step)

    $svg = New-Object System.Text.StringBuilder
    for ($tick = 0; $tick -le $niceMax; $tick += $step) {
        $y = [math]::Round($baseY - ($tick / $niceMax) * $innerH, 1)
        $lineCls = if ($tick -eq 0) { 'axisline' } else { 'gridline' }
        [void]$svg.Append("<line x1='$padL' y1='$y' x2='$($svgW - $padR)' y2='$y' class='$lineCls'/>")
        [void]$svg.Append("<text x='$($padL - 7)' y='$($y + 3.5)' text-anchor='end' class='axis-t tnum'>$tick</text>")
    }

    for ($w = 0; $w -lt 13; $w++) {
        $x = [math]::Round($padL + $w * $bandW + ($bandW - $barW) / 2, 1)
        $centerX = [math]::Round($padL + $w * $bandW + $bandW / 2, 1)
        $weekLabel = $weeks[$w].Start.ToString('d MMM', $inv)
        if ($w % 2 -eq 0 -or $bandW -gt 52) {
            [void]$svg.Append("<text x='$centerX' y='$($svgH - 8)' text-anchor='middle' class='axis-t'>$weekLabel</text>")
        }

        $segments = [System.Collections.Generic.List[object]]::new()
        if ($weeks[$w].Crit -gt 0) { $segments.Add(@($weeks[$w].Crit, 'seg-crit')) }
        if ($weeks[$w].Warn -gt 0) { $segments.Add(@($weeks[$w].Warn, 'seg-warn')) }
        if ($weeks[$w].Ok -gt 0) { $segments.Add(@($weeks[$w].Ok, 'seg-ok')) }
        $yCursor = [double]$baseY
        for ($s = 0; $s -lt $segments.Count; $s++) {
            $count = $segments[$s][0]; $cls = $segments[$s][1]
            $segH = ($count / $niceMax) * $innerH
            $isTop = ($s -eq @($segments).Count - 1)
            $drawH = if ($isTop) { $segH } else { [math]::Max(2, $segH - 2) }   # 2px surface gap
            $yTop = $yCursor - $segH
            if ($isTop -and $drawH -ge 6) {
                # 4px rounded data-end, square at the baseline side
                $r = 4
                $x2 = $x + $barW
                [void]$svg.Append(("<path class='$cls' d='M{0},{1} L{0},{2} Q{0},{3} {4},{3} L{5},{3} Q{6},{3} {6},{2} L{6},{1} Z'/>" -f `
                        [math]::Round($x, 1), [math]::Round($yCursor, 1), [math]::Round($yTop + $r, 1), [math]::Round($yTop, 1),
                    [math]::Round($x + $r, 1), [math]::Round($x2 - $r, 1), [math]::Round($x2, 1)))
            }
            else {
                [void]$svg.Append("<rect class='$cls' x='$([math]::Round($x, 1))' y='$([math]::Round($yCursor - $segH, 1))' width='$barW' height='$([math]::Round($drawH, 1))'/>")
            }
            $yCursor -= $segH
        }

        $total = $weeks[$w].Crit + $weeks[$w].Warn + $weeks[$w].Ok
        if ($total -gt 0) {
            [void]$svg.Append("<text x='$centerX' y='$([math]::Round($yCursor - 6, 1))' text-anchor='middle' class='bar-t tnum'>$total</text>")
        }

        # transparent full-band hit target for the hover tooltip
        $rangeLabel = "$($weeks[$w].Start.ToString('d MMM', $inv)) &ndash; $($weeks[$w].Start.AddDays(6).ToString('d MMM', $inv))"
        [void]$svg.Append("<rect class='hit' x='$([math]::Round($padL + $w * $bandW, 1))' y='$padT' width='$([math]::Round($bandW, 1))' height='$($innerH)' " +
            "data-label='$rangeLabel' data-crit='$($weeks[$w].Crit)' data-warn='$($weeks[$w].Warn)' data-ok='$($weeks[$w].Ok)'/>")
    }

    $chartEmpty = ''
    if ($maxTotal -eq 0) {
        $chartEmpty = "<div class='chart-empty'>No certificate expiries in the next 90 days</div>"
    }

    # ------------------------------------------------------- trend section ---
    $trendHtml = ''
    if ($Trend) {
        $sb = New-Object System.Text.StringBuilder
        [void]$sb.Append("<section class='card'><div class='card-h'><h2>Trend vs. baseline</h2></div><div class='trend-grid'>")
        foreach ($block in @(
                @('New certificates', $Trend.NewCertificates),
                @('Removed certificates', $Trend.RemovedCertificates),
                @('Approaching expiry', $Trend.Approaching))) {
            $items = @($block[1])
            [void]$sb.Append("<div class='trend-col'><div class='trend-h'>$($block[0]) <span class='chip tnum'>$($items.Count)</span></div>")
            if ($items.Count -gt 0) {
                [void]$sb.Append("<ul class='trend-list'>")
                foreach ($item in ($items | Select-Object -First 12)) {
                    $days = if ($null -ne (Prop $item 'DaysRemaining')) { " &middot; $([math]::Round([double]$item.DaysRemaining, 1))d left" } else { '' }
                    [void]$sb.Append("<li>$(Dot (Prop $item 'Severity'))<span class='trend-host'>$(Encode $item.Host)</span> $(Encode $item.Role) &middot; $(Encode $item.Subject)$days</li>")
                }
                if ($items.Count -gt 12) { [void]$sb.Append("<li class='muted'>+ $($items.Count - 12) more</li>") }
                [void]$sb.Append('</ul>')
            }
            else {
                [void]$sb.Append("<div class='empty-s'>none</div>")
            }
            [void]$sb.Append('</div>')
        }
        [void]$sb.Append('</div></section>')
        $trendHtml = $sb.ToString()
    }

    # -------------------------------------- per-tenant / per-server tables ---
    $tables = New-Object System.Text.StringBuilder
    $tenantGroups = $Snapshot | Group-Object { if ((Prop $_ 'Tenant')) { $_.Tenant } else { 'default' } } | Sort-Object Name
    foreach ($tenant in $tenantGroups) {
        if ($tenantGroups.Count -gt 1 -or $tenant.Name -ne 'default') {
            [void]$tables.Append("<h2 class='tenant-h'>Tenant &middot; $(Encode $tenant.Name)</h2>")
        }
        foreach ($snap in ($tenant.Group | Sort-Object Host)) {
            $certs = @($snap.Certificates)
            $hostWorst = 'OK'
            foreach ($cert in $certs) {
                $certSev = if ($cert.Severity) { [string]$cert.Severity } else { 'OK' }
                if ($sevRank[$certSev] -gt $sevRank[$hostWorst]) { $hostWorst = $certSev }
            }
            $hostNext = $certs | Sort-Object { [double]$_.DaysRemaining } | Select-Object -First 1

            [void]$tables.Append("<section class='card'><details open><summary><span class='srv'>$(Encode $snap.Host)</span>")
            [void]$tables.Append("<span class='chip tnum'>$($certs.Count) certs</span>")
            if ($hostNext) { [void]$tables.Append("<span class='chip'>next expiry <b class='tnum'>$([math]::Floor([double]$hostNext.DaysRemaining))d</b></span>") }
            [void]$tables.Append("<span class='chip muted'>scanned $(FormatDate $snap.Timestamp)</span><span class='spacer'></span>$(Badge $hostWorst)</summary>")
            [void]$tables.Append("<div class='table-tools'><label class='search'><svg viewBox='0 0 24 24' fill='none' stroke='currentColor' stroke-width='2.2' stroke-linecap='round'><circle cx='11' cy='11' r='7'/><line x1='21' y1='21' x2='16.5' y2='16.5'/></svg><input type='text' placeholder='Filter certificates&hellip;' onkeyup='cwFilter(this)'></label></div>")
            [void]$tables.Append("<div class='twrap'><table class='certs'><thead><tr>" +
                "<th onclick='cwSort(this,0)'>Severity</th><th onclick='cwSort(this,1)'>Role</th><th onclick='cwSort(this,2)'>Name / Template</th>" +
                "<th onclick='cwSort(this,3)'>Subject</th><th onclick='cwSort(this,4)'>SAN</th><th onclick='cwSort(this,5)'>Issued by</th>" +
                "<th onclick='cwSort(this,6)' data-num='1'>Days left</th><th onclick='cwSort(this,7)'>Expires</th><th onclick='cwSort(this,8)'>Thumbprint</th>" +
                '</tr></thead><tbody>')
            foreach ($cert in ($certs | Sort-Object { [double]$_.DaysRemaining })) {
                $sev = if ($cert.Severity) { [string]$cert.Severity } else { 'OK' }
                $notAfter = ([datetime]$cert.NotAfter).ToString('yyyy-MM-dd')
                $days = [double]$cert.DaysRemaining

                $name = Prop $cert 'FriendlyName'
                if (-not $name) { $name = Prop $cert 'Template' }
                $nameTitle = @(
                    $(if (Prop $cert 'Template') { "Template: $(Prop $cert 'Template')" })
                    $(if (Prop $cert 'EkuNames') { "EKU: $(@(Prop $cert 'EkuNames') -join ', ')" })
                    $(if (Prop $cert 'KeyAlgorithm') { "Key: $(Prop $cert 'KeyAlgorithm')" })
                    $(if ((Prop $cert 'HasPrivateKey') -eq $false) { 'No private key' })
                ) | Where-Object { $_ }
                $nameCell = if ($name) { Encode $name } else { "<span class='muted'>&mdash;</span>" }

                $san = @(Prop $cert 'SanDnsNames')
                $sanCell = if ($san.Count -gt 0) {
                    $shown = @($san | Select-Object -First 2)
                    $more = if ($san.Count -gt 2) { " <span class='muted'>+$($san.Count - 2)</span>" } else { '' }
                    "$(Encode ($shown -join ', '))$more"
                }
                else { "<span class='muted'>&mdash;</span>" }

                $issuer = [string]$cert.Issuer
                $issuerCn = if ($issuer -match '(?:^|,\s*)CN=([^,]+)') { $Matches[1] } else { $issuer }

                $thumb = [string]$cert.Thumbprint
                $thumbShort = if ($thumb.Length -gt 16) { "$($thumb.Substring(0, 8))&hellip;$($thumb.Substring($thumb.Length - 6))" } else { Encode $thumb }

                # remaining-lifetime meter vs the 90-day window; fill = status, track = tint
                $meterPct = [math]::Max(4, [math]::Min(100, [math]::Round($days / 90 * 100)))
                if ($days -le 0) { $meterPct = 100 }
                $meterCls = @{ OK = 'm-ok'; WARNING = 'm-warn'; CRITICAL = 'm-crit'; UNKNOWN = 'm-unk' }[$sev]

                [void]$tables.Append("<tr><td>$(Badge $sev)</td>" +
                    "<td>$(Encode $cert.Role)</td>" +
                    "<td title='$(Encode ($nameTitle -join ' | '))'>$nameCell</td>" +
                    "<td class='subj'>$(Encode $cert.Subject)</td>" +
                    "<td title='$(Encode ($san -join ', '))'>$sanCell</td>" +
                    "<td title='$(Encode $issuer)'>$(Encode $issuerCn)</td>" +
                    "<td data-v='$days' class='tnum'><div class='days'><span>$([math]::Round($days, 1))</span><span class='meter $meterCls'><i style='width:$meterPct%'></i></span></div></td>" +
                    "<td class='tnum'>$notAfter</td>" +
                    "<td class='mono' title='$(Encode $thumb)'>$thumbShort</td></tr>")
            }
            [void]$tables.Append('</tbody></table></div></details></section>')
        }
    }

    # -------------------------------------------------- CRL / CA panels ------
    $crlRows = New-Object System.Text.StringBuilder
    foreach ($snap in $Snapshot) {
        foreach ($crl in @($snap.Crls)) {
            if (-not $crl) { continue }
            $cdp = @(Prop $crl 'CdpResults') | ForEach-Object {
                $state = if ($_.Reachable) { "<span class='reach-ok'>reachable</span>" } else { "<span class='reach-fail'>unreachable</span>" }
                "<span class='mono cdp'>$(Encode $_.Url)</span> $state"
            }
            $sigCell = switch ([string](Prop $crl 'SignatureValid')) {
                'True' { "<span class='reach-ok'>valid</span>" }
                'False' { "<span class='reach-fail'>INVALID</span>" }
                default { "<span class='muted'>n/a</span>" }
            }
            $hours = Prop $crl 'HoursRemaining'
            [void]$crlRows.Append("<tr><td>$(Badge (Prop $crl 'Severity'))</td><td>$(Encode $snap.Host)</td><td>$(Encode (Prop $crl 'Type'))</td>" +
                "<td class='subj'>$(Encode (Prop $crl 'Issuer'))</td><td class='tnum'>$(FormatDate (Prop $crl 'NextUpdate'))</td>" +
                "<td class='tnum'>$(if ($null -ne $hours) { [math]::Round([double]$hours, 1) })</td>" +
                "<td>$($cdp -join '<br>')</td><td>$sigCell</td></tr>")
        }
    }
    $caRows = New-Object System.Text.StringBuilder
    foreach ($snap in $Snapshot) {
        $ca = $snap.CaHealth
        if (-not ($ca -and $ca.IsCa)) { continue }
        [void]$caRows.Append("<tr><td>$(Badge (Prop $ca 'Severity'))</td><td>$(Encode $snap.Host)</td><td>$(Encode (Prop $ca 'CaName'))</td>" +
            "<td>$(Encode (Prop $ca 'CaType'))</td><td>$(Encode (Prop $ca 'ServiceStatus'))</td><td class='tnum'>$(Encode (Prop $ca 'PendingRequests'))</td>" +
            "<td class='tnum'>$(Encode (Prop $ca 'FailedRequests24h'))</td><td class='tnum'>$(FormatDate (Prop $ca 'CaCertNotAfter'))</td></tr>")
    }

    $emptyRow = "<tr><td colspan='8'><div class='empty'><svg viewBox='0 0 24 24' fill='none' stroke='currentColor' stroke-width='1.8' stroke-linecap='round' stroke-linejoin='round'><circle cx='12' cy='12' r='10'/><line x1='8' y1='12' x2='16' y2='12'/></svg>{0}</div></td></tr>"

    # --------------------------------------------------------------- style ---
    $style = @'
<style>
:root{
  color-scheme: light;
  --page:#f4f4f1; --card:#fcfcfb; --ink:#0b0b0b; --ink-2:#52514e; --muted:#898781;
  --grid:#e1e0d9; --axis:#c3c2b7; --line:rgba(11,11,11,.10); --line-soft:rgba(11,11,11,.06);
  --ok:#0ca30c; --warn:#b97c00; --crit:#d03b3b; --unk:#898781;
  --ok-m:#0ca30c; --warn-m:#fab219; --crit-m:#d03b3b; --unk-m:#898781;
  --ok-t:rgba(12,163,12,.09); --warn-t:rgba(250,178,25,.13); --crit-t:rgba(208,59,59,.09); --unk-t:rgba(137,135,129,.10);
  --ok-b:rgba(12,163,12,.30); --warn-b:rgba(185,124,0,.35); --crit-b:rgba(208,59,59,.30); --unk-b:rgba(137,135,129,.30);
  --accent:#2a78d6; --shadow:0 1px 2px rgba(11,11,11,.04),0 4px 16px rgba(11,11,11,.04);
  --hover:rgba(11,11,11,.028);
}
@media (prefers-color-scheme: dark){
  :root{
    color-scheme: dark;
    --page:#0d0d0d; --card:#1a1a19; --ink:#ffffff; --ink-2:#c3c2b7; --muted:#898781;
    --grid:#2c2c2a; --axis:#383835; --line:rgba(255,255,255,.10); --line-soft:rgba(255,255,255,.06);
    --warn:#fab219;
    --shadow:0 1px 2px rgba(0,0,0,.3),0 6px 20px rgba(0,0,0,.25);
    --hover:rgba(255,255,255,.035);
  }
}
*{box-sizing:border-box}
body{font-family:system-ui,-apple-system,'Segoe UI',sans-serif;color:var(--ink);background:var(--page);margin:0;padding:28px 32px 48px;font-size:14px;line-height:1.45}
.wrap{max-width:1240px;margin:0 auto}
h1{margin:0;font-size:21px;font-weight:650;letter-spacing:-.01em}
h2{font-size:15px;font-weight:650;margin:0;letter-spacing:-.005em}
.tenant-h{font-size:13px;font-weight:650;color:var(--ink-2);text-transform:uppercase;letter-spacing:.07em;margin:30px 4px 0}
.muted{color:var(--muted)}
.tnum{font-variant-numeric:tabular-nums}
/* header */
.hdr{display:flex;align-items:center;gap:14px;margin-bottom:6px}
.mark{width:38px;height:38px;border-radius:10px;background:linear-gradient(145deg,#2a78d6,#1c5cab);display:flex;align-items:center;justify-content:center;box-shadow:var(--shadow);flex:none}
.mark svg{width:22px;height:22px;stroke:#fff}
.hdr-sub{color:var(--muted);font-size:12.5px;margin-top:2px;display:flex;gap:8px;flex-wrap:wrap;align-items:center}
.hdr-sub b{color:var(--ink-2);font-weight:600}
.hdr-sub .sep{opacity:.5}
/* tiles */
.tiles{display:grid;grid-template-columns:repeat(auto-fit,minmax(158px,1fr));gap:14px;margin:22px 0 26px}
.tile{background:var(--card);border:1px solid var(--line);border-radius:14px;padding:15px 17px 14px;box-shadow:var(--shadow);position:relative;overflow:hidden}
.tile.accent-crit::before,.tile.accent-warn::before{content:'';position:absolute;left:0;top:0;bottom:0;width:3px}
.tile.accent-crit::before{background:var(--crit-m)}
.tile.accent-warn::before{background:var(--warn-m)}
.tile .l{font-size:10.5px;letter-spacing:.09em;text-transform:uppercase;color:var(--muted);font-weight:650}
.tile .v{font-size:29px;font-weight:650;margin-top:7px;letter-spacing:-.02em;display:flex;align-items:center;gap:9px}
.tile .s{font-size:11.5px;color:var(--muted);margin-top:3px}
/* badges & dots */
.badge{display:inline-flex;align-items:center;gap:5px;padding:3px 10px 3px 8px;border-radius:999px;font-size:11px;font-weight:650;letter-spacing:.04em;border:1px solid;color:var(--ink)}
.badge .ic{display:inline-flex}.badge .ic svg{width:12px;height:12px}
.b-ok{background:var(--ok-t);border-color:var(--ok-b)}.b-ok .ic{color:var(--ok-m)}
.b-warn{background:var(--warn-t);border-color:var(--warn-b)}.b-warn .ic{color:var(--warn)}
.b-crit{background:var(--crit-t);border-color:var(--crit-b)}.b-crit .ic{color:var(--crit-m)}
.b-unk{background:var(--unk-t);border-color:var(--unk-b)}.b-unk .ic{color:var(--unk-m)}
.badge.lg{font-size:13px;padding:5px 14px 5px 11px}.badge.lg .ic svg{width:15px;height:15px}
.dot{display:inline-block;width:8px;height:8px;border-radius:50%;margin-right:7px;flex:none}
.d-ok{background:var(--ok-m)}.d-warn{background:var(--warn-m)}.d-crit{background:var(--crit-m)}.d-unk{background:var(--unk-m)}
/* cards */
.card{background:var(--card);border:1px solid var(--line);border-radius:14px;box-shadow:var(--shadow);margin:16px 0;overflow:hidden}
.card-h{display:flex;align-items:center;gap:12px;padding:15px 18px;border-bottom:1px solid var(--line-soft)}
.card-h .spacer,summary .spacer{flex:1}
.chip{display:inline-flex;align-items:center;gap:5px;background:var(--hover);border:1px solid var(--line-soft);border-radius:999px;padding:2px 10px;font-size:11.5px;color:var(--ink-2)}
.chip b{font-weight:650;color:var(--ink)}
/* legend */
.legend{display:flex;gap:14px;font-size:11.5px;color:var(--ink-2)}
.legend i{display:inline-block;width:10px;height:10px;border-radius:3px;margin-right:5px;vertical-align:-1px}
/* chart */
.chart-body{padding:10px 14px 6px;position:relative}
svg.chart{width:100%;height:auto;display:block}
.gridline{stroke:var(--grid);stroke-width:1}
.axisline{stroke:var(--axis);stroke-width:1}
.axis-t{fill:var(--muted);font-size:10.5px;font-family:inherit}
.bar-t{fill:var(--ink-2);font-size:10.5px;font-weight:650;font-family:inherit}
.seg-crit{fill:var(--crit-m)}.seg-warn{fill:var(--warn-m)}.seg-ok{fill:var(--ok-m)}
.hit{fill:transparent;cursor:default}
.hit:hover{fill:var(--hover)}
.chart-empty{position:absolute;inset:0;display:flex;align-items:center;justify-content:center;color:var(--muted);font-size:13px}
.tip{position:fixed;z-index:10;background:var(--card);border:1px solid var(--line);border-radius:10px;box-shadow:var(--shadow);padding:9px 12px;font-size:12px;pointer-events:none;display:none;min-width:150px}
.tip .t{font-weight:650;margin-bottom:6px;color:var(--ink-2);font-size:11.5px}
.tip .r{display:flex;align-items:center;gap:7px;margin-top:3px}
.tip .k{width:12px;height:3px;border-radius:2px;flex:none}
.tip .v{font-weight:650;margin-left:auto;font-variant-numeric:tabular-nums}
/* server sections */
summary{display:flex;align-items:center;gap:10px;padding:14px 18px;cursor:pointer;list-style:none;flex-wrap:wrap}
summary::-webkit-details-marker{display:none}
summary::before{content:'';border:solid var(--muted);border-width:0 1.6px 1.6px 0;padding:2.6px;transform:rotate(45deg);transition:transform .15s;margin-right:2px}
details:not([open]) summary::before{transform:rotate(-45deg)}
.srv{font-weight:650;font-size:14.5px}
.table-tools{padding:4px 18px 10px}
.search{display:inline-flex;align-items:center;gap:7px;background:var(--page);border:1px solid var(--line);border-radius:9px;padding:6px 11px;width:270px;color:var(--muted)}
.search svg{width:13px;height:13px;flex:none}
.search input{border:0;background:none;outline:0;font:inherit;color:var(--ink);width:100%}
.search:focus-within{border-color:var(--accent);box-shadow:0 0 0 3px rgba(42,120,214,.15)}
/* tables */
.twrap{overflow-x:auto}
table{border-collapse:collapse;width:100%;font-size:12.5px}
th,td{padding:8px 12px;text-align:left;border-bottom:1px solid var(--line-soft);vertical-align:middle}
th{color:var(--muted);font-size:10.5px;text-transform:uppercase;letter-spacing:.07em;font-weight:650;cursor:pointer;user-select:none;white-space:nowrap;background:var(--card);position:sticky;top:0}
th:hover{color:var(--ink-2)}
th[data-asc='1']::after{content:' \25B4'}
th[data-asc='0']::after{content:' \25BE'}
tbody tr:hover{background:var(--hover)}
tbody tr:last-child td{border-bottom:none}
td:first-child,th:first-child{padding-left:18px}
td:last-child,th:last-child{padding-right:18px}
.mono{font-family:ui-monospace,Consolas,monospace;font-size:11.5px;color:var(--ink-2)}
.subj{max-width:300px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
.cdp{font-size:11px}
.reach-ok{color:var(--ok);font-weight:600;font-size:11.5px}
.reach-fail{color:var(--crit);font-weight:650;font-size:11.5px}
/* days + meter */
.days{display:flex;align-items:center;gap:9px}
.days>span:first-child{min-width:44px;font-weight:650}
.meter{display:inline-block;width:52px;height:5px;border-radius:3px;overflow:hidden;flex:none}
.meter i{display:block;height:100%;border-radius:3px}
.m-ok{background:var(--ok-t)}.m-ok i{background:var(--ok-m)}
.m-warn{background:var(--warn-t)}.m-warn i{background:var(--warn-m)}
.m-crit{background:var(--crit-t)}.m-crit i{background:var(--crit-m)}
.m-unk{background:var(--unk-t)}.m-unk i{background:var(--unk-m)}
/* trend */
.trend-grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(240px,1fr));gap:0 24px;padding:14px 18px 16px}
.trend-h{font-size:12px;font-weight:650;color:var(--ink-2);display:flex;align-items:center;gap:8px;margin-bottom:8px}
.trend-list{list-style:none;margin:0;padding:0;font-size:12.5px}
.trend-list li{display:flex;align-items:center;padding:5px 0;border-bottom:1px solid var(--line-soft)}
.trend-list li:last-child{border-bottom:none}
.trend-host{font-weight:650;margin-right:6px}
.empty-s{color:var(--muted);font-size:12.5px;padding:6px 0}
/* empty states */
.empty{display:flex;align-items:center;justify-content:center;gap:9px;color:var(--muted);padding:26px 0;font-size:13px}
.empty svg{width:17px;height:17px;opacity:.7}
/* footer */
.foot{margin-top:28px;color:var(--muted);font-size:11.5px;display:flex;gap:8px;align-items:center}
@media print{
  body{background:#fff;padding:0}
  .card,.tile{box-shadow:none;break-inside:avoid}
  .search,.hit{display:none}
  details:not([open])>*:not(summary){display:block}
}
</style>
'@


    # -------------------------------------------------------------- script ---
    $script = @'
<script>
(function(){
  var tip=document.createElement('div');tip.className='tip';document.body.appendChild(tip);
  function row(label,color,val){
    var r=document.createElement('div');r.className='r';
    var k=document.createElement('i');k.className='k';k.style.background=color;
    var l=document.createElement('span');l.textContent=label;
    var v=document.createElement('span');v.className='v';v.textContent=val;
    r.appendChild(k);r.appendChild(l);r.appendChild(v);return r;
  }
  var css=getComputedStyle(document.documentElement);
  document.querySelectorAll('.hit').forEach(function(h){
    h.addEventListener('pointermove',function(e){
      tip.textContent='';
      var t=document.createElement('div');t.className='t';t.textContent=h.dataset.label.replace('&ndash;','–');tip.appendChild(t);
      var total=0;
      [['CRITICAL','--crit-m','crit'],['WARNING','--warn-m','warn'],['OK','--ok-m','ok']].forEach(function(s){
        var n=parseInt(h.dataset[s[2]],10)||0;total+=n;
        if(n>0)tip.appendChild(row(s[0],css.getPropertyValue(s[1]).trim(),n));
      });
      if(total===0){var z=document.createElement('div');z.textContent='No expiries this week';z.style.color=css.getPropertyValue('--muted');tip.appendChild(z);}
      tip.style.display='block';
      var x=e.clientX+14,y=e.clientY+14,r=tip.getBoundingClientRect();
      if(x+r.width>innerWidth-8)x=e.clientX-r.width-12;
      if(y+r.height>innerHeight-8)y=e.clientY-r.height-12;
      tip.style.left=x+'px';tip.style.top=y+'px';
    });
    h.addEventListener('pointerleave',function(){tip.style.display='none';});
  });
})();
function cwFilter(inp){
  var q=inp.value.toLowerCase(), card=inp.closest('.card')||document, tb=card.querySelector('table tbody');
  if(!tb)return;
  for(var r of tb.rows){r.style.display=r.textContent.toLowerCase().indexOf(q)>=0?'':'none';}
}
function cwSort(th,idx){
  var table=th.closest('table'), tb=table.tBodies[0], rows=Array.from(tb.rows);
  var num=th.hasAttribute('data-num'), asc=th.dataset.asc!=='1';
  table.querySelectorAll('th').forEach(function(h){h.removeAttribute('data-asc');});
  th.dataset.asc=asc?'1':'0';
  rows.sort(function(a,b){
    var x=a.cells[idx], y=b.cells[idx];
    var xv=x.dataset.v!==undefined?parseFloat(x.dataset.v):x.textContent.trim();
    var yv=y.dataset.v!==undefined?parseFloat(y.dataset.v):y.textContent.trim();
    if(num||x.dataset.v!==undefined){xv=parseFloat(xv)||0;yv=parseFloat(yv)||0;return asc?xv-yv:yv-xv;}
    return asc?String(xv).localeCompare(String(yv)):String(yv).localeCompare(String(xv));
  });
  rows.forEach(function(r){tb.appendChild(r);});
}
</script>
'@


    # ---------------------------------------------------------------- html ---
    $nextTileValue = if ($next) { "$([math]::Round($next.DaysRemaining, 1))<span style='font-size:15px;font-weight:600;color:var(--muted)'>days</span>" } else { '&ndash;' }
    $nextTileSub = if ($next) { "$(Encode $next.Role) on $(Encode $next.Host)" } else { 'no certificates in scope' }
    $critAccent = if ($critCount -gt 0) { ' accent-crit' } else { '' }
    $warnAccent = if ($warnCount -gt 0) { ' accent-warn' } else { '' }

    $html = @"
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>$(Encode $Title)</title>
$style
</head>
<body>
<div class="wrap">
<header class="hdr">
  <div class="mark"><svg viewBox="0 0 24 24" fill="none" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 2l8 3.2V11c0 5-3.4 8.6-8 11-4.6-2.4-8-6-8-11V5.2L12 2z"/><path d="M8.5 12l2.5 2.5 4.5-4.5"/></svg></div>
  <div>
    <h1>$(Encode $Title)</h1>
    <div class="hdr-sub"><b>$($Snapshot.Count)</b> server$(if ($Snapshot.Count -ne 1) { 's' })<span class="sep">&middot;</span><b>$($allCerts.Count)</b> certificates<span class="sep">&middot;</span>generated $($now.ToString('yyyy-MM-dd HH:mm'))<span class="sep">&middot;</span>cert-watchtower</div>
  </div>
</header>
 
<div class="tiles">
  <div class="tile"><div class="l">Overall</div><div class="v">$((Badge $score) -replace "class='badge ", "class='badge lg ")</div><div class="s">worst finding across the fleet</div></div>
  <div class="tile$critAccent"><div class="l">Critical</div><div class="v tnum">$critCount$(if ($critCount -gt 0) { Dot 'CRITICAL' })</div><div class="s">certificates</div></div>
  <div class="tile$warnAccent"><div class="l">Warning</div><div class="v tnum">$warnCount$(if ($warnCount -gt 0) { Dot 'WARNING' })</div><div class="s">certificates</div></div>
  <div class="tile"><div class="l">Next expiry</div><div class="v tnum">$nextTileValue</div><div class="s">$nextTileSub</div></div>
  <div class="tile"><div class="l">CRL health</div><div class="v">$(Badge $crlLight[0])</div><div class="s">$(Encode $crlLight[1])</div></div>
  <div class="tile"><div class="l">CA health</div><div class="v">$(Badge $caLight[0])</div><div class="s">$(Encode $caLight[1])</div></div>
</div>
 
<section class="card">
  <div class="card-h"><h2>Expiry timeline</h2><span class="chip">next 90 days</span><span class="spacer"></span>
    <div class="legend"><span><i style="background:var(--crit-m)"></i>Critical</span><span><i style="background:var(--warn-m)"></i>Warning</span><span><i style="background:var(--ok-m)"></i>OK</span></div>
  </div>
  <div class="chart-body">
    <svg class="chart" viewBox="0 0 $svgW $svgH" role="img" aria-label="Certificate expiries per week, next 90 days">$($svg.ToString())</svg>
    $chartEmpty
  </div>
</section>
$trendHtml
$($tables.ToString())
<section class="card">
  <div class="card-h"><h2>CRL health</h2>$(if ($allCrls.Count) { "<span class='chip tnum'>$($allCrls.Count)</span>" })</div>
  <div class="twrap"><table><thead><tr><th>Severity</th><th>Host</th><th>Type</th><th>Issuer</th><th>Next update</th><th>Hours left</th><th>Distribution point</th><th>Signature</th></tr></thead>
  <tbody>$(if ($crlRows.Length) { $crlRows.ToString() } else { $emptyRow -f 'No CRLs referenced by the scanned certificates' })</tbody></table></div>
</section>
<section class="card">
  <div class="card-h"><h2>ADCS CA health</h2>$(if ($allCas.Count) { "<span class='chip tnum'>$($allCas.Count)</span>" })</div>
  <div class="twrap"><table><thead><tr><th>Severity</th><th>Host</th><th>CA</th><th>Type</th><th>CertSvc</th><th>Pending</th><th>Failed 24h</th><th>CA cert expires</th></tr></thead>
  <tbody>$(if ($caRows.Length) { $caRows.ToString() } else { $emptyRow -f 'No certificate authority in scope' })</tbody></table></div>
</section>
<div class="foot">cert-watchtower &middot; agentless PKI monitoring &middot; github.com/xGreeny/cert-watchtower</div>
</div>
$script
</body>
</html>
"@


    $html
}