Public/ConvertTo-ServerFactsMarkdown.ps1

function Format-SqlServerInstanceBlock {
    [OutputType([string])]
    param([Parameter(Mandatory)][object]$Instance)

    $sb  = [System.Text.StringBuilder]::new()
    $w   = { param($s) [void]$sb.AppendLine($s) }
    $dot = [char]0x00B7
    $i   = $Instance

    & $w "### Instance: $($i.InstanceName) $dot $($i.Version) $($i.Edition) ($($i.PatchLevel))"
    & $w "**Collation:** $($i.Collation) $dot **Auth Mode:** $($i.AuthenticationMode) $dot **Clustered:** $(if ($i.IsClustered) { 'Yes' } else { 'No' })"
    if ($i.ServiceAccounts.Engine)  { & $w "**Engine Service:** $($i.ServiceAccounts.Engine.Account) ($($i.ServiceAccounts.Engine.StartMode), $($i.ServiceAccounts.Engine.State))" }
    if ($i.ServiceAccounts.Agent)   { & $w "**Agent Service:** $($i.ServiceAccounts.Agent.Account) ($($i.ServiceAccounts.Agent.StartMode), $($i.ServiceAccounts.Agent.State))" }
    & $w "**Features:** $(@($i.InstalledFeatures) -join ', ')"
    & $w "**Connection:** $($i.ConnectionMethod)"
    if ($i.ConnectionError) { & $w "$([char]0x26A0) **Connection error:** $($i.ConnectionError)" }
    if ($i.LiveQueryError)  { & $w "$([char]0x26A0) **Live-query error:** $($i.LiveQueryError)" }
    & $w ""

    if ($i.Configuration) {
        & $w '#### Configuration'
        & $w '| Setting | Value |'
        & $w '|---|---|'
        foreach ($pair in @(
            @('Max Memory (MB)', $i.Configuration.MaxMemoryMB),
            @('Min Memory (MB)', $i.Configuration.MinMemoryMB),
            @('MAXDOP',          $i.Configuration.MaxDop),
            @('Cost Threshold for Parallelism', $i.Configuration.CostThresholdForParallelism),
            @('Default Data Path', $i.Configuration.DefaultDataPath),
            @('Default Log Path',  $i.Configuration.DefaultLogPath),
            @('Backup Directory',  $i.Configuration.BackupDirectory)
        )) { & $w "| $($pair[0]) | $($pair[1]) |" }
        & $w ""
    }

    if (@($i.InstalledUpdates).Count -gt 0) {
        & $w "#### Installed Updates (n=$(@($i.InstalledUpdates).Count))"
        & $w '| KB | Version | Installed (UTC) |'
        & $w '|---|---|---|'
        foreach ($u in $i.InstalledUpdates) {
            $when = if ($u.InstalledUtc) { ([datetime]$u.InstalledUtc).ToString('yyyy-MM-dd') } else { '' }
            & $w "| $($u.KB) | $($u.Version) | $when |"
        }
        & $w ""
    }

    if (@($i.Logins).Count -gt 0) {
        & $w "#### Server-Level Logins (n=$(@($i.Logins).Count))"
        & $w '| Login | Type | Disabled | Server Roles | Database Access |'
        & $w '|---|---|---|---|---|'
        foreach ($l in $i.Logins) {
            $disabled = if ($l.Disabled) { [char]0x2705 } else { [char]0x274C }
            $sr = if (@($l.ServerRoles).Count -gt 0) { @($l.ServerRoles) -join ', ' } else { '*(none)*' }
            $da = if (@($l.DatabaseAccess).Count -gt 0) {
                ($l.DatabaseAccess | ForEach-Object { "$($_.Database):$(@($_.Roles) -join ',')" }) -join '; '
            } else { '*(none)*' }
            & $w "| $($l.Name) | $($l.Type) | $disabled | $sr | $da |"
        }
        & $w ""
    }

    if (@($i.OrphanDatabaseUsers).Count -gt 0) {
        & $w "#### Orphan Database Users (n=$(@($i.OrphanDatabaseUsers).Count))"
        & $w '| Database | DB User | Roles |'
        & $w '|---|---|---|'
        foreach ($o in $i.OrphanDatabaseUsers) {
            & $w "| $($o.Database) | $($o.DatabaseUser) | $(@($o.Roles) -join ', ') |"
        }
        & $w ""
    }

    if (@($i.LinkedServers).Count -gt 0) {
        & $w "#### Linked Servers (n=$(@($i.LinkedServers).Count))"
        & $w '| Name | Product | Data Source | Security Mode |'
        & $w '|---|---|---|---|'
        foreach ($ls in $i.LinkedServers) {
            & $w "| $($ls.Name) | $($ls.Product) | $($ls.DataSource) | $($ls.SecurityMode) |"
        }
        & $w ""
    }

    if (@($i.Databases).Count -gt 0) {
        $sysCount  = @($i.Databases | Where-Object IsSystem).Count
        $userCount = @($i.Databases | Where-Object { -not $_.IsSystem }).Count
        & $w "#### Databases ($userCount user, $sysCount system)"
        & $w '| Name | System | Collation | Recovery | Compat | Owner | Status | Size MB |'
        & $w '|---|---|---|---|---|---|---|---|'
        foreach ($db in $i.Databases) {
            $sys = if ($db.IsSystem) { [char]0x2705 } else { [char]0x274C }
            & $w "| $($db.Name) | $sys | $($db.Collation) | $($db.RecoveryModel) | $($db.CompatibilityLevel) | $($db.Owner) | $($db.Status) | $($db.SizeMB) |"
        }
        & $w ""
    }

    if (@($i.DiskLayout).Count -gt 0) {
        & $w '#### Disk Layout'
        & $w '| Drive | Used For | Total GB | Free GB |'
        & $w '|---|---|---|---|'
        foreach ($d in $i.DiskLayout) {
            & $w "| $($d.Drive) | $(@($d.Roles) -join ', ') | $($d.TotalGB) | $($d.FreeGB) |"
        }
        & $w ""
    }

    if (@($i.AvailabilityGroups).Count -gt 0) {
        & $w "#### Always On Availability Groups (n=$(@($i.AvailabilityGroups).Count))"
        & $w '| AG | Cluster | Role | Replica | Sync Mode | Failover | Listener |'
        & $w '|---|---|---|---|---|---|---|'
        foreach ($ag in $i.AvailabilityGroups) {
            $listener = if ($ag.Listener) { "$($ag.Listener.Name):$($ag.Listener.Port)" } else { '' }
            foreach ($rep in @($ag.Replicas)) {
                $sync = if ($rep.AvailabilityMode -match 'Synchronous') { 'Sync' } else { 'Async' }
                & $w "| $($ag.Name) | $($ag.ClusterType) | $($rep.Role) | $($rep.Name) | $sync | $($rep.FailoverMode) | $listener |"
            }
            if (@($ag.Databases).Count -gt 0) {
                & $w ""
                & $w "**Databases in $($ag.Name):** $(@($ag.Databases) -join ', ')"
            }
        }
        & $w ""
    }

    if (@($i.AgentJobs).Count -gt 0) {
        & $w "#### SQL Agent Jobs (n=$(@($i.AgentJobs).Count))"
        & $w '| Job | Enabled | Owner | Category | Schedules | Last Run (UTC) | Outcome | Next Run (UTC) |'
        & $w '|---|---|---|---|---|---|---|---|'
        foreach ($j in $i.AgentJobs) {
            $en  = if ($j.Enabled) { [char]0x2705 } else { [char]0x274C }
            $sch = if (@($j.Schedules).Count -gt 0) { (@($j.Schedules) | ForEach-Object Summary) -join '; ' } else { '*(none)*' }
            $lr  = if ($j.LastRunUtc) { ([datetime]$j.LastRunUtc).ToString('yyyy-MM-dd HH:mm') } else { '' }
            $nr  = if ($j.NextRunUtc) { ([datetime]$j.NextRunUtc).ToString('yyyy-MM-dd HH:mm') } else { '' }
            & $w "| $($j.Name) | $en | $($j.Owner) | $($j.Category) | $sch | $lr | $($j.LastRunOutcome) | $nr |"
        }
        & $w ""
    }

    if (@($i.Backups).Count -gt 0) {
        & $w '#### Backups (per database)'
        & $w '| Database | Last Full | Last Diff | Last Log |'
        & $w '|---|---|---|---|'
        $now  = Get-Date
        $cell = {
            param($x)
            if (-not $x) { return "$([char]0x26A0) never" }
            $when = ([datetime]$x.BackupAtUtc).ToString('yyyy-MM-dd HH:mm')
            $age  = ($now - [datetime]$x.BackupAtUtc).TotalDays
            $prefix = if ($age -gt 7) { "$([char]0x26A0) " } else { '' }
            "$prefix$when ($($x.SizeMB) MB)"
        }
        foreach ($b in $i.Backups) {
            & $w "| $($b.DatabaseName) | $(& $cell $b.LastFull) | $(& $cell $b.LastDiff) | $(& $cell $b.LastLog) |"
        }
        & $w ""
    }

    if (@($i.BackupDirectoryAcls).Count -gt 0) {
        & $w '#### Backup Directory'
        foreach ($acl in $i.BackupDirectoryAcls) {
            if ($acl.Error) {
                & $w "$([char]0x26A0) Could not read ACL on $($acl.Path): $($acl.Error)"
                & $w ""
                continue
            }
            $readSuffix = if ($acl.ReadFrom) { " $dot **Read from:** $($acl.ReadFrom)" } else { '' }
            & $w "**Path:** $($acl.Path) $dot **Type:** $($acl.PathType) $dot **Owner:** $($acl.Owner)$readSuffix"
            & $w ""
            & $w "**Non-inherited NTFS ACEs** (n=$(@($acl.Entries).Count)):"
            & $w '| Identity | Rights | Access | Applies To | Type |'
            & $w '|---|---|---|---|---|'
            foreach ($e in $acl.Entries) {
                $type = if ($e.Principal -and $e.Principal.Type) { $e.Principal.Type } else { 'Unresolved' }
                & $w "| $($e.Account) | $($e.Rights) | $($e.AccessControlType) | $($e.AppliesTo) | $type |"
            }
            & $w ""

            # Group expansions appear only when -ExpandGroupMembers was set.
            $groupExpansions = @($acl.Entries | Where-Object {
                $_.Principal -and $_.Principal.Type -eq 'Group' -and ($_.PSObject.Properties['Members'])
            })
            if ($groupExpansions.Count -gt 0) {
                & $w '**Group expansions (recursive):**'
                & $w ''
                foreach ($e in $groupExpansions) {
                    if ($e.MembersSkipped) {
                        & $w "- **$($e.Account)** *(default group — recursion skipped)*"
                        continue
                    }
                    $members = @($e.Members)
                    & $w "- **$($e.Account)** ($($members.Count) members):"
                    foreach ($m in ($members | Sort-Object { $_.Account })) {
                        $name = if ($m.Account) { $m.Account } elseif ($m.DistinguishedName) { $m.DistinguishedName } else { $m.Sid }
                        $via  = if (@($m.Path).Count -gt 1) { " *(via " + ((@($m.Path) | Select-Object -Skip 1) -join ' → ') + ')*' } else { '' }
                        & $w " - $name [$($m.Type)]$via"
                    }
                }
                & $w ''
            }
        }
    }

    $sb.ToString()
}

