Private/Report.ps1

# The Report: the document the Technician reads after a Run.
#
# Findings, Sections and Provenance in, markup out. Nothing here talks to the Target
# Machine or decides anything, which is why it can be tested: ordering, encoding, Hint
# suppression and the Provenance statement are the things that break quietly.
#
# How it reads, top to bottom, is who reads it:
#
# 1. What needs doing. Every FAIL and WARN as a card: what it is about, what was found,
# what to do. A first-level Technician stops here.
# 2. Every Check, folded by Category. For the question "was that looked at, and what did
# it say".
# 3. The Sections, folded. Evidence for second level.
#
# One file, and nothing fetched: it is opened at a Customer Site, from a Desktop, by
# whatever browser is there, with or without a network. The script only adds comfort -
# filtering, opening everything at once - and the Report says all it has to without it.
#
# Dark on a screen and light on paper, because a dark page printed is a page of toner.

$script:ReportCss = @'
<style>
:root{
  --bg:#0b0f14; --panel:#121820; --panel2:#18202b; --line:#243042; --line2:#1a2330; --text:#e6edf3; --dim:#8b98a9; --faint:#5c6a7c;
  --fail:#ff5c5c; --warn:#f0b429; --ok:#3ecf8e; --info:#7d8ea3;
  --failbg:rgba(255,92,92,.10); --warnbg:rgba(240,180,41,.10); --okbg:rgba(62,207,142,.08); --infobg:rgba(125,142,163,.08);
  --failln:rgba(255,92,92,.35); --warnln:rgba(240,180,41,.35); --okln:rgba(62,207,142,.30); --infoln:rgba(125,142,163,.30);
  --accent:#3fd0c9; --merlin:#2f81f7; --radius:10px;
  --sans:"Segoe UI Variable Text","Segoe UI",system-ui,Arial,sans-serif; --mono:"Cascadia Mono",Consolas,monospace;
}
*{box-sizing:border-box}
body{margin:0;background:var(--bg);color:var(--text);font:14px/1.5 var(--sans);-webkit-font-smoothing:antialiased}
button{font:inherit;color:inherit;cursor:pointer}
.FAIL{--c:var(--fail);--cb:var(--failbg);--cl:var(--failln)} .WARN{--c:var(--warn);--cb:var(--warnbg);--cl:var(--warnln)}
.OK{--c:var(--ok);--cb:var(--okbg);--cl:var(--okln)} .INFO{--c:var(--info);--cb:var(--infobg);--cl:var(--infoln)}
.badge{display:inline-block;font:600 11px/1 var(--mono);letter-spacing:.06em;padding:4px 7px;border-radius:5px;color:var(--c);background:var(--cb);border:1px solid var(--cl);white-space:nowrap}
.dot{display:inline-block;width:9px;height:9px;border-radius:50%;background:var(--c);flex:none}
.chip{display:inline-block;font-size:12px;padding:2px 9px;border-radius:999px;background:var(--panel2);border:1px solid var(--line);color:var(--dim);white-space:nowrap}
.chip.admin{color:#c9a7ff;border-color:#3d2f5c;background:#1d1730}
.brand{display:flex;align-items:center;gap:14px;padding:14px 28px;border-bottom:1px solid var(--line);background:linear-gradient(180deg,#0f151d,#0b0f14)}
.brand .logo{font:700 20px var(--mono);color:var(--accent);letter-spacing:.5px}
.brand .logo:before{content:"> ";color:var(--faint)}
.brand .tag{color:var(--faint);font-style:italic}
.brand .ver{font:12px var(--mono);color:var(--faint)}
.brand .company{margin-left:auto;font:800 20px/1 var(--sans);letter-spacing:.22em;color:var(--merlin)}
.wrap{max-width:1080px;margin:0 auto;padding:26px 28px 60px}
.hero{display:grid;grid-template-columns:1fr auto;gap:20px;align-items:end;margin-bottom:8px}
@media (max-width:760px){.hero{grid-template-columns:1fr}}
.hero h1{margin:0;font:700 34px/1.1 var(--sans);letter-spacing:-.01em}
.hero .meta{color:var(--dim);margin-top:6px}
.tally{display:flex;gap:10px}
.tally div{min-width:86px;padding:10px 14px;border-radius:var(--radius);background:var(--cb);border:1px solid var(--cl);text-align:center}
.tally b{display:block;font:700 26px/1 var(--sans);color:var(--c)} .tally small{font:600 11px var(--mono);color:var(--c);letter-spacing:.08em}
h2{font:600 13px var(--mono);letter-spacing:.12em;text-transform:uppercase;color:var(--dim);margin:34px 0 12px;display:flex;gap:12px;align-items:center}
h2 .tools{margin-left:auto;display:flex;gap:6px;text-transform:none;letter-spacing:0}
h2 .tools button{background:var(--panel);border:1px solid var(--line);border-radius:999px;padding:3px 11px;color:var(--dim);font:12px var(--sans)}
h2 .tools button:hover{color:var(--text);border-color:var(--accent)}
.none{color:var(--dim);padding:14px 16px;background:var(--panel);border:1px solid var(--line);border-radius:var(--radius)}
.card{background:var(--panel);border:1px solid var(--line);border-left:4px solid var(--c);border-radius:var(--radius);padding:16px 18px;margin-bottom:12px}
.card header{display:flex;gap:10px;align-items:center;flex-wrap:wrap;margin-bottom:10px}
.card h3{margin:0;font:600 17px var(--sans);flex:1 1 300px}
.refs,.backrefs{margin-top:8px;font-size:12.5px;color:var(--dim);display:flex;flex-wrap:wrap;gap:4px 12px;align-items:baseline}
.refs .k,.backrefs .k{color:var(--faint);font:600 11px var(--mono);letter-spacing:.06em;text-transform:uppercase}
.backrefs{margin:0 0 10px}
a.jump{color:var(--accent);text-decoration:none;border-bottom:1px dotted var(--accent)}
.copy{background:var(--panel);border:1px solid var(--line);border-radius:999px;padding:2px 10px;color:var(--dim);font:12px var(--sans);cursor:pointer;white-space:nowrap}
.copy:hover,.copy:focus{color:var(--text);border-color:var(--accent)}
.copy.done{color:var(--ok,#3fb950);border-color:var(--ok,#3fb950)}
.row .copy{display:block;margin-top:5px;padding:1px 8px;font-size:11px;opacity:0}
.row:hover .copy,.row .copy:focus,.row .copy.done{opacity:1}
@media (hover:none){.row .copy{opacity:1}}
.sep{color:var(--faint);font-weight:400}
.parts{margin:0;padding:0;list-style:none;display:grid;gap:6px}
.parts>li{display:grid;grid-template-columns:minmax(120px,190px) 1fr;gap:10px;align-items:baseline;overflow-wrap:anywhere}
.parts .k{color:var(--dim);font-size:12.5px}
.parts .v ul{margin:0;padding-left:18px}
.parts>li.lead{grid-template-columns:1fr;font-size:15px}
.parts>li.plain{grid-template-columns:1fr}
.parts>li.verdict{grid-template-columns:1fr;padding:10px 12px;border-radius:8px;background:var(--cb);border-left:3px solid var(--c);font-weight:600}
.count{display:inline-block;font:700 15px/1 var(--sans);margin-right:10px;vertical-align:middle}
.names{display:inline-flex;flex-wrap:wrap;gap:6px;margin:0;padding:0;list-style:none;vertical-align:middle}
.names li{font:12.5px var(--mono);padding:3px 9px;border-radius:6px;background:var(--panel2);border:1px solid var(--line);color:var(--text)}
.parts>li.lead .names{display:flex;margin-top:8px}
.parts>li.lead .count{font-size:22px}
.meaning{margin-top:12px}
.meaning h4{margin:0 0 4px;font:600 11px var(--mono);letter-spacing:.08em;color:var(--dim);text-transform:uppercase}
.meaning p{margin:0;overflow-wrap:anywhere}
.row .meaning{margin-top:5px;color:#c3cedb}
.row .meaning .k,.row .hint .k{color:var(--faint);font:600 11px var(--mono);letter-spacing:.06em;text-transform:uppercase;margin-right:8px}
.todo{margin-top:12px;padding:10px 12px;border-radius:8px;background:#0e1620;border:1px dashed #2b3a50}
.todo h4{margin:0 0 4px;font:600 11px var(--mono);letter-spacing:.08em;color:var(--accent);text-transform:uppercase}
.todo ul{margin:0;padding-left:18px} .todo li{margin:3px 0}
a.jump{color:var(--accent);text-decoration:none;border-bottom:1px dotted var(--accent)}
a.jump:hover{border-bottom-style:solid}
details.group:target>summary{box-shadow:inset 3px 0 0 var(--accent)}
details>summary{cursor:pointer;list-style:none} details>summary::-webkit-details-marker{display:none}
.group{background:var(--panel);border:1px solid var(--line);border-radius:var(--radius);margin-bottom:8px;overflow:hidden}
.group>summary{display:flex;align-items:center;gap:10px;padding:11px 16px;flex-wrap:wrap}
.group>summary:hover{background:var(--panel2)}
.group>summary b{flex:1;font-weight:600}
.group>summary:after{content:"+";color:var(--faint);font:16px var(--mono);width:12px;text-align:center}
.group[open]>summary:after{content:"-"}
.sysinfo{background:var(--panel);border:1px solid var(--line);border-radius:var(--radius);margin:14px 0 4px;overflow:hidden}
.sysinfo>summary{display:flex;align-items:center;gap:10px;padding:11px 16px;font-weight:600}
.sysinfo>summary:hover{background:var(--panel2)}
.sysinfo>summary:after{content:"+";margin-left:auto;color:var(--faint);font:16px var(--mono)}
.sysinfo[open]>summary:after{content:"-"}
.sysgrid{display:grid;grid-template-columns:repeat(auto-fit,minmax(330px,1fr));gap:4px 28px;padding:4px 16px 14px}
.sysgrid h4{margin:10px 0 4px;font:600 11px var(--mono);letter-spacing:.08em;text-transform:uppercase;color:var(--accent)}
.sysgrid dl{display:grid;grid-template-columns:118px 1fr;gap:3px 12px;margin:0}
.sysgrid dt{color:var(--dim)} .sysgrid dd{margin:0;overflow-wrap:anywhere}
.leadbox{background:var(--panel);border:1px solid var(--accent);border-left:4px solid var(--accent);border-radius:var(--radius);padding:16px 18px;margin-bottom:12px}
.leadbox h3{margin:0 0 4px;font:600 17px var(--sans)}
.leadbox .refs{margin-top:6px}
.leadgap{margin:0 0 12px;padding:10px 14px;border:1px dashed var(--line);border-radius:var(--radius);color:var(--dim);display:flex;flex-wrap:wrap;gap:4px 10px;align-items:baseline}
.leadgap b{color:var(--text);font-weight:600}
.alert{display:grid;grid-template-columns:auto 1fr;gap:4px 16px;align-items:start;margin:14px 0 4px;padding:16px 18px;border-radius:var(--radius);border:2px solid var(--warn);background:repeating-linear-gradient(135deg,rgba(210,153,34,.13) 0 14px,rgba(210,153,34,.05) 14px 28px)}
.alert .mark{grid-row:1 / span 3;font:700 30px/1 var(--mono);color:var(--warn);border:2px solid var(--warn);border-radius:50%;width:44px;height:44px;display:flex;align-items:center;justify-content:center}
.alert h3{margin:0;font:700 17px var(--sans);color:var(--warn);letter-spacing:.01em}
.alert p{margin:0} .alert .then{color:var(--dim)}
.kind{margin-bottom:18px}
.kind>.kindname{display:flex;align-items:baseline;gap:8px;margin:0 2px 7px;font:600 12px var(--mono);letter-spacing:.06em;text-transform:uppercase;color:var(--accent)}
.kind>.kindname small{color:var(--faint);font-weight:400}
.subject{padding:9px 16px 5px;border-top:1px solid var(--line2);font:600 12px var(--mono);letter-spacing:.06em;color:var(--accent);background:#0f151d}
.row{display:grid;grid-template-columns:14px minmax(150px,240px) 1fr;gap:12px;padding:8px 16px;border-top:1px solid var(--line2);align-items:baseline}
.row .what b{font-weight:600;overflow-wrap:anywhere}
.row .val{overflow-wrap:anywhere}
.row .val div+div{margin-top:2px;color:#c3cedb}
.row .hint{margin-top:5px;color:var(--dim);font-size:12.5px}
 
input.search{width:100%;margin-bottom:10px;background:var(--panel);border:1px solid var(--line);color:var(--text);border-radius:8px;padding:8px 12px;font:inherit;outline:none}
input.search:focus{border-color:var(--accent)}
.evi{margin:0 12px 12px;overflow:auto;max-height:70vh;border:1px solid var(--line);border-radius:8px}
.evi table{border-collapse:collapse;width:100%;font-size:12.5px}
.evi th{position:sticky;top:0;background:#1b2532;color:var(--dim);font-weight:600;text-align:left;padding:7px 9px;border-bottom:1px solid var(--line);white-space:nowrap}
.evi td{padding:6px 9px;border-bottom:1px solid var(--line2);vertical-align:top;overflow-wrap:break-word}
.evi tr:hover td{background:#141c27}
.evi pre{margin:0;padding:12px;font:12px/1.5 var(--mono);white-space:pre-wrap;color:#c8d3df}
.evi td.hl{font-weight:600;color:var(--accent);box-shadow:inset 2px 0 0 var(--accent)}
.long{display:-webkit-box;-webkit-box-orient:vertical;-webkit-line-clamp:3;overflow:hidden;cursor:pointer;min-width:420px}
@media (min-width:1200px){#evidence{width:min(1560px,94vw);position:relative;left:50%;transform:translateX(-50%)}}
.long:after{content:""}
.long.open{display:block}
td.haslong{border-left:2px solid var(--line)} td.haslong:hover{border-left-color:var(--accent)}
footer{margin-top:40px;padding-top:14px;border-top:1px solid var(--line);color:var(--faint);font-size:12px}
@media print{
  :root{--bg:#fff;--panel:#fff;--panel2:#f3f4f6;--line:#c9ced6;--line2:#e3e6ea;--text:#111;--dim:#444;--faint:#666;
        --fail:#b00020;--warn:#8a5a00;--ok:#0a6b3d;--info:#4a5563;--accent:#0b6e69;--merlin:#1456c4;
        --failbg:#fde7ea;--warnbg:#fff3d6;--okbg:#e6f6ee;--infobg:#f1f3f5}
  body{font-size:11.5px} .brand{background:none} .wrap{max-width:none;padding:10px 0}
  .tools,input.search,.copy{display:none!important} a.jump{color:inherit;border:0}
  .sysinfo>summary:after{content:""}
  .alert{background:#fff8e5;border-color:#9a6700} .alert h3,.alert .mark{color:#9a6700;border-color:#9a6700}
  .card,.group{break-inside:avoid-page} .todo{background:#f6f8fa;border-color:#c9ced6}
  .subject{background:#f6f8fa} #evidence{width:auto;position:static;transform:none} .evi{max-height:none;overflow:visible} .evi th{position:static;background:#eef0f3}
  .long{display:block}
  .group>summary:after{content:""}
}
</style>
'@


# Comfort only. The Report is complete without it: <details> folds by itself.
$script:ReportScript = @'
<script>
(function(){
  function each(sel, fn){ Array.prototype.forEach.call(document.querySelectorAll(sel), fn); }
  each('[data-fold]', function(b){
    b.addEventListener('click', function(){
      var open = b.getAttribute('data-fold') === 'open';
      each(b.getAttribute('data-target') + ' details', function(d){ if (d.style.display !== 'none') d.open = open; });
    });
  });
  var q = document.getElementById('evidence-filter');
  if (q) q.addEventListener('input', function(){
    var words = q.value.toLowerCase().split(/\s+/).filter(Boolean);
    each('#evidence details', function(d){
      var t = d.getAttribute('data-title') || '';
      d.style.display = words.every(function(w){ return t.indexOf(w) >= 0; }) ? '' : 'none';
    });
    // A heading with nothing left under it goes too.
    each('#evidence .kind', function(k){
      var left = Array.prototype.some.call(k.querySelectorAll('details'), function(d){ return d.style.display !== 'none'; });
      k.style.display = left ? '' : 'none';
    });
  });
  // A Hint that names a piece of evidence leads there: shown again if filtered away, and open.
  function reveal(){
    var d = location.hash.length > 1 ? document.getElementById(location.hash.slice(1)) : null;
    if (!d) return;
    // A Section is a fold of its own; a Finding's line sits in the fold of its Category.
    var fold = d.tagName === 'DETAILS' ? d : (d.closest ? d.closest('details') : null);
    if (fold) { fold.style.display = ''; if (fold.parentNode && fold.parentNode.style) fold.parentNode.style.display = ''; fold.open = true; }
    d.scrollIntoView();
  }
  window.addEventListener('hashchange', reveal);
  document.addEventListener('click', function(e){ if (e.target.closest && e.target.closest('a.jump')) setTimeout(reveal, 0); });
  reveal();
  document.addEventListener('click', function(e){
    var el = e.target.closest ? e.target.closest('.long') : null;
    if (el && !window.getSelection().toString()) el.classList.toggle('open');
  });
  // A Finding as text, for a ticket or a mail. The clipboard of a page opened from a
  // file is not everywhere to be had, so there is the old way behind the new one.
  function put(text, done){
    function old(){
      var t = document.createElement('textarea'); t.value = text; t.style.position = 'fixed'; t.style.opacity = '0';
      document.body.appendChild(t); t.select();
      try { if (document.execCommand('copy')) done(); } catch (x) {}
      document.body.removeChild(t);
    }
    if (navigator.clipboard && navigator.clipboard.writeText) navigator.clipboard.writeText(text).then(done, old); else old();
  }
  document.addEventListener('click', function(e){
    var b = e.target.closest ? e.target.closest('button.copy') : null;
    if (!b) return;
    e.preventDefault();
    var label = b.textContent;
    put(b.getAttribute('data-copy') || '', function(){
      b.textContent = b.getAttribute('data-done') || label; b.classList.add('done');
      setTimeout(function(){ b.textContent = label; b.classList.remove('done'); }, 1500);
    });
  });
  // Paper has nothing to click: everything folded is printed open, and folded back after.
  var before = [];
  window.addEventListener('beforeprint', function(){ before = []; each('details', function(d){ before.push([d, d.open]); d.open = true; }); });
  window.addEventListener('afterprint', function(){ before.forEach(function(p){ p[0].open = p[1]; }); });
})();
</script>
'@


# What a Finding's Value and Hint put between their statements. A Judge that has several
# things to say joins them with this, and the Report takes them apart again to give each
# its own line instead of one long cell.
$script:ReportStatementSeparator = ' | '

# How long a table cell may be before the Report folds it to three lines.
$script:ReportLongCellLength = 180

function ConvertTo-HtmlText {
    [CmdletBinding()]
    param([AllowNull()][AllowEmptyString()]$Text)
    [System.Net.WebUtility]::HtmlEncode([string]$Text)
}

function Sort-Finding {
    <#
    .SYNOPSIS
        Findings worst first, then grouped by Category.
    #>

    [CmdletBinding()]
    param([AllowNull()][AllowEmptyCollection()]$Finding)

    # $script:SeverityValues is ordered worst first; its index is the sort key.
    $order = @{}
    for ($i = 0; $i -lt $script:SeverityValues.Count; $i++) { $order[$script:SeverityValues[$i]] = $i }

    @($Finding | Where-Object { $_ }) | Sort-Object `
        @{ Expression = { $order[[string]$_.Severity] } },
        @{ Expression = { [string]$_.Category } },
        @{ Expression = { [string]$_.Check } }
}

function Split-ReportStatement {
    <#
    .SYNOPSIS
        The statements a Value or a Hint is made of. Pure.
    #>

    [CmdletBinding()]
    [OutputType([string[]])]
    param([AllowNull()][AllowEmptyString()][string]$Text)

    @("$Text" -split [regex]::Escape($script:ReportStatementSeparator) |
        ForEach-Object { $_.Trim() } | Where-Object { $_ })
}

function Get-ReportSubject {
    <#
    .SYNOPSIS
        What a Finding or a Section is about, where its own text does not already say. Pure.
    .DESCRIPTION
        The application its Check is for. "Abstuerze / .NET-Fehler / Haenger" needs
        "Server-Eye" in front of it. A Check that opens with its application - "Outlook:
        COM-Add-Ins" - needs nothing, and neither does a Check on the machine itself, which
        its Category names. That the name turns up somewhere inside the text is not enough:
        "Folder size C:\Users\x\AppData\Local\Microsoft\Edge\User Data" does not say Edge
        to anybody who is not reading paths.
    #>

    [CmdletBinding()]
    [OutputType([string])]
    param([AllowNull()]$InputObject, [AllowNull()][AllowEmptyString()][string]$Text)

    $app = "$(Get-DataProperty $InputObject 'App')".Trim()
    if (-not $app) { return '' }
    if ("$Text".TrimStart().StartsWith($app, [StringComparison]::OrdinalIgnoreCase)) { return '' }
    $app
}

function ConvertTo-ReportTitleHtml {
    [CmdletBinding()]
    [OutputType([string])]
    param([AllowNull()]$InputObject, [AllowNull()][AllowEmptyString()][string]$Text)

    $subject = Get-ReportSubject -InputObject $InputObject -Text $Text
    if (-not $subject) { return (ConvertTo-HtmlText $Text) }
    '{0} <span class="sep">&rsaquo;</span> {1}' -f (ConvertTo-HtmlText $subject), (ConvertTo-HtmlText $Text)
}

function Split-ReportCountedList {
    <#
    .SYNOPSIS
        The names behind a count, when a statement is a count and its names. Pure.
    .DESCRIPTION
        "7: AdobeAcroOutlook.SendAsLink, IpPbxOutlookAddin.AddinModule, ..." is a number
        and what it counted, written as one sentence because a Value is one string. As a
        sentence it is a line nobody reads to the end; as a list it is seven things.
 
        Taken for a list only when the number in front is the number of names behind it.
        That is what tells "7: a, b, c, d, e, f, g" from a sentence that happens to open
        with a number and to hold a comma, and it costs nothing when it is wrong: the
        statement is then shown as it was written.
    #>

    [CmdletBinding()]
    [OutputType([string[]])]
    param([AllowNull()][AllowEmptyString()][string]$Statement)

    if ("$Statement" -notmatch '^(\d+):\s+(.+)$') { return @() }
    $names = @($Matches[2] -split ',\s+' | ForEach-Object { $_.Trim() } | Where-Object { $_ })
    if ($names.Count -lt 2 -or $names.Count -ne [int]$Matches[1]) { return @() }
    $names
}

function ConvertTo-ReportCountedListHtml {
    [CmdletBinding()]
    [OutputType([string])]
    param([AllowNull()][AllowEmptyCollection()][string[]]$Name)

    '<ul class="names">{0}</ul>' -f ((@($Name) | ForEach-Object { '<li>{0}</li>' -f (ConvertTo-HtmlText $_) }) -join '')
}

function ConvertTo-ReportValueHtml {
    <#
    .SYNOPSIS
        A Finding's Value as lines: what was found first, then each further statement.
    .DESCRIPTION
        A statement that opens with a short label - "Ausnahmen: ..." - is shown as label
        and content, and its content as a list where it holds several things. The verdict
        is set apart, because it is the sentence the rest leads up to. Everything else is
        a line of its own. Nothing is dropped and nothing reordered.
    #>

    [CmdletBinding()]
    [OutputType([string])]
    param([AllowNull()][AllowEmptyString()][string]$Value, [string]$VerdictLabel = '')

    $statements = @(Split-ReportStatement -Text $Value)
    if (-not $statements.Count) { return '' }

    $items = for ($i = 0; $i -lt $statements.Count; $i++) {
        $statement = $statements[$i]
        $names = @(Split-ReportCountedList -Statement $statement)
        if ($names.Count) {
            $class = if ($i -eq 0) { 'lead' } else { 'plain' }
            '<li class="{0}"><span class="count">{1}</span>{2}</li>' -f $class, $names.Count, (ConvertTo-ReportCountedListHtml -Name $names)
            continue
        }
        if ($i -eq 0) { '<li class="lead">{0}</li>' -f (ConvertTo-HtmlText $statement); continue }

        if ($statement -match '^([^:]{2,32}):\s+(.+)$') {
            $label   = $Matches[1]
            $content = $Matches[2]
            if ($VerdictLabel -and $label -eq $VerdictLabel) {
                '<li class="verdict">{0}</li>' -f (ConvertTo-HtmlText $content)
                continue
            }
            $several = @($content -split '; ' | Where-Object { $_ })
            if ($several.Count -gt 1) {
                $list = ($several | ForEach-Object { '<li>{0}</li>' -f (ConvertTo-HtmlText $_) }) -join ''
                '<li><span class="k">{0}</span><span class="v"><ul>{1}</ul></span></li>' -f (ConvertTo-HtmlText $label), $list
            }
            else {
                '<li><span class="k">{0}</span><span class="v">{1}</span></li>' -f (ConvertTo-HtmlText $label), (ConvertTo-HtmlText $content)
            }
            continue
        }
        '<li class="plain">{0}</li>' -f (ConvertTo-HtmlText $statement)
    }
    '<ul class="parts">{0}</ul>' -f ($items -join '')
}

function Get-ReportSectionLink {
    <#
    .SYNOPSIS
        Which piece of evidence a Hint can point at: Section title to the id of its fold. Pure.
    .DESCRIPTION
        "Die Liste der Verzoegerungen unten pruefen" sent a Technician looking through
        fifty folds for a list that is called something else. A Hint names the Section by
        its title, in quotation marks, and the Report makes a link of it.
 
        Only a title one Section alone carries: "Laufzeitmessungen (Server)" is five
        Sections, and a link to the first of them would be a link to the wrong one.
    #>

    [CmdletBinding()]
    [OutputType([hashtable])]
    param([AllowNull()][AllowEmptyCollection()]$Section)

    $seen = @{}
    foreach ($s in @($Section | Where-Object { $_ })) {
        $title = "$($s.Title)".Trim()
        if ($title) { $seen[$title] = 1 + [int]$seen[$title] }
    }
    $link = @{}
    $n = 0
    foreach ($s in @($Section | Where-Object { $_ })) {
        $title = "$($s.Title)".Trim()
        if ($title -and $seen[$title] -eq 1) { $n++; $link[$title] = 'evi-{0}' -f $n }
    }
    $link
}

function ConvertTo-ReportHintTextHtml {
    <#
    .SYNOPSIS
        One statement of a Hint, with every Section it names in quotation marks made a
        link to that Section. Pure.
    #>

    [CmdletBinding()]
    [OutputType([string])]
    param([AllowNull()][AllowEmptyString()][string]$Text, [AllowNull()][hashtable]$SectionLink)

    $html = ConvertTo-HtmlText $Text
    if (-not $SectionLink -or -not $SectionLink.Count) { return $html }
    foreach ($title in $SectionLink.Keys) {
        $quoted = ConvertTo-HtmlText ('"{0}"' -f $title)
        if ($html.Contains($quoted)) {
            $html = $html.Replace($quoted, ('<a class="jump" href="#{0}">{1}</a>' -f $SectionLink[$title], (ConvertTo-HtmlText $title)))
        }
    }
    $html
}

function Get-ReportFindingLink {
    <#
    .SYNOPSIS
        Which Findings a text can point at by name: the title of a Finding to where it
        stands in the Report. Pure.
    .DESCRIPTION
        "Wie lange der Rechner laeuft, sagt der Befund "Laufzeit seit Neustart"" left the
        reader to look for it. A text names a Finding by its title, in quotation marks,
        and the Report makes a link of it - in what a Finding found, what it means and
        what to do, and in the cell of a table.
 
        Only a title one Finding alone carries, as with the Sections: "Programm update"
        twice is two Findings, and a link to the first could be a link to the wrong one.
    #>

    [CmdletBinding()]
    [OutputType([hashtable])]
    param([AllowNull()][AllowEmptyCollection()]$Finding, [AllowNull()][hashtable]$Word)

    $seen = @{}
    foreach ($f in @($Finding | Where-Object { $_ })) {
        $title = "$($f.Check)".Trim()
        if ($title) { $seen[$title] = 1 + [int]$seen[$title] }
    }
    $link = @{}
    foreach ($f in @($Finding | Where-Object { $_ })) {
        $title = "$($f.Check)".Trim()
        if (-not $title -or $seen[$title] -ne 1) { continue }
        $anchor = Get-ReportFindingAnchor -Finding $f -Word $Word
        if ($anchor) { $link[$title] = $anchor }
    }
    $link
}

function Add-ReportFindingLink {
    <#
    .SYNOPSIS
        A piece of the Report with every Finding it names in quotation marks made a link
        to that Finding. Pure.
    .DESCRIPTION
        Works on what is already HTML: a title is looked for as it is written there. A
        Finding is not linked to itself - that is -Self, the title of the Finding the
        piece belongs to.
    #>

    [CmdletBinding()]
    [OutputType([string])]
    param([AllowNull()][AllowEmptyString()][string]$Html, [AllowNull()][hashtable]$Word, [AllowNull()][AllowEmptyString()][string]$Self)

    $link = if ($Word) { $Word['FindingLink'] }
    if (-not $Html -or -not $link -or -not $link.Count) { return $Html }
    foreach ($title in @($link.Keys)) {
        if ($title -eq "$Self".Trim()) { continue }
        $quoted = ConvertTo-HtmlText ('"{0}"' -f $title)
        if ($Html.Contains($quoted)) {
            $Html = $Html.Replace($quoted, ('<a class="jump" href="#{0}">{1}</a>' -f $link[$title], (ConvertTo-HtmlText $title)))
        }
    }
    $Html
}

function ConvertTo-ReportHintHtml {
    <#
    .SYNOPSIS
        A Finding's Hint as the list of things to do. Nothing for an OK Finding.
    #>

    [CmdletBinding()]
    [OutputType([string])]
    param([AllowNull()]$Finding, [string]$Heading, [AllowNull()][hashtable]$SectionLink)

    # An OK Finding has nothing for a Technician to act on, so it shows no Hint. The
    # constructor already drops it, but Findings merged from the Elevated Part arrive
    # over CliXML without passing through it.
    if ("$($Finding.Severity)" -eq 'OK') { return '' }
    $steps = @(Split-ReportStatement -Text "$($Finding.Hint)")
    if (-not $steps.Count) { return '' }

    '<div class="todo"><h4>{0}</h4><ul>{1}</ul></div>' -f (ConvertTo-HtmlText $Heading),
        (($steps | ForEach-Object { '<li>{0}</li>' -f (ConvertTo-ReportHintTextHtml -Text $_ -SectionLink $SectionLink) }) -join '')
}

function ConvertTo-ReportMeaningHtml {
    <#
    .SYNOPSIS
        What a Finding means, under its own heading on a card and as a line of its own
        among every Check. Nothing for an OK Finding, and nothing where there is none.
    #>

    [CmdletBinding()]
    [OutputType([string])]
    param([AllowNull()]$Finding, [string]$Heading, [switch]$Line)

    # The constructor drops it on an OK Finding; what came over CliXML did not pass it.
    if ("$($Finding.Severity)" -eq 'OK') { return '' }
    $parts = @(Split-ReportStatement -Text "$(Get-DataProperty $Finding 'Meaning')")
    if (-not $parts.Count) { return '' }

    $text = ($parts | ForEach-Object { ConvertTo-HtmlText $_ }) -join ' &middot; '
    if ($Line) {
        $label = ''
        if ($Heading) { $label = '<span class="k">{0}</span>' -f (ConvertTo-HtmlText $Heading) }
        return '<div class="meaning">{0}{1}</div>' -f $label, $text
    }
    '<div class="meaning"><h4>{0}</h4><p>{1}</p></div>' -f (ConvertTo-HtmlText $Heading), $text
}

function ConvertTo-ReportPrivilegeHtml {
    [CmdletBinding()]
    [OutputType([string])]
    param([AllowNull()][AllowEmptyString()][string]$Privilege, [string]$Title)

    $class = 'chip'
    if ($Privilege -eq 'admin') { $class = 'chip admin' }
    '<span class="{0}" title="{1}">{2}</span>' -f $class, (ConvertTo-HtmlText $Title), (ConvertTo-HtmlText $Privilege)
}

function Get-ReportFindingAnchor {
    <#
    .SYNOPSIS
        Where in the Report a Finding can be linked to: its card if it has one, else its
        line among every Check. Pure.
    #>

    [CmdletBinding()]
    [OutputType([string])]
    param([AllowNull()]$Finding, [AllowNull()][hashtable]$Word, [switch]$Line)

    $index = if ($Word) { $Word['FindingIndex'] }
    if (-not $index -or $null -eq $Finding -or -not $index.ContainsKey($Finding)) { return '' }
    $cards = $Word['FindingCard']
    if (-not $Line -and $cards -and $cards.Contains($Finding)) { return 'f-{0}' -f $index[$Finding] }
    'r-{0}' -f $index[$Finding]
}

function Resolve-ReportReference {
    <#
    .SYNOPSIS
        The Sections of this Report that a Finding refers to: title and the id of the fold. Pure.
    .DESCRIPTION
        A Reference to a Section that is not in the Report - its Check was skipped, or
        needed rights this Run did not have - resolves to nothing and leaves nothing
        behind. So does one to a title several Sections carry: a link to the first of
        five would be a link to the wrong one.
    #>

    [CmdletBinding()]
    [OutputType([psobject])]
    param([AllowNull()]$Finding, [AllowNull()][hashtable]$SectionLink)

    if (-not $SectionLink -or -not $SectionLink.Count) { return }
    $seen = @{}
    foreach ($reference in (Get-DataCollection $Finding 'References')) {
        if ("$(Get-DataProperty $reference 'Target')" -ne 'Section') { continue }
        $title = "$(Get-DataProperty $reference 'Title')".Trim()
        if (-not $title -or $seen[$title] -or -not $SectionLink.ContainsKey($title)) { continue }
        $seen[$title] = $true
        [pscustomobject]@{ Title = $title; Id = $SectionLink[$title] }
    }
}

function Resolve-ReportFindingReference {
    <#
    .SYNOPSIS
        The other Findings of this Report that a Finding refers to, by Signal. Pure.
    .DESCRIPTION
        Every Finding that carries the Signal, never the one that asks. Narrowed to the
        same subject where the Reference says so, and a Finding without a subject is then
        about nothing in particular and is not one of them.
    #>

    [CmdletBinding()]
    [OutputType([psobject])]
    param([AllowNull()]$Finding, [AllowNull()][AllowEmptyCollection()]$All)

    $found = New-Object 'System.Collections.Generic.List[object]'
    foreach ($reference in (Get-DataCollection $Finding 'References')) {
        if ("$(Get-DataProperty $reference 'Target')" -ne 'Signal') { continue }
        $signal  = "$(Get-DataProperty $reference 'Signal')"
        $subject = "$(Get-DataProperty $Finding 'Subject')"
        foreach ($other in @($All | Where-Object { $_ })) {
            if ([object]::ReferenceEquals($other, $Finding) -or $found.Contains($other)) { continue }
            if ($signal -notin (Get-DataCollection $other 'Signals')) { continue }
            # As a Lead compares them: FS01 and fs01.contoso-example.de are one Server.
            if ((Get-DataProperty $reference 'SameSubject') -and (-not $subject -or
                    (ConvertTo-LeadSubject "$(Get-DataProperty $other 'Subject')") -ne (ConvertTo-LeadSubject $subject))) { continue }
            $found.Add($other)
        }
    }
    $found.ToArray()
}

function ConvertTo-ReportReferenceHtml {
    <#
    .SYNOPSIS
        What a Finding refers to, as links under it. Nothing where it refers to nothing
        that is in this Report.
    #>

    [CmdletBinding()]
    [OutputType([string])]
    param([AllowNull()]$Finding, [AllowNull()][hashtable]$Word)

    $links = @(Resolve-ReportReference -Finding $Finding -SectionLink (Get-DataProperty $Word 'SectionLink') | ForEach-Object {
        '<a class="jump" href="#{0}">{1}</a>' -f $_.Id, (ConvertTo-HtmlText $_.Title)
    })
    $html = ''
    if ($links.Count) {
        $html = '<div class="refs"><span class="k">{0}</span>{1}</div>' -f (ConvertTo-HtmlText "$(Get-DataProperty $Word 'References')"), ($links -join '')
    }

    # The Lead it belongs to comes first: that is where the combined instruction is.
    if ($Word -and $Word['FindingLead'] -and $null -ne $Finding -and $Word['FindingLead'].ContainsKey($Finding)) {
        $of = @($Word['FindingLead'][$Finding] | ForEach-Object { '<a class="jump" href="#lead-{0}">{1}</a>' -f $_.Index, (ConvertTo-HtmlText $_.Title) })
        $html = ('<div class="refs leadof"><span class="k">{0}</span>{1}</div>' -f (ConvertTo-HtmlText "$(Get-DataProperty $Word 'LeadOf')"), ($of -join '')) + $html
    }

    $others = @()
    if ($Word -and $Word['Findings']) { $others = @(Resolve-ReportFindingReference -Finding $Finding -All $Word['Findings']) }
    $also = @($others | ForEach-Object {
        $target = Get-ReportFindingAnchor -Finding $_ -Word $Word
        if ($target) { '<a class="jump" href="#{0}">{1}</a>' -f $target, (ConvertTo-HtmlText "$($_.Check)") }
    })
    if ($also.Count) {
        $html += '<div class="refs also"><span class="k">{0}</span>{1}</div>' -f (ConvertTo-HtmlText "$(Get-DataProperty $Word 'SeeAlso')"), ($also -join '')
    }
    $html
}

function Get-ReportSectionReferrer {
    <#
    .SYNOPSIS
        For each Section title, the Findings that refer to it: by a declared Reference, or
        by naming it in quotation marks in their Hint. Pure.
    #>

    [CmdletBinding()]
    [OutputType([hashtable])]
    param([AllowNull()][AllowEmptyCollection()]$Finding, [AllowNull()][hashtable]$SectionLink)

    $referrer = @{}
    if (-not $SectionLink -or -not $SectionLink.Count) { return $referrer }
    foreach ($f in @($Finding | Where-Object { $_ })) {
        $titles = New-Object System.Collections.Generic.List[string]
        foreach ($resolved in @(Resolve-ReportReference -Finding $f -SectionLink $SectionLink)) { $titles.Add($resolved.Title) }
        # An OK Finding shows no Hint, so nothing it says there leads anywhere.
        if ("$($f.Severity)" -ne 'OK' -and "$($f.Hint)") {
            foreach ($title in $SectionLink.Keys) {
                if ("$($f.Hint)".Contains(('"{0}"' -f $title)) -and -not $titles.Contains($title)) { $titles.Add($title) }
            }
        }
        foreach ($title in $titles) {
            if (-not $referrer.ContainsKey($title)) { $referrer[$title] = New-Object System.Collections.Generic.List[object] }
            $referrer[$title].Add($f)
        }
    }
    $referrer
}

function ConvertTo-ReportCopyText {
    <#
    .SYNOPSIS
        A Finding as the few lines somebody pastes into a ticket. Pure.
    .DESCRIPTION
        Severity, what was checked and where it belongs; what it said; and the note, where
        there is one to act on. The machine's name leads, because a ticket is read by
        somebody who was not at the machine.
    #>

    [CmdletBinding()]
    [OutputType([string])]
    param([Parameter(Mandatory)]$Finding, [AllowNull()][hashtable]$Word)

    $lines = New-Object System.Collections.Generic.List[string]
    $head = (Get-Text 'Report.Copy.Head') -f "$($Finding.Severity)", "$($Finding.Check)", (Get-ReportCategoryTitle -Category "$($Finding.Category)")
    if ($Word -and $Word.ComputerName) { $head = '{0}: {1}' -f $Word.ComputerName, $head }
    $lines.Add($head)
    if ("$($Finding.Value)") { $lines.Add("$($Finding.Value)") }
    $meaning = "$(Get-DataProperty $Finding 'Meaning')"
    if ("$($Finding.Severity)" -ne 'OK' -and $meaning) {
        $lines.Add(((Get-Text 'Report.Copy.Hint') -f $(if ($Word -and $Word.Meaning) { $Word.Meaning } else { Get-Text 'Report.Label.Meaning' }), $meaning))
    }
    if ("$($Finding.Severity)" -ne 'OK' -and "$($Finding.Hint)") {
        $lines.Add(((Get-Text 'Report.Copy.Hint') -f $(if ($Word -and $Word.Todo) { $Word.Todo } else { Get-Text 'Report.Label.Todo' }), "$($Finding.Hint)"))
    }
    $lines -join "`n"
}

function ConvertTo-ReportCopyHtml {
    <#
    .SYNOPSIS
        The button that copies one Finding. Nothing where the words for it are missing.
    #>

    [CmdletBinding()]
    [OutputType([string])]
    param([Parameter(Mandatory)]$Finding, [AllowNull()][hashtable]$Word)

    if (-not $Word -or -not $Word.Copy) { return '' }
    # A line break in an attribute is written as its number, or a browser makes a space of it.
    $text = (ConvertTo-HtmlText (ConvertTo-ReportCopyText -Finding $Finding -Word $Word)) -replace "`r?`n", '&#10;'
    '<button type="button" class="copy" title="{0}" data-done="{1}" data-copy="{2}">{3}</button>' -f `
        (ConvertTo-HtmlText $Word.CopyTitle), (ConvertTo-HtmlText $Word.Copied), $text, (ConvertTo-HtmlText $Word.Copy)
}

function ConvertTo-ReportCardHtml {
    <#
    .SYNOPSIS
        One Finding that needs doing something about, in full.
    #>

    [CmdletBinding()]
    [OutputType([string])]
    param([Parameter(Mandatory)]$Finding, [hashtable]$Word)

    $anchor = Get-ReportFindingAnchor -Finding $Finding -Word $Word
    if ($anchor) { $anchor = ' id="{0}"' -f $anchor }

    '<article class="card {0}"{7}><header><span class="badge">{0}</span><h3>{1}</h3><span class="chip">{2}</span>{3}</header>{4}{5}{6}</article>' -f `
        $Finding.Severity,
        (ConvertTo-ReportTitleHtml -InputObject $Finding -Text "$($Finding.Check)"),
        (ConvertTo-HtmlText (Get-ReportCategoryTitle -Category "$($Finding.Category)")),
        ((ConvertTo-ReportPrivilegeHtml -Privilege "$($Finding.Privilege)" -Title $Word.PrivilegeTitle) + (ConvertTo-ReportCopyHtml -Finding $Finding -Word $Word)),
        (Add-ReportFindingLink -Word $Word -Self "$($Finding.Check)" -Html (ConvertTo-ReportValueHtml -Value "$($Finding.Value)" -VerdictLabel $Word.VerdictLabel)),
        (Add-ReportFindingLink -Word $Word -Self "$($Finding.Check)" -Html ((ConvertTo-ReportMeaningHtml -Finding $Finding -Heading $Word.Meaning) + (ConvertTo-ReportHintHtml -Finding $Finding -Heading $Word.Todo -SectionLink $Word.SectionLink))),
        (ConvertTo-ReportReferenceHtml -Finding $Finding -Word $Word),
        $anchor
}

function ConvertTo-ReportRowHtml {
    <#
    .SYNOPSIS
        One Finding as a line in its Category: what was checked and what it said.
    #>

    [CmdletBinding()]
    [OutputType([string])]
    param([Parameter(Mandatory)]$Finding, [AllowNull()][hashtable]$SectionLink, [AllowNull()][hashtable]$Word)

    $lines = (Split-ReportStatement -Text "$($Finding.Value)" | ForEach-Object {
        $names = @(Split-ReportCountedList -Statement $_)
        if ($names.Count) { '<div><span class="count">{0}</span>{1}</div>' -f $names.Count, (ConvertTo-ReportCountedListHtml -Name $names) }
        else { '<div>{0}</div>' -f (ConvertTo-HtmlText $_) }
    }) -join ''

    $hint = ''
    if ("$($Finding.Severity)" -ne 'OK' -and "$($Finding.Hint)") {
        # Labelled as on the card: with two texts on a line, which is which has to be said.
        $label = ''
        if ($Word -and $Word.Todo) { $label = '<span class="k">{0}</span>' -f (ConvertTo-HtmlText $Word.Todo) }
        $hint = '<div class="hint">{0}{1}</div>' -f $label, ((Split-ReportStatement -Text "$($Finding.Hint)" |
            ForEach-Object { ConvertTo-ReportHintTextHtml -Text $_ -SectionLink $SectionLink }) -join ' &middot; ')
    }

    $anchor = Get-ReportFindingAnchor -Finding $Finding -Word $Word -Line
    if ($anchor) { $anchor = ' id="{0}"' -f $anchor }

    # What it found, means and asks for may each name another Finding.
    $self    = "$($Finding.Check)"
    $lines   = Add-ReportFindingLink -Word $Word -Self $self -Html $lines
    $hint    = Add-ReportFindingLink -Word $Word -Self $self -Html $hint
    $meaning = Add-ReportFindingLink -Word $Word -Self $self -Html (ConvertTo-ReportMeaningHtml -Finding $Finding -Line -Heading "$(if ($Word) { $Word.Meaning })")

    '<div class="row {0}"{6}><span class="dot" title="{0}"></span><div class="what"><b>{1}</b>{4}</div><div class="val">{2}{7}{3}{5}</div></div>' -f `
        $Finding.Severity, (ConvertTo-HtmlText $Finding.Check), $lines, $hint, (ConvertTo-ReportCopyHtml -Finding $Finding -Word $Word),
        (ConvertTo-ReportReferenceHtml -Finding $Finding -Word $Word), $anchor, $meaning
}

function ConvertTo-ReportCategoryHtml {
    <#
    .SYNOPSIS
        Every Finding of one Category, folded, and inside it by application.
    .DESCRIPTION
        The Checks on the machine itself come first, then each application under its own
        name, the one with the worst Finding first. A Category of 130 Findings is only a
        list a Technician can read once it says whose each is.
    #>

    [CmdletBinding()]
    [OutputType([string])]
    param(
        [Parameter(Mandatory)][string]$Category,
        [Parameter(Mandatory)][AllowEmptyCollection()]$Finding,
        [hashtable]$Word
    )

    $list  = @(Sort-Finding -Finding $Finding)
    $order = @{}
    for ($i = 0; $i -lt $script:SeverityValues.Count; $i++) { $order[$script:SeverityValues[$i]] = $i }
    $worst = "$($list[0].Severity)"

    $badges = foreach ($severity in $script:SeverityValues) {
        $n = @($list | Where-Object { $_.Severity -eq $severity }).Count
        if ($n) { '<span class="badge {0}">{1} {0}</span>' -f $severity, $n }
    }

    $general = @($list | Where-Object { -not "$(Get-DataProperty $_ 'App')".Trim() })
    $rows    = @($general | ForEach-Object { ConvertTo-ReportRowHtml -Finding $_ -SectionLink $Word.SectionLink -Word $Word })

    $apps = @($list | Where-Object { "$(Get-DataProperty $_ 'App')".Trim() } |
        Group-Object { "$(Get-DataProperty $_ 'App')".Trim() } |
        Sort-Object @{ Expression = { $order[[string](@(Sort-Finding -Finding $_.Group)[0].Severity)] } }, Name)
    foreach ($app in $apps) {
        $rows += '<div class="subject">{0}</div>' -f (ConvertTo-HtmlText $app.Name)
        $rows += @(Sort-Finding -Finding $app.Group | ForEach-Object { ConvertTo-ReportRowHtml -Finding $_ -SectionLink $Word.SectionLink -Word $Word })
    }

    '<details class="group"><summary class="{0}"><span class="dot"></span><b>{1}</b>{2}</summary>{3}</details>' -f `
        $worst, (ConvertTo-HtmlText (Get-ReportCategoryTitle -Category $Category)), ($badges -join ' '), ($rows -join "`n")
}

function ConvertTo-ReportTableHtml {
    <#
    .SYNOPSIS
        A Section's rows as a table, with a long cell folded to three lines.
    .DESCRIPTION
        Built here rather than by ConvertTo-Html, which knows nothing of a cell that holds
        a whole event message: shown in full it makes a table nobody can scan, and cut
        short it ends in the middle of a sentence. Folded, it is all there - a click opens
        it, and on paper it is printed open.
    #>

    [CmdletBinding()]
    [OutputType([string])]
    param([AllowNull()][AllowEmptyCollection()]$Row, [string]$MoreTitle, [string]$HighlightTitle,
          # For the Findings a cell names in quotation marks. See Add-ReportFindingLink.
          [AllowNull()][hashtable]$Word)

    $rows = @($Row | Where-Object { $null -ne $_ })
    if (-not $rows.Count) { return '' }

    # The columns of the first row, like ConvertTo-Html. Rows that came back from the
    # Elevated Part are property bags, and a hashtable row has keys instead.
    # A column whose name begins with an underscore is a note to this function and is
    # not shown: _Highlight names the column whose cell, in that row, is set apart.
    $names = @(@(if ($rows[0] -is [System.Collections.IDictionary]) { $rows[0].Keys } else { $rows[0].PSObject.Properties.Name }) |
        Where-Object { "$_" -notlike '_*' })

    $head = ($names | ForEach-Object { '<th>{0}</th>' -f (ConvertTo-HtmlText $_) }) -join ''
    $body = foreach ($row in $rows) {
        $highlight = "$(if ($row -is [System.Collections.IDictionary]) { $row['_Highlight'] } else { Get-DataProperty $row '_Highlight' })"
        $cells = foreach ($name in $names) {
            $value = if ($row -is [System.Collections.IDictionary]) { $row[$name] } else { Get-DataProperty $row $name }
            if ($value -is [System.Collections.IEnumerable] -and $value -isnot [string]) { $value = @($value) -join ', ' }
            $text = "$value"
            if ($text.Length -gt $script:ReportLongCellLength) {
                '<td class="haslong"><div class="long" title="{0}">{1}</div></td>' -f (ConvertTo-HtmlText $MoreTitle), (Add-ReportFindingLink -Word $Word -Html (ConvertTo-HtmlText $text))
            }
            elseif ($highlight -and "$name" -eq $highlight -and $text) {
                '<td class="hl" title="{0}">{1}</td>' -f (ConvertTo-HtmlText $HighlightTitle), (ConvertTo-HtmlText $text)
            }
            else { '<td>{0}</td>' -f (Add-ReportFindingLink -Word $Word -Html (ConvertTo-HtmlText $text)) }
        }
        '<tr>{0}</tr>' -f ($cells -join '')
    }
    "<table><tr>{0}</tr>`n{1}</table>" -f $head, ($body -join "`n")
}

function ConvertTo-ReportLeadHtml {
    <#
    .SYNOPSIS
        One Lead: what is suspected, the Findings it rests on, and what to do.
    .DESCRIPTION
        Not a card: a card is a Finding, with a Severity, and is counted. A Lead is a
        reading of Findings that are already counted, and looks like nothing they do.
    #>

    [CmdletBinding()]
    [OutputType([string])]
    param([Parameter(Mandatory)]$Lead, [hashtable]$Word, [int]$Index)

    $links = @((Get-DataCollection $Lead 'Findings') | ForEach-Object {
        $target = Get-ReportFindingAnchor -Finding $_ -Word $Word
        if ($target) { '<a class="jump" href="#{0}">{1}</a>' -f $target, (ConvertTo-HtmlText "$($_.Check)") }
        else { '<span>{0}</span>' -f (ConvertTo-HtmlText "$($_.Check)") }
    })
    $steps = @(Split-ReportStatement -Text "$(Get-DataProperty $Lead 'Hint')")
    $todo = ''
    if ($steps.Count) {
        $todo = '<div class="todo"><h4>{0}</h4><ul>{1}</ul></div>' -f (ConvertTo-HtmlText "$($Word.Todo)"),
            (($steps | ForEach-Object { '<li>{0}</li>' -f (ConvertTo-HtmlText $_) }) -join '')
    }

    $unchecked = ConvertTo-ReportNotCheckedHtml -Lead $Lead -Word $Word

    '<article class="leadbox" id="lead-{0}"><h3>{1}</h3><div class="refs"><span class="k">{2}</span>{3}</div>{5}{4}</article>' -f `
        $Index, (ConvertTo-HtmlText "$(Get-DataProperty $Lead 'Title')"), (ConvertTo-HtmlText "$($Word.RestsOn)"), ($links -join ''), $todo, $unchecked
}

function ConvertTo-ReportNotCheckedHtml {
    <#
    .SYNOPSIS
        What could have supported a Lead and was not looked at, each with why. Nothing
        where every Check that could have looked did.
    #>

    [CmdletBinding()]
    [OutputType([string])]
    param([Parameter(Mandatory)]$Lead, [hashtable]$Word)

    $items = @((Get-DataCollection $Lead 'NotChecked') | ForEach-Object {
        '<span>{0}</span>' -f (ConvertTo-HtmlText ((Get-Text 'Report.Lead.NotCheckedItem') -f "$(Get-DataProperty $_ 'Check')", "$(Get-DataProperty $_ 'Reason')"))
    })
    if (-not $items.Count) { return '' }
    '<div class="refs unchecked"><span class="k">{0}</span>{1}</div>' -f (ConvertTo-HtmlText "$($Word.NotChecked)"), ($items -join '')
}

function ConvertTo-ReportLeadGapHtml {
    <#
    .SYNOPSIS
        What could not become a Lead because nobody looked for the second observation.
    .DESCRIPTION
        One line, and no box: there is nothing to act on but the one Finding, which says
        what to do itself. What the line is for is that an empty place where a Lead would
        stand is not read as "nothing points to this".
    #>

    [CmdletBinding()]
    [OutputType([string])]
    param([Parameter(Mandatory)]$Lead, [hashtable]$Word)

    $seen = @((Get-DataCollection $Lead 'Findings') | ForEach-Object {
        $target = Get-ReportFindingAnchor -Finding $_ -Word $Word
        if ($target) { '<a class="jump" href="#{0}">{1}</a>' -f $target, (ConvertTo-HtmlText "$($_.Check)") }
        else { '<span>{0}</span>' -f (ConvertTo-HtmlText "$($_.Check)") }
    })
    $missing = @((Get-DataCollection $Lead 'NotChecked') | ForEach-Object {
        '<span>{0}</span>' -f (ConvertTo-HtmlText ((Get-Text 'Report.Lead.NotCheckedItem') -f "$(Get-DataProperty $_ 'Check')", "$(Get-DataProperty $_ 'Reason')"))
    })
    '<div class="leadgap"><b>{0}</b>{1}<span>{2}</span>{3}</div>' -f `
        (ConvertTo-HtmlText ((Get-Text 'Report.Lead.Gap') -f "$(Get-DataProperty $Lead 'Title')")), ($seen -join ''),
        (ConvertTo-HtmlText (Get-Text 'Report.Lead.GapMissing')), ($missing -join '')
}

function ConvertTo-ReportAlertHtml {
    <#
    .SYNOPSIS
        A Finding that puts the whole Report in doubt, as a banner nobody reads past.
    .DESCRIPTION
        One Finding is not about the machine but about the Run: it was started in another
        account than the user's, and everything it says about the user's profile - mail
        file, network drives, shortcuts, processes - is about the wrong one. As one WARN
        card among fifteen it was read as one more thing wrong with the PC. It stands
        directly under the machine's box, looks like nothing else on the page, and is
        not repeated among the cards.
    #>

    [CmdletBinding()]
    [OutputType([string])]
    param([Parameter(Mandatory)]$Finding)

    $then = (@(Split-ReportStatement -Text "$($Finding.Hint)") | ForEach-Object { ConvertTo-HtmlText $_ }) -join ' &middot; '
    '<div class="alert" role="alert"><div class="mark">!</div><h3>{0}</h3><p>{1}</p><p class="then">{2}</p></div>' -f `
        (ConvertTo-HtmlText "$($Finding.Check)"), (ConvertTo-HtmlText "$($Finding.Value)"), $then
}

function ConvertTo-ReportTopHtml {
    <#
    .SYNOPSIS
        A Section placed at the top as a box: its rows by Group, each a name and a value.
    .DESCRIPTION
        Open, because it is what a reader wants first - which machine, which Windows -
        and foldable, because the fifth Report of the same machine does not need it. A
        value that names a piece of evidence in quotation marks links to it, as a Hint does.
    #>

    [CmdletBinding()]
    [OutputType([string])]
    param([Parameter(Mandatory)]$Section, [hashtable]$Word = @{})

    $order  = New-Object 'System.Collections.Generic.List[string]'
    $groups = @{}
    foreach ($row in @($Section.Row | Where-Object { $_ })) {
        $group = "$(Get-DataProperty $row 'Group')"
        if (-not $groups.ContainsKey($group)) { $order.Add($group); $groups[$group] = New-Object 'System.Collections.Generic.List[string]' }
        $groups[$group].Add(('<dt>{0}</dt><dd>{1}</dd>' -f (ConvertTo-HtmlText "$(Get-DataProperty $row 'Name')"),
            (ConvertTo-ReportHintTextHtml -Text "$(Get-DataProperty $row 'Value')" -SectionLink $Word.SectionLink)))
    }
    if (-not $order.Count) { return '' }

    $blocks = foreach ($group in $order) {
        '<section><h4>{0}</h4><dl>{1}</dl></section>' -f (ConvertTo-HtmlText $group), ($groups[$group] -join '')
    }
    '<details class="sysinfo" open><summary>{0}</summary><div class="sysgrid">{1}</div></details>' -f `
        (ConvertTo-HtmlText "$($Section.Title)"), ($blocks -join '')
}

function Get-ReportCategoryTitle {
    <#
    .SYNOPSIS
        What a Category is called in the Report. Pure.
    .DESCRIPTION
        A Category stands under its own name, as it is in findings.csv and in a Check
        Definition's documentation: Storage, Network. The one that does not is User, which
        a Technician would read as a column of a table and not as a person; it is
        "Benutzer". Only the Report says so. In findings.csv it is User, like every
        Category there is the value and not a word for it.
    #>

    [CmdletBinding()]
    [OutputType([string])]
    param([AllowNull()][AllowEmptyString()][string]$Category)

    switch ("$Category".Trim()) {
        'User'  { return Get-Text 'Report.Category.User' }
        default { return "$Category" }
    }
}

function Get-ReportKindTitle {
    <#
    .SYNOPSIS
        What the evidence of one Kind stands under. Pure.
    .DESCRIPTION
        A Kind this Report has no word for stands under its own name: a Kind added later
        is then still grouped, and it shows that the word is missing. Evidence that names
        no Kind - it came from somewhere other than a Check - stands under "Weitere".
    #>

    [CmdletBinding()]
    [OutputType([string])]
    param([AllowNull()][AllowEmptyString()][string]$Kind)

    switch ("$Kind".Trim()) {
        'App'               { return Get-Text 'Report.Kind.App' }
        'AppConnection'     { return Get-Text 'Report.Kind.AppConnection' }
        'AppLog'            { return Get-Text 'Report.Kind.AppLog' }
        'Autodiscover'      { return Get-Text 'Report.Kind.Autodiscover' }
        'BootPerformance'   { return Get-Text 'Report.Kind.BootPerformance' }
        # Not a heading of its own: what changed on the machine is read with what it has
        # been doing since, and stands with the Stability Kind's evidence.
        'Change'            { return Get-Text 'Report.Kind.Stability' }
        'Cpu'               { return Get-Text 'Report.Kind.Cpu' }
        'CrashDump'         { return Get-Text 'Report.Kind.CrashDump' }
        'DotNet'            { return Get-Text 'Report.Kind.DotNet' }
        'GroupPolicy'       { return Get-Text 'Report.Kind.GroupPolicy' }
        'HttpsEndpoint'     { return Get-Text 'Report.Kind.HttpsEndpoint' }
        'Memory'            { return Get-Text 'Report.Kind.Memory' }
        'Network'           { return Get-Text 'Report.Kind.Network' }
        'NetworkDependency' { return Get-Text 'Report.Kind.NetworkDependency' }
        'NetworkDisruption' { return Get-Text 'Report.Kind.NetworkDisruption' }
        'Odbc'              { return Get-Text 'Report.Kind.Odbc' }
        'OfficeAddins'      { return Get-Text 'Report.Kind.OfficeAddins' }
        'OutlookAuth'       { return Get-Text 'Report.Kind.OutlookAuth' }
        'Printing'          { return Get-Text 'Report.Kind.Printing' }
        'Program'           { return Get-Text 'Report.Kind.Program' }
        'Security'          { return Get-Text 'Report.Kind.Security' }
        'Server'            { return Get-Text 'Report.Kind.Server' }
        'Service'           { return Get-Text 'Report.Kind.Service' }
        'Session'           { return Get-Text 'Report.Kind.Session' }
        'Stability'         { return Get-Text 'Report.Kind.Stability' }
        'Startup'           { return Get-Text 'Report.Kind.Startup' }
        'Stress'            { return Get-Text 'Report.Kind.Stress' }
        'System'            { return Get-Text 'Report.Kind.System' }
        'UserProfile'       { return Get-Text 'Report.Kind.UserProfile' }
        'UserSetting'       { return Get-Text 'Report.Kind.UserSetting' }
        ''                  { return Get-Text 'Report.Kind.Other' }
        default             { return "$Kind".Trim() }
    }
}

function ConvertTo-ReportEvidenceHtml {
    <#
    .SYNOPSIS
        Every Section, grouped by the Kind of the Check that produced it.
    .DESCRIPTION
        Fifty folds in the order the Checks ran put an application's processes between
        the printers and the proxy. Under its Kind, what belongs together stands together:
        every application's processes, every application's Servers. Kinds stand in the
        order the Run first met them, and the Sections of each in the order they came.
    #>

    [CmdletBinding()]
    [OutputType([string])]
    param([AllowNull()][AllowEmptyCollection()]$Section, [hashtable]$Word = @{})

    # By what the Kind stands under, and not by the Kind: two Kinds may stand under one
    # heading - the Changes with the Stability Kind's evidence - and are then one group.
    $titles  = New-Object System.Collections.Generic.List[string]
    $byTitle = @{}
    foreach ($s in @($Section | Where-Object { $_ })) {
        $title = Get-ReportKindTitle -Kind "$(Get-DataProperty $s 'Kind')".Trim()
        if (-not $byTitle.ContainsKey($title)) { $titles.Add($title); $byTitle[$title] = New-Object System.Collections.Generic.List[psobject] }
        $byTitle[$title].Add($s)
    }
    # What names no Kind goes last, whenever it turned up.
    $other = Get-ReportKindTitle -Kind ''
    if ($titles.Remove($other)) { $titles.Add($other) }

    foreach ($title in $titles) {
        $folds = foreach ($s in $byTitle[$title]) { ConvertTo-SectionHtml -Section $s -Word $Word -KindTitle $title }
        '<div class="kind"><div class="kindname">{0}<small>{1}</small></div>{2}</div>' -f `
            (ConvertTo-HtmlText $title), $byTitle[$title].Count, ($folds -join "`n")
    }
}

function ConvertTo-SectionHtml {
    <#
    .SYNOPSIS
        One Section, folded, under a title that says whose it is.
    #>

    [CmdletBinding()]
    [OutputType([string])]
    param([Parameter(Mandatory)]$Section, [hashtable]$Word = @{}, [string]$KindTitle = '')

    if ($Section.Text) {
        $body = '<pre>' + (ConvertTo-HtmlText ([string]$Section.Text).Trim()) + '</pre>'
    }
    else {
        $body = ConvertTo-ReportTableHtml -Row $Section.Row -MoreTitle "$($Word.More)" -HighlightTitle "$($Word.Targeted)" -Word $Word
    }

    $subject = Get-ReportSubject -InputObject $Section -Text "$($Section.Title)"
    $search  = ('{0} {1} {2} {3}' -f $subject, $Section.Title, (Get-DataProperty $Section 'CheckName'), $KindTitle).ToLowerInvariant()

    # The id a Hint that names this Section links to. See Get-ReportSectionLink.
    $id = ''
    $anchor = Get-DataProperty $Word 'SectionLink'
    if ($anchor -and $anchor.ContainsKey("$($Section.Title)".Trim())) { $id = ' id="{0}"' -f $anchor["$($Section.Title)".Trim()] }

    # Whose evidence this is: the Findings that sent a reader here, and the way back.
    $back = ''
    $referrer = Get-DataProperty $Word 'SectionReferrer'
    if ($referrer -and $referrer.ContainsKey("$($Section.Title)".Trim())) {
        $links = @($referrer["$($Section.Title)".Trim()] | ForEach-Object {
            $target = Get-ReportFindingAnchor -Finding $_ -Word $Word
            if ($target) { '<a class="jump" href="#{0}">{1}</a>' -f $target, (ConvertTo-HtmlText "$($_.Check)") }
        })
        if ($links.Count) { $back = '<div class="backrefs"><span class="k">{0}</span>{1}</div>' -f (ConvertTo-HtmlText "$($Word.ReferredBy)"), ($links -join '') }
    }

    '<details class="group"{4} data-title="{0}"><summary><b>{1}</b>{2}</summary>{5}<div class="evi">{3}</div></details>' -f `
        (ConvertTo-HtmlText $search),
        (ConvertTo-ReportTitleHtml -InputObject $Section -Text "$($Section.Title)"),
        (ConvertTo-ReportPrivilegeHtml -Privilege "$($Section.Privilege)" -Title "$($Word.PrivilegeTitle)"),
        $body, $id, $back
}

function ConvertTo-Report {
    <#
    .SYNOPSIS
        Renders a Run as the HTML document a Technician reads.
    #>

    [CmdletBinding()]
    [OutputType([string])]
    param(
        [AllowNull()][AllowEmptyCollection()]$Finding,
        [AllowNull()][AllowEmptyCollection()]$Section,
        [Parameter(Mandatory)]$Provenance,
        [Parameter(Mandatory)][string]$ComputerName,
        [datetime]$AsOf = (Get-Date),

        # What several Findings point to together. Not Findings, and in no count.
        [AllowNull()][AllowEmptyCollection()]$Lead
    )

    $sorted = @(Sort-Finding -Finding $Finding)

    # Read before the template rather than inside it: an expression inside a here-string
    # that throws leaves a half-rendered Report, and Get-Text throws on a missing key.
    $word = @{
        Todo           = Get-Text 'Report.Label.Todo'
        Meaning        = Get-Text 'Report.Label.Meaning'
        VerdictLabel   = Get-Text 'Report.Label.Verdict'
        PrivilegeTitle = Get-Text 'Report.Label.PrivilegeTitle'
        More           = Get-Text 'Report.Label.More'
        Targeted       = Get-Text 'Report.Label.Targeted'
        Copy           = Get-Text 'Report.Label.Copy'
        Copied         = Get-Text 'Report.Label.Copied'
        CopyTitle      = Get-Text 'Report.Label.CopyTitle'
        References     = Get-Text 'Report.Label.References'
        ReferredBy     = Get-Text 'Report.Label.ReferredBy'
        SeeAlso        = Get-Text 'Report.Label.SeeAlso'
        RestsOn        = Get-Text 'Report.Label.RestsOn'
        NotChecked     = Get-Text 'Report.Label.NotChecked'
        LeadOf         = Get-Text 'Report.Label.LeadOf'
        ComputerName   = $ComputerName
    }
    # What is placed at the top is a box there and not a fold among the evidence.
    $all      = @($Section | Where-Object { $_ })
    $topParts = @($all | Where-Object { "$(Get-DataProperty $_ 'Placement')" -eq 'Top' })
    $Section  = @($all | Where-Object { "$(Get-DataProperty $_ 'Placement')" -ne 'Top' })
    $word.SectionLink = Get-ReportSectionLink -Section $Section
    $topHtml  = @($topParts | ForEach-Object { ConvertTo-ReportTopHtml -Section $_ -Word $word }) -join "`n"
    $title           = ConvertTo-HtmlText (Get-Text 'Report.Title' $ComputerName)
    $tagline         = ConvertTo-HtmlText (Get-Text 'Report.Tagline')
    $company         = ConvertTo-HtmlText (Get-Text 'Report.Company')
    $generated       = ConvertTo-HtmlText (Get-Text 'Report.Generated' `
                          $AsOf.ToString('yyyy-MM-dd HH:mm') $PSVersionTable.PSVersion)
    $headingAction   = ConvertTo-HtmlText (Get-Text 'Report.Heading.Action')
    $headingChecks   = ConvertTo-HtmlText (Get-Text 'Report.Heading.Checks')
    $headingEvidence = ConvertTo-HtmlText (Get-Text 'Report.Heading.Evidence')
    $nothingToDo     = ConvertTo-HtmlText (Get-Text 'Report.Action.None')
    $expandAll       = ConvertTo-HtmlText (Get-Text 'Report.Button.ExpandAll')
    $collapseAll     = ConvertTo-HtmlText (Get-Text 'Report.Button.CollapseAll')
    $filterEvidence  = ConvertTo-HtmlText (Get-Text 'Report.Filter.Evidence')

    # Not $provenance: PowerShell variable names are case-insensitive, so that would
    # overwrite the $Provenance parameter and leave every later read of it empty.
    $machine        = ConvertTo-HtmlText $ComputerName
    $provenanceText = ConvertTo-HtmlText (Get-ProvenanceStatement -Provenance $Provenance -AsOf $AsOf)
    $moduleVersion  = ConvertTo-HtmlText $Provenance.ModuleVersion

    $tally = ($script:SeverityValues | ForEach-Object {
        $severity = $_
        '<div class="{0}"><b>{1}</b><small>{0}</small></div>' -f $severity, @($sorted | Where-Object { $_.Severity -eq $severity }).Count
    }) -join ''

    # 1. What needs doing: worst first, and every one of them, however many there are.
    # What puts the Report itself in doubt is a banner under the box, and no card.
    $alertCheck = Get-Text 'Check.Elevation.WrongUserContext'
    $alerts     = @($sorted | Where-Object { "$($_.Check)" -eq $alertCheck -and $_.Severity -in 'FAIL', 'WARN' })
    $alertHtml  = @($alerts | ForEach-Object { ConvertTo-ReportAlertHtml -Finding $_ }) -join "`n"
    $problems = @($sorted | Where-Object { $_.Severity -in 'FAIL', 'WARN' -and $alerts -notcontains $_ })

    # Where each Finding can be linked to, and which Section each sends a reader to. By
    # the Finding itself and not by its text: two Findings may read the same.
    $word.FindingIndex = New-Object 'System.Collections.Generic.Dictionary[object,int]'
    $word.FindingCard  = New-Object 'System.Collections.Generic.HashSet[object]'
    $n = 0
    foreach ($f in $sorted) { $n++; $word.FindingIndex[$f] = $n }
    foreach ($f in $problems) { $null = $word.FindingCard.Add($f) }
    $word.Findings = $sorted
    $word.FindingLink = Get-ReportFindingLink -Finding $sorted -Word $word
    $word.SectionReferrer = Get-ReportSectionReferrer -Finding $sorted -SectionLink $word.SectionLink

    # What several of them point to together, before any of them: the combined
    # instruction is the one to act on first. Nothing at all where there is no Lead.
    # A Lead from data of before says nothing of whether it formed, and did.
    $read  = @($Lead | Where-Object { $_ })
    $leads = @($read | Where-Object { $null -eq (Get-DataProperty $_ 'Formed') -or [bool](Get-DataProperty $_ 'Formed') })
    $gaps  = @($read | Where-Object { $leads -notcontains $_ })

    # Which Lead each Finding belongs to, so that its card can say so and lead there.
    $word.FindingLead = New-Object 'System.Collections.Generic.Dictionary[object,object]'
    $n = 0
    foreach ($lead in $leads) {
        $n++
        foreach ($f in (Get-DataCollection $lead 'Findings')) {
            if ($null -eq $f) { continue }
            if (-not $word.FindingLead.ContainsKey($f)) { $word.FindingLead[$f] = New-Object 'System.Collections.Generic.List[object]' }
            $word.FindingLead[$f].Add([pscustomobject]@{ Index = $n; Title = "$(Get-DataProperty $lead 'Title')" })
        }
    }

    $cards    = @($problems | ForEach-Object { ConvertTo-ReportCardHtml -Finding $_ -Word $word })
    if (-not $cards.Count) { $cards = @('<p class="none">{0}</p>' -f $nothingToDo) }

    $leadHtml = ''
    if ($read.Count) {
        $n = 0
        $leadHtml = '<h2>{0} &middot; {1}</h2>' -f (ConvertTo-HtmlText (Get-Text 'Report.Heading.Leads')), $leads.Count
        $leadHtml += "`n" + (@(
            $leads | ForEach-Object { $n++; ConvertTo-ReportLeadHtml -Lead $_ -Word $word -Index $n }
            $gaps  | ForEach-Object { ConvertTo-ReportLeadGapHtml -Lead $_ -Word $word }) -join "`n")
    }

    # 2. Every Check. The Category with the worst Finding first, as $sorted already has them.
    $categories = New-Object System.Collections.Generic.List[string]
    foreach ($f in $sorted) { if (-not $categories.Contains([string]$f.Category)) { $categories.Add([string]$f.Category) } }
    $groups = foreach ($category in $categories) {
        ConvertTo-ReportCategoryHtml -Category $category -Word $word `
            -Finding @($sorted | Where-Object { [string]$_.Category -eq $category })
    }

    # 3. The evidence.
    $sections    = @($Section | Where-Object { $_ })
    $sectionHtml = @(ConvertTo-ReportEvidenceHtml -Section $sections -Word $word)

    @"
<!DOCTYPE html><html lang="de"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1"><title>$title</title>$script:ReportCss</head><body>
<div class="brand"><span class="logo">gutcheck</span><span class="tag">$tagline</span><span class="ver">v$moduleVersion</span><span class="company">$company</span></div>
<div class="wrap">
<div class="hero"><div><h1>$machine</h1><div class="meta">$generated<br><span class="provenance">$provenanceText</span></div></div><div class="tally">$tally</div></div>
$topHtml
$alertHtml
$leadHtml
<h2>$headingAction &middot; $($problems.Count)</h2>
$($cards -join "`n")
<h2>$headingChecks &middot; $($sorted.Count)<span class="tools"><button type="button" data-fold="open" data-target="#checks">$expandAll</button><button type="button" data-fold="shut" data-target="#checks">$collapseAll</button></span></h2>
<div id="checks">
$($groups -join "`n")
</div>
<h2>$headingEvidence &middot; $($sections.Count)<span class="tools"><button type="button" data-fold="open" data-target="#evidence">$expandAll</button><button type="button" data-fold="shut" data-target="#evidence">$collapseAll</button></span></h2>
<input class="search" id="evidence-filter" type="search" placeholder="$filterEvidence">
<div id="evidence">
$($sectionHtml -join "`n")
</div>
<footer>$provenanceText</footer>
</div>
$script:ReportScript
</body></html>
"@

}

function Write-Report {
    <#
    .SYNOPSIS
        Writes a Run's Report and its CSV companions, and returns the Report's path.
    .DESCRIPTION
        The CSVs are semicolon-delimited because Technicians open them in German Excel.
        The events CSV is written even when a Run found no events: a missing file and a
        clean machine must not look the same on disk.
    #>

    [CmdletBinding()]
    [OutputType([string])]
    param(
        [AllowNull()][AllowEmptyCollection()]$Finding,
        [AllowNull()][AllowEmptyCollection()]$Section,
        [AllowNull()][AllowEmptyCollection()]$EventLogEntry,
        [Parameter(Mandatory)][string]$OutputPath,
        [Parameter(Mandatory)]$Provenance,
        [Parameter(Mandatory)][string]$ComputerName,
        [datetime]$AsOf = (Get-Date),
        [AllowNull()][AllowEmptyCollection()]$Lead
    )

    if (-not (Test-Path $OutputPath)) {
        New-Item -ItemType Directory -Path $OutputPath -Force | Out-Null
    }

    $reportPath = Join-Path $OutputPath 'report.html'
    ConvertTo-Report -Finding $Finding -Section $Section -Provenance $Provenance `
        -ComputerName $ComputerName -AsOf $AsOf -Lead $Lead | Out-File -FilePath $reportPath -Encoding utf8

    # App and CheckName last: whoever has a sheet built on the first six columns keeps it.
    @(Sort-Finding -Finding $Finding) |
        Select-Object Severity, Category, Check, Value, Hint, Privilege,
            @{ Name = 'App';       Expression = { Get-DataProperty $_ 'App' } },
            @{ Name = 'CheckName'; Expression = { Get-DataProperty $_ 'CheckName' } },
            # What the Finding refers to, whether or not it is in this Report: the file
            # is read by people who do not have the Report beside it.
            @{ Name = 'References'; Expression = { @((Get-DataCollection $_ 'References') | ForEach-Object {
                    # A Section by its title, other Findings by the Signal they carry.
                    if ("$(Get-DataProperty $_ 'Target')" -eq 'Signal') { 'Signal: {0}' -f (Get-DataProperty $_ 'Signal') } else { "$(Get-DataProperty $_ 'Title')" }
                } | Where-Object { $_ }) -join ' | ' } },
            # What it observed, by name: enough to read Findings together somewhere else.
            @{ Name = 'Signals'; Expression = { @((Get-DataCollection $_ 'Signals') | Where-Object { $_ }) -join ' | ' } },
            @{ Name = 'Meaning'; Expression = { Get-DataProperty $_ 'Meaning' } },
            # What a Lead for one Server goes by, and what was not looked for.
            @{ Name = 'Subject'; Expression = { Get-DataProperty $_ 'Subject' } },
            @{ Name = 'Unobserved'; Expression = { @((Get-DataCollection $_ 'Unobserved') | Where-Object { $_ }) -join ' | ' } } |
        Export-Csv -Path (Join-Path $OutputPath 'findings.csv') -NoTypeInformation -Encoding UTF8 -Delimiter ';'

    $events = @($EventLogEntry | Where-Object { $_ })
    if (-not $events.Count) {
        # Export-Csv writes nothing at all for an empty collection, which would leave the
        # Technician unable to tell "no events" from "the Run never got that far".
        'Time;Category;Provider;Id;Message' |
            Out-File -FilePath (Join-Path $OutputPath 'events.csv') -Encoding utf8
    }
    else {
        $events | Sort-Object Time -Descending |
            Select-Object Time, Category, Provider, Id, Message |
            Export-Csv -Path (Join-Path $OutputPath 'events.csv') -NoTypeInformation -Encoding UTF8 -Delimiter ';'
    }

    $reportPath
}