function ConvertTo-ServerFactsMarkdown {
    <#
    .SYNOPSIS
        Render a server facts result object as a human-readable Markdown report.

    .DESCRIPTION
        ConvertTo-ServerFactsMarkdown takes one result object produced by Export-ServerFacts
        (or equivalently re-hydrated from a per-server JSON file) and returns the Markdown
        report as a single string. One ## heading is emitted per collected section, with
        filtered-count summaries on Services / Shares / ScheduledTasks / Firewall headings
        and Success / PartialSuccess / Failed status indicators.

        If advisories are present on the result (.AdvisoriesComputed), they are surfaced
        twice: once as a summary block immediately after the banner, and inline as
        blockquotes under the section they apply to.

        Export-ServerFacts calls this function automatically for every scanned server
        and writes the result to <server>.md in the run folder. Call it directly only
        when you want to re-render a stored JSON, transform the output further, or pipe
        it somewhere other than a file.

    .PARAMETER Result
        A server facts result object: either the live in-memory object returned by the
        per-server worker, or an object re-hydrated from <server>.json via
        Get-Content | ConvertFrom-Json. The function reads .Computer, .ScannedAtUtc,
        .ScannedBy, .DurationSeconds, .Status, and one property per collected section.

    .EXAMPLE
        $result = Get-Content .\output\2026-05-19_103045_001\srv01.json | ConvertFrom-Json
        ConvertTo-ServerFactsMarkdown -Result $result | Set-Content -Path .\srv01-rerender.md

        Re-render the Markdown from a previously-saved JSON. Useful when you've edited
        the formatter and want to refresh historical reports without re-scanning.

    .EXAMPLE
        Get-ChildItem .\output\latest\*.json |
            ForEach-Object {
                $r = Get-Content $_.FullName | ConvertFrom-Json
                ConvertTo-ServerFactsMarkdown -Result $r |
                    Set-Content -Path ($_.FullName -replace '\.json$','.md')
            }

        Re-render every per-server JSON in a run folder back into Markdown.

    .OUTPUTS
        System.String. The full Markdown report.

    .LINK
        Export-ServerFacts

    .LINK
        https://github.com/martin-joho/PSServerFacts
    #>

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

    $sb = [System.Text.StringBuilder]::new()
    $w  = { param($s) [void]$sb.AppendLine($s) }
    $statusEmoji = switch ($Result.Status) {
        'Success'        { [char]0x2705 }
        'PartialSuccess' { [char]0x26A0 }     # warning sign
        'Failed'         { [char]0x274C }
        default          { '·' }
    }

    & $w "# Server Assessment: $($Result.Computer)"
    & $w ""
    & $w "**Scanned:** $($Result.ScannedAtUtc) by ``$($Result.ScannedBy)`` $([char]0x00B7) **Duration:** $($Result.DurationSeconds)s $([char]0x00B7) **Status:** $statusEmoji $($Result.Status)"

    if ($Result.Status -eq 'Failed') {
        & $w ""
        & $w "## Errors"
        foreach ($e in $Result.Errors) { & $w "- **$($e.Section):** $($e.Message)" }
        return $sb.ToString()
    }

    # Advisory plumbing — build a section-keyed lookup for inline blockquotes.
    $allAdv = @($Result.PSObject.Properties['AdvisoriesComputed'].Value | Where-Object { $null -ne $_ })
    $advBySection = @{}
    foreach ($a in $allAdv) {
        if (-not $advBySection.ContainsKey($a.Section)) {
            $advBySection[$a.Section] = New-Object System.Collections.Generic.List[object]
        }
        [void]$advBySection[$a.Section].Add($a)
    }
    $renderInlineFor = {
        param([string]$Section)
        if (-not $advBySection.ContainsKey($Section)) { return }
        foreach ($a in $advBySection[$Section]) {
            & $w ''
            & $w (Format-AdvisoryInlineLine -Advisory $a)
        }
    }

    # Emit the advisory summary block immediately after the banner.
    $summaryBlock = Format-AdvisorySummaryBlock -Advisories $allAdv
    if ($summaryBlock) {
        & $w ''
        & $w $summaryBlock
    }

    # Determine which sections were collected. Backward compat: if the field is
    # missing entirely (older fixture / legacy data), fall back to "all collected".
    $collected = if ($Result.PSObject.Properties['CollectedSections']) {
        @($Result.CollectedSections)
    } else {
        @('System','Hardware','Disks','Network','InstalledApplications',
          'ChocolateyPackages','WindowsFeatures','Permissions','Services','Shares',
          'ScheduledTasks','LocalUsers','FirewallRules','Iis','Certificates')
    }
    $isCollected = { param([string]$Name) $Name -in $collected }

    # System
    if (& $isCollected 'System') {
        & $w ""
        & $w "## System"
        & $w "| Property | Value |"
        & $w "|---|---|"
        & $w "| OS | $($Result.System.OsCaption) ($($Result.System.OsVersion) / $($Result.System.OsBuild)) |"
        & $w "| Installed | $($Result.System.InstallDateUtc) |"
        & $w "| Last boot | $($Result.System.LastBootUtc) |"
        if ($Result.System.LastHotfix) {
            & $w "| Last hotfix | $($Result.System.LastHotfix.HotFixId) — $($Result.System.LastHotfix.InstalledOnUtc) |"
        } else { & $w "| Last hotfix | *(none reported)* |" }
        & $w "| Domain | $($Result.System.Domain) |"
        & $w "| Time zone | $($Result.System.TimeZone) |"
        if ($Result.System.WinRmHttpsCertThumbprint) {
            & $w "| WinRM HTTPS cert | ``$($Result.System.WinRmHttpsCertThumbprint)`` |"
        }
        & $renderInlineFor 'System'
    }

    # Hardware
    if (& $isCollected 'Hardware') {
        & $w ""
        & $w "## Hardware"
        & $w "$($Result.Hardware.CpuSockets) × $($Result.Hardware.CpuModel) $([char]0x00B7) $($Result.Hardware.CpuLogicalCores) logical cores $([char]0x00B7) $($Result.Hardware.MemoryTotalGB) GB RAM"
        & $renderInlineFor 'Hardware'
    }

    # Disks
    if (& $isCollected 'Disks') {
        & $w ""
        & $w "## Disks"
        if (@($Result.Disks).Count -eq 0) { & $w "*(none)*" }
        else {
            & $w "| Drive | Label | FS | Total | Free | Used |"
            & $w "|---|---|---|---:|---:|---:|"
            foreach ($d in $Result.Disks) { & $w "| $($d.Drive) | $($d.Label) | $($d.FileSystem) | $($d.TotalGB) GB | $($d.FreeGB) GB | $($d.UsedPct)% |" }
        }
        & $renderInlineFor 'Disks'
    }

    # Network
    if (& $isCollected 'Network') {
        & $w ""
        & $w "## Network"
        if (@($Result.Network.Adapters).Count -eq 0) { & $w "*(none)*" }
        else {
            & $w "| Adapter | Status | IPv4 | Gateway | DNS |"
            & $w "|---|---|---|---|---|"
            foreach ($a in $Result.Network.Adapters) {
                $ipv4 = ($a.IPv4 | ForEach-Object { "$($_.Address)/$($_.PrefixLength)" }) -join ', '
                $dns  = $a.DnsServers -join ', '
                & $w "| $($a.Name) | $($a.Status) | $ipv4 | $($a.DefaultGateway) | $dns |"
            }
        }
        & $renderInlineFor 'Network'
    }

    # Permissions — two views per local group:
    # 1. "Direct members" — exactly what's on the local group (groups stay
    # groups, users stay users). Mirrors `net localgroup <g>`.
    # 2. "Effective members" — recursive flat user list when
    # -ExpandGroupMembers was set on the run; users reached via multiple
    # groups have all paths joined by " ; ".
    if (& $isCollected 'Permissions') {
        & $w ""
        & $w "## Permissions"
        foreach ($g in 'Administrators','RemoteDesktopUsers','RemoteManagementUsers') {
            & $w ""
            & $w "### $g"
            $list = @($Result.Permissions.$g)
            if ($list.Count -eq 0) { & $w "*(none)*"; continue }

            & $w "#### Direct members"
            & $w "| Account | Type | Domain | DistinguishedName | Note |"
            & $w "|---|---|---|---|---|"
            foreach ($p in $list) {
                $note = if ($p.ResolveError) { $p.ResolveError } else { '' }
                & $w "| ``$($p.Account)`` | $($p.Type) | $($p.Domain) | $($p.DistinguishedName) | $note |"
            }

            $hasExpansion = [bool](@($list | Where-Object { $_.Type -eq 'Group' -and $_.PSObject.Properties['Members'] }).Count)
            if (-not $hasExpansion) { continue }

            & $w ""
            & $w "#### Effective members *(recursive)*"
            $effective = [ordered]@{}
            $upsert = {
                param($key, $row, $viaPath)
                if ([string]::IsNullOrWhiteSpace([string]$key)) { return }
                if ($effective.Contains($key)) {
                    if ($effective[$key].Paths -notcontains $viaPath) {
                        $effective[$key].Paths = @($effective[$key].Paths) + $viaPath
                    }
                } else {
                    $row | Add-Member -NotePropertyName Paths -NotePropertyValue @($viaPath) -Force
                    $effective[$key] = $row
                }
            }

            foreach ($p in $list) {
                $keyP = if ($p.Sid) { $p.Sid } else { $p.Account }
                if ($p.Type -eq 'Group' -and $p.PSObject.Properties['Members']) {
                    if ($p.MembersSkipped) {
                        # Default-group recursion suppressed — keep the group itself in the table.
                        $row = [pscustomobject]@{
                            Account = $p.Account; Type = 'Group (skipped)'
                            Name = $p.SamAccountName; Domain = $p.Domain
                            DistinguishedName = $p.DistinguishedName
                            Note = 'default group — recursion skipped'
                        }
                        & $upsert $keyP $row '(direct)'
                        continue
                    }
                    $members = @($p.Members)
                    if ($members.Count -eq 0) {
                        # Group expanded but had no resolvable members — keep the group row visible.
                        $row = [pscustomobject]@{
                            Account = $p.Account; Type = 'Group (empty)'
                            Name = $p.SamAccountName; Domain = $p.Domain
                            DistinguishedName = $p.DistinguishedName; Note = ''
                        }
                        & $upsert $keyP $row '(direct)'
                        continue
                    }
                    foreach ($m in $members) {
                        # Path on each member is the chain from the top-level group inward.
                        # Render only the chain ABOVE the leaf (i.e. the groups) joined with " → ".
                        $viaPath = @($m.Path) -join ' → '
                        if (-not $viaPath) { $viaPath = $p.Account }
                        $keyM = if ($m.Sid) { $m.Sid } else { $m.Account }
                        $row = [pscustomobject]@{
                            Account = $m.Account; Type = $m.Type
                            Name = $m.Name; Domain = $m.Domain
                            DistinguishedName = $m.DistinguishedName; Note = ''
                        }
                        & $upsert $keyM $row $viaPath
                    }
                    continue
                }
                # Top-level entry that is itself a leaf (LocalAccount / User / WellKnown / Unresolved /
                # Group without Members because -ExpandGroupMembers wasn't set).
                $note = if ($p.ResolveError) { $p.ResolveError } else { '' }
                $row = [pscustomobject]@{
                    Account = $p.Account; Type = $p.Type
                    Name = $p.SamAccountName; Domain = $p.Domain
                    DistinguishedName = $p.DistinguishedName; Note = $note
                }
                & $upsert $keyP $row '(direct)'
            }

            & $w "| Account | Domain | Type | Name | DistinguishedName | Inherited via | Note |"
            & $w "|---|---|---|---|---|---|---|"
            foreach ($e in ($effective.Values | Sort-Object Account)) {
                $via = @($e.Paths) -join ' ; '
                & $w "| ``$($e.Account)`` | $($e.Domain) | $($e.Type) | $($e.Name) | $($e.DistinguishedName) | $via | $($e.Note) |"
            }
        }
        & $renderInlineFor 'Permissions'
    }

    # Services
    if (& $isCollected 'Services') {
        & $w ""
        & $w "## Services *(custom only)*"
        if (@($Result.Services).Count -eq 0) { & $w "*(none)*" }
        else {
            & $w "| Name | DisplayName | State | StartMode | RunAs | Path |"
            & $w "|---|---|---|---|---|---|"
            foreach ($s in $Result.Services) { & $w "| $($s.Name) | $($s.DisplayName) | $($s.State) | $($s.StartMode) | ``$($s.StartName)`` | ``$($s.PathName)`` |" }
        }
        & $renderInlineFor 'Services'
    }

    # Shares
    if (& $isCollected 'Shares') {
        & $w ""
        & $w "## Shares *(custom only)*"
        if (@($Result.Shares).Count -eq 0) { & $w "*(none)*" }
        else {
            foreach ($sh in $Result.Shares) {
                & $w ""
                & $w "### ``$($sh.Name)`` — ``$($sh.Path)``"
                & $w "**Share permissions**"
                & $w ""
                if (@($sh.SharePermissions).Count -eq 0) { & $w "*(none)*" }
                else {
                    & $w "| Account | Right | Type |"
                    & $w "|---|---|---|"
                    foreach ($sp in $sh.SharePermissions) { & $w "| ``$($sp.Account)`` | $($sp.AccessRight) | $($sp.AccessControlType) |" }
                }
                & $w ""
                & $w "**NTFS ($($sh.NtfsPermissions.Depth))**"
                & $w ""
                if (@($sh.NtfsPermissions.Entries).Count -eq 0) { & $w "*(none)*" }
                else {
                    & $w "| Path | Account | Rights | Inherited | AppliesTo | Type |"
                    & $w "|---|---|---|---|---|---|"
                    foreach ($e in $sh.NtfsPermissions.Entries) { & $w "| ``$($e.Path)`` | ``$($e.Account)`` | $($e.Rights) | $(if ($e.Inherited) { 'Yes' } else { 'No' }) | $($e.AppliesTo) | $($e.AccessControlType) |" }
                }
            }
        }
        & $renderInlineFor 'Shares'
    }

    # Scheduled Tasks
    if (& $isCollected 'ScheduledTasks') {
        & $w ""
        & $w "## Scheduled Tasks *(custom only)*"
        if (@($Result.ScheduledTasks).Count -eq 0) { & $w "*(none)*" }
        else {
            & $w "| Path | Name | State | RunAs | Trigger | Action |"
            & $w "|---|---|---|---|---|---|"
            foreach ($t in $Result.ScheduledTasks) {
                $trig = ($t.Triggers -join '; ')
                $act  = ($t.Actions | ForEach-Object { "$($_.Execute) $($_.Arguments)".Trim() }) -join '; '
                & $w "| ``$($t.TaskPath)`` | $($t.TaskName) | $($t.State) | ``$($t.RunAs)`` | $trig | ``$act`` |"
            }
        }
        & $renderInlineFor 'ScheduledTasks'
    }

    # Service Accounts (auto-follows Services/ScheduledTasks/Iis)
    if (('Services' -in $collected) -or ('ScheduledTasks' -in $collected) -or ('Iis' -in $collected)) {
        & $w ""
        & $w "## Domain Service Accounts *(cross-cutting)*"
        if (@($Result.ServiceAccounts).Count -eq 0) { & $w "*(none)*" }
        else {
            & $w "| Account | Type | Used by |"
            & $w "|---|---|---|"
            foreach ($a in $Result.ServiceAccounts) {
                $used = ($a.UsedBy | ForEach-Object {
                    switch ($_.Kind) {
                        'Service'       { "Service: $($_.Name)" }
                        'ScheduledTask' { "ScheduledTask: $($_.Path)" }
                        'IISAppPool'    { "IISAppPool: $($_.Name)" }
                        default         { $_.Kind }
                    }
                }) -join '; '
                & $w "| ``$($a.Account)`` | $($a.Type) | $used |"
            }
        }
    }

    # Local Users
    if (& $isCollected 'LocalUsers') {
        & $w ""
        & $w "## Local Users *(non-standard only)*"
        if (@($Result.LocalUsers).Count -eq 0) { & $w "*(none)*" }
        else {
            & $w "| Name | Enabled | Password last set | PW never expires | Description |"
            & $w "|---|---|---|---|---|"
            foreach ($u in $Result.LocalUsers) { & $w "| $($u.Name) | $($u.Enabled) | $($u.PasswordLastSetUtc) | $($u.PasswordNeverExpires) | $($u.Description) |" }
        }
        & $renderInlineFor 'LocalUsers'
    }

    # Firewall
    if (& $isCollected 'FirewallRules') {
        & $w ""
        & $w "## Firewall *(non-standard, enabled only)*"
        if (@($Result.FirewallRules).Count -eq 0) { & $w "*(none)*" }
        else {
            & $w "| Rule | Direction | Action | Protocol | Port | Program | Profile |"
            & $w "|---|---|---|---|---|---|---|"
            foreach ($r in $Result.FirewallRules) { & $w "| $($r.DisplayName) | $($r.Direction) | $($r.Action) | $($r.Protocol) | $($r.LocalPort) | ``$($r.Program)`` | $($r.Profile) |" }
        }
        & $renderInlineFor 'FirewallRules'
    }

    # IIS
    if (& $isCollected 'Iis') {
        & $w ""
        & $w "## IIS"
        if (-not $Result.Iis) { & $w "*(not installed)*" }
        else {
            & $w "**Sites**"
            & $w ""
            if (@($Result.Iis.Sites).Count -eq 0) { & $w "*(none)*" }
            else {
                & $w "| Site | State | Bindings | Physical path |"
                & $w "|---|---|---|---|"
                foreach ($s in $Result.Iis.Sites) {
                    $b = ($s.Bindings | ForEach-Object {
                        $cert = if ($_.CertificateThumbprint) { " (cert ``$($_.CertificateThumbprint)``)" } else { '' }
                        "$($_.Protocol)://$($_.BindingInformation)$cert"
                    }) -join '; '
                    & $w "| $($s.Name) | $($s.State) | $b | ``$($s.PhysicalPath)`` |"
                }
            }
            & $w ""
            & $w "**App Pools**"
            & $w ""
            if (@($Result.Iis.AppPools).Count -eq 0) { & $w "*(none)*" }
            else {
                & $w "| Pool | State | Runtime | Identity |"
                & $w "|---|---|---|---|"
                foreach ($p in $Result.Iis.AppPools) {
                    $id = if ($p.Username) { $p.Username } else { $p.IdentityType }
                    & $w "| $($p.Name) | $($p.State) | $($p.ManagedRuntimeVersion) ($($p.ManagedPipelineMode)) | ``$id`` |"
                }
            }
        }
        & $renderInlineFor 'Iis'
    }

    # Installed Applications
    if (& $isCollected 'InstalledApplications') {
        & $w ""
        & $w "## Installed Applications *($(@($Result.InstalledApplications).Count))*"
        if (@($Result.InstalledApplications).Count -gt 0) {
            & $w "| Name | Version | Publisher | Install date | Source |"
            & $w "|---|---|---|---|---|"
            foreach ($a in $Result.InstalledApplications) { & $w "| $($a.DisplayName) | $($a.Version) | $($a.Publisher) | $($a.InstallDateUtc) | $($a.Source) |" }
        }
        & $renderInlineFor 'InstalledApplications'
    }

    # Chocolatey
    if (& $isCollected 'ChocolateyPackages') {
        & $w ""
        & $w "## Chocolatey Packages *($(@($Result.ChocolateyPackages).Count))*"
        if (@($Result.ChocolateyPackages).Count -gt 0) {
            & $w "| Id | Version |"
            & $w "|---|---|"
            foreach ($c in $Result.ChocolateyPackages) { & $w "| $($c.Id) | $($c.Version) |" }
        }
        & $renderInlineFor 'ChocolateyPackages'
    }

    # Windows Features
    if (& $isCollected 'WindowsFeatures') {
        & $w ""
        & $w "## Windows Features *($(@($Result.WindowsFeatures).Count))*"
        if (@($Result.WindowsFeatures).Count -gt 0) {
            & $w "| Name | DisplayName |"
            & $w "|---|---|"
            foreach ($f in $Result.WindowsFeatures) { & $w "| $($f.Name) | $($f.DisplayName) |" }
        }
        & $renderInlineFor 'WindowsFeatures'
    }

    # Certificates
    if (& $isCollected 'Certificates') {
        & $w ""
        & $w "## Certificates"
        if (@($Result.Certificates).Count -eq 0) { & $w "*(none)*" }
        else {
            $byStore = $Result.Certificates | Group-Object Store
            foreach ($g in $byStore) {
                & $w ""
                & $w "### ``$($g.Name)``"
                & $w "| Subject | Used for | Issuer | Thumbprint | Not after | Expires in | HasKey |"
                & $w "|---|---|---|---|---|---|---|"
                foreach ($c in $g.Group) {
                    $warn = if ($c.DaysUntilExpiry -le 90 -and -not $c.Expired) { "$([char]0x26A0) " } elseif ($c.Expired) { "$([char]0x274C) " } else { '' }
                    # "Used for": prefer FriendlyName, else fall back to the SAN DNS list.
                    $usedFor = if (-not [string]::IsNullOrWhiteSpace($c.FriendlyName)) {
                        $c.FriendlyName
                    } elseif (@($c.SubjectAlternativeNames).Count -gt 0) {
                        @($c.SubjectAlternativeNames) -join ', '
                    } else { '' }
                    & $w "| $($c.Subject) | $usedFor | $($c.Issuer) | ``$($c.Thumbprint)`` | $($c.NotAfterUtc) | $warn$($c.DaysUntilExpiry) d | $($c.HasPrivateKey) |"
                }
            }
        }
        & $renderInlineFor 'Certificates'
    }

    # Pending Reboot
    if (& $isCollected 'PendingReboot') {
        & $w ''
        & $w '## Pending Reboot'
        $pr = $Result.PendingReboot
        if ($null -eq $pr) { & $w '*(not collected)*' }
        else {
            & $w "**Any pending:** $($pr.Any)"
            & $w ''
            & $w '| Source | Pending |'
            & $w '|---|---|'
            foreach ($p in 'ComponentBasedServicing','WindowsUpdate','PendingFileRenameOps','ComputerNameChange','CcmClientSDK') {
                $val = $pr.$p
                $cell = if ($val) { [char]0x2705 } else { [char]0x274C }
                & $w "| $p | $cell |"
            }
            & $renderInlineFor 'PendingReboot'
        }
    }

    # Power Plan
    if (& $isCollected 'PowerPlan') {
        & $w ''
        & $w '## Power Plan'
        $pp = $Result.PowerPlan
        if ($null -eq $pp) { & $w '*(not collected)*' }
        else {
            & $w "**Active scheme:** $($pp.ActiveSchemeName) (``$($pp.ActiveSchemeGuid)``)"
            & $w ''
            & $w '| Scheme | GUID | Active |'
            & $w '|---|---|---|'
            foreach ($s in @($pp.AvailableSchemes)) {
                $a = if ($s.IsActive) { [char]0x2705 } else { '' }
                & $w "| $($s.Name) | ``$($s.Guid)`` | $a |"
            }
            & $renderInlineFor 'PowerPlan'
        }
    }

    # Page File
    if (& $isCollected 'PageFile') {
        & $w ''
        & $w '## Page File'
        $pf = $Result.PageFile
        if ($null -eq $pf) { & $w '*(not collected)*' }
        else {
            & $w "**Automatic management:** $($pf.AutomaticManagedPagefile)"
            if (@($pf.PageFiles).Count -gt 0) {
                & $w ''
                & $w '| Path | Initial MB | Max MB | Allocated MB | Peak MB | Current MB |'
                & $w '|---|---:|---:|---:|---:|---:|'
                foreach ($p in $pf.PageFiles) { & $w "| ``$($p.Path)`` | $($p.InitialMB) | $($p.MaximumMB) | $($p.AllocatedMB) | $($p.PeakMB) | $($p.CurrentMB) |" }
            }
            if ($pf.CrashControl) {
                & $w ''
                & $w '**Crash dump:**'
                $dedicatedStr = if ([string]::IsNullOrWhiteSpace($pf.CrashControl.DedicatedDumpFile)) { '*(none)*' } else { "``$($pf.CrashControl.DedicatedDumpFile)``" }
                & $w "- Mode: $($pf.CrashControl.CrashDumpEnabled) · File: ``$($pf.CrashControl.DumpFile)`` · Dedicated: $dedicatedStr"
                & $w "- Overwrite: $($pf.CrashControl.Overwrite) · AutoReboot: $($pf.CrashControl.AutoReboot)"
            }
            & $renderInlineFor 'PageFile'
        }
    }

    # TLS / SChannel
    if (& $isCollected 'Tls') {
        & $w ''
        & $w '## TLS / SChannel'
        $tls = $Result.Tls
        if ($null -eq $tls) { & $w '*(not collected)*' }
        else {
            & $w '| Protocol | Side | Enabled | DisabledByDefault |'
            & $w '|---|---|---|---|'
            foreach ($p in @($tls.Protocols)) {
                $e = if ($p.Enabled)           { [char]0x2705 } else { [char]0x274C }
                $d = if ($p.DisabledByDefault) { [char]0x2705 } else { [char]0x274C }
                & $w "| $($p.Name) | $($p.Side) | $e | $d |"
            }
            & $renderInlineFor 'Tls'
        }
    }

    if (& $isCollected 'Smb') {
        & $w ''
        & $w '## SMB'
        $smb = $Result.Smb
        if ($null -eq $smb) { & $w '*(not collected)*' }
        else {
            $bool = { param($v) if ($null -eq $v) { '?' } elseif ($v) { [char]0x2705 } else { [char]0x274C } }
            & $w '| Setting | Value |'
            & $w '|---|---|'
            & $w "| SMB1 enabled | $(& $bool $smb.Smb1Enabled) |"
            & $w "| SMB2 enabled | $(& $bool $smb.Smb2Enabled) |"
            & $w "| Signing required | $(& $bool $smb.SigningRequired) |"
            & $w "| Signing enabled | $(& $bool $smb.SigningEnabled) |"
            & $w "| Encrypt data | $(& $bool $smb.EncryptData) |"
            & $w "| Reject unencrypted | $(& $bool $smb.RejectUnencryptedAccess) |"
            & $w "| Audit SMB1 access | $(& $bool $smb.AuditSmb1Access) |"
            & $w "| SMB1 client feature | $(& $bool $smb.Smb1ClientInstalled) |"
            & $renderInlineFor 'Smb'
        }
    }

    if (& $isCollected 'Defender') {
        & $w ''
        & $w '## Microsoft Defender'
        $def = $Result.Defender
        if ($null -eq $def) { & $w '*(not collected)*' }
        else {
            $bool = { param($v) if ($null -eq $v) { '?' } elseif ($v) { [char]0x2705 } else { [char]0x274C } }
            & $w '### Engine'
            & $w '| Setting | Value |'
            & $w '|---|---|'
            & $w "| Antivirus enabled | $(& $bool $def.Engine.AntivirusEnabled) |"
            & $w "| Real-time protection enabled | $(& $bool $def.Engine.RealTimeProtectionEnabled) |"
            & $w "| Tamper protected | $(& $bool $def.Engine.TamperProtected) |"
            & $w "| AM service enabled | $(& $bool $def.Engine.AMServiceEnabled) |"
            & $w "| Signatures last updated UTC | $($def.Engine.AntivirusSignatureLastUpdatedUtc) |"
            & $w "| Signature age (days) | $($def.Engine.AntivirusSignatureAgeDays) |"
            & $w ''
            & $w '### Exclusions'
            & $w ''
            foreach ($exclGroup in @(
                @{ Label = 'Paths';      Items = @($def.Exclusions.Paths) },
                @{ Label = 'Extensions'; Items = @($def.Exclusions.Extensions) },
                @{ Label = 'Processes';  Items = @($def.Exclusions.Processes) }
            )) {
                $items = $exclGroup.Items
                if ($items.Count -gt 0) {
                    & $w "**$($exclGroup.Label)** ($($items.Count))"
                    foreach ($item in $items) { & $w "- ``$item``" }
                } else {
                    & $w "**$($exclGroup.Label)** *(none)*"
                }
                & $w ''
            }
            & $renderInlineFor 'Defender'
        }
    }

    if (& $isCollected 'UserRightsAssignment') {
        & $w ''
        & $w '## User Rights Assignment'
        $ura = @($Result.UserRightsAssignment)
        if ($ura.Count -eq 0) { & $w '*(not collected)*' }
        else {
            $uraIncludeAll = $Result.PSObject.Properties['UserRightsAssignmentIncludeAll'] -and $Result.UserRightsAssignmentIncludeAll
            $uraExpandMembers = $Result.PSObject.Properties['UserRightsExpandMembers'] -and $Result.UserRightsExpandMembers
            if (-not $uraIncludeAll) {
                & $w '*(non-default principals only — pass `-IncludeAllUserRights` to see all)*'
            }
            if (-not $uraExpandMembers) {
                & $w '*(pass `-ExpandUserRightsMembers` to see recursive user-only expansion under each privilege)*'
            }
            foreach ($r in $ura) {
                & $w ''
                & $w "### $($r.Privilege)"
                if (@($r.Principals).Count -eq 0) { & $w '*(none)*'; continue }
                & $w '| Account | Type | DistinguishedName |'
                & $w '|---|---|---|'
                foreach ($p in $r.Principals) { & $w "| ``$($p.Account)`` | $($p.Type) | $($p.DistinguishedName) |" }

                # Effective members block — only when expansion was requested AND members are present
                $expandFlag = $Result.PSObject.Properties['UserRightsExpandMembers'] -and $Result.UserRightsExpandMembers
                $principalsWithMembers = @($r.Principals | Where-Object { $_.PSObject.Properties['Members'] -and @($_.Members).Count -gt 0 })
                if ($expandFlag -and $principalsWithMembers.Count -gt 0) {
                    & $w ''
                    & $w '#### Effective members'
                    # De-dup + path-join (same algorithm as Permissions effective-members block).
                    # Guard against null/empty keys — URA local-group members can come back
                    # from the ADSI fallback with neither SID nor Account (fully unresolvable).
                    $effective = [ordered]@{}
                    $upsert = {
                        param($key, $row, $viaPath)
                        if ([string]::IsNullOrWhiteSpace([string]$key)) { return }
                        if ($effective.Contains($key)) {
                            if ($effective[$key].Paths -notcontains $viaPath) {
                                $effective[$key].Paths = @($effective[$key].Paths) + $viaPath
                            }
                        } else {
                            $row | Add-Member -NotePropertyName Paths -NotePropertyValue @($viaPath) -Force
                            $effective[$key] = $row
                        }
                    }
                    foreach ($p in $r.Principals) {
                        if ($p.PSObject.Properties['Members'] -and @($p.Members).Count -gt 0) {
                            foreach ($m in @($p.Members)) {
                                $viaPath = if (@($m.Path).Count -gt 0) { @($m.Path) -join ' → ' } else { $p.Account }
                                $keyM = if ($m.Sid) { $m.Sid } else { $m.Account }
                                $row = [pscustomobject]@{
                                    Account = $m.Account; Type = $m.Type
                                    Name = $m.Name; Domain = $m.Domain
                                    DistinguishedName = $m.DistinguishedName
                                }
                                & $upsert $keyM $row $viaPath
                            }
                        }
                    }
                    & $w '| Account | Domain | Type | Name | DistinguishedName | Inherited via |'
                    & $w '|---|---|---|---|---|---|'
                    foreach ($e in ($effective.Values | Sort-Object Account)) {
                        $via = @($e.Paths) -join ' ; '
                        & $w "| ``$($e.Account)`` | $($e.Domain) | $($e.Type) | $($e.Name) | $($e.DistinguishedName) | $via |"
                    }
                }
            }
            & $renderInlineFor 'UserRightsAssignment'
        }
    }

    # Chef
    if (& $isCollected 'Chef') {
        & $w ""
        & $w "## Chef"
        if (-not $Result.Chef) { & $w "*(not installed)*" }
        else {
            $c = $Result.Chef
            $dot = [char]0x00B7
            & $w "**Node:** $($c.NodeName) $dot **Environment:** $($c.Environment) $dot **Version:** $($c.Version)"
            & $w "**Chef Server:** $($c.ChefServerUrl)"
            if ($c.LastSuccessfulRunUtc) { & $w "**Last Successful Run (UTC):** $($c.LastSuccessfulRunUtc)" }
            & $w ""
            $runListHeader = "### Run List"
            if ($c.RunListSource) { $runListHeader += " *(source: $($c.RunListSource))*" }
            & $w $runListHeader
            if (@($c.RunList).Count -gt 0) {
                foreach ($r in $c.RunList) { & $w "- $r" }
            } else {
                & $w "*(empty)*"
                if ($c.ClientLogStatus -and $c.ClientLogStatus -ne 'OK') {
                    $logPathStr = if ($c.ClientLogPath) { " ($($c.ClientLogPath))" } else { '' }
                    & $w ""
                    & $w "> Client log probe: $($c.ClientLogStatus)$logPathStr"
                }
            }
            & $w ""
            $attrHeader = "### Attribute Keys"
            if ($c.AttributeSource) { $attrHeader += " *(source: $($c.AttributeSource))*" }
            & $w $attrHeader
            if ($c.AttributeStatus -and $c.AttributeStatus -ne 'OK') {
                $reason = switch ($c.AttributeStatus) {
                    'NoCacheFile'     { 'no node cache file under C:\chef\cache\nodes — server has not cached attributes locally' }
                    'CacheUnreadable' { "cache file present ($($c.AttributeCachePath)) but could not be parsed" }
                    default           { $c.AttributeStatus }
                }
                & $w "*(unavailable: $reason)*"
            } else {
                & $w "| Tree | Top-level keys |"
                & $w "|---|---|"
                foreach ($tree in 'default','normal','override','automatic') {
                    $keys = @($c.AttributeKeys.$tree)
                    $val  = if ($keys.Count -gt 0) { $keys -join ', ' } else { '*(none)*' }
                    & $w "| $tree | $val |"
                }
            }
        }
        & $renderInlineFor 'Chef'
    }

    # SQL Server — section is omitted entirely when SQL is not installed,
    # rather than printing an empty "*(not installed)*" stub. Detection ran;
    # absence is the answer.
    if ((& $isCollected 'SqlServer') -and -not ($null -eq $Result.SqlServer -or @($Result.SqlServer).Count -eq 0)) {
        & $w ""
        & $w "## SQL Server"
        foreach ($inst in @($Result.SqlServer)) {
            & $w ""
            [void]$sb.Append((Format-SqlServerInstanceBlock -Instance $inst))
        }
        & $renderInlineFor 'SqlServer'
    }

    # Errors (always rendered)
    & $w ""
    & $w "## Errors / Warnings"
    if (@($Result.Errors).Count -eq 0) { & $w "*(none)*" }
    else {
        foreach ($e in $Result.Errors) { & $w "- **$($e.Section):** $($e.Message)" }
    }

    return $sb.ToString()
}