<#
.SYNOPSIS
    GuruRMM onboarding diagnostic probe (Phase 1).

.DESCRIPTION
    Security + health + inventory probe for a newly onboarded Windows agent.
    Dispatched via the GuruRMM RMM API and run as SYSTEM by the agent.

    Design constraints (do not relax):
      - Windows PowerShell 5.1 compatible (no PS7-only syntax, no ternary, no ??).
      - ASCII only. No unicode, em-dashes, smart quotes. They break the
        Discord alert path and can break PS5.1 parsing of this file.
      - Non-interactive. No Read-Host / pause / prompts.
      - EVERY check wrapped in try/catch so one failure cannot abort the probe.
        A failed check records a finding with severity "unknown".
      - Emits a SINGLE JSON object to stdout, fenced by literal marker lines:
            ===DIAG-JSON-START===
            { ... }
            ===DIAG-JSON-END===
        so the runner can extract it cleanly from surrounding noise.

    Finding schema:
      { id, category, severity, title, detail, evidence }
      severity in: critical | warning | info | unknown

    Top-level schema:
      { host, collected_at_utc, os{}, facts{}, findings[] }
#>

# Be permissive: non-terminating errors should not kill the probe. Each check
# uses its own try/catch with -ErrorAction Stop where it needs hard failures.
$ErrorActionPreference = 'Continue'
$ProgressPreference    = 'SilentlyContinue'
Set-StrictMode -Off

# ---------------------------------------------------------------------------
# Legacy-PowerShell guard.
# This probe targets Windows PowerShell 5.1 (see .SYNOPSIS): it uses [ordered],
# Get-CimInstance, and ConvertTo-Json - ALL PowerShell 3.0+. On stock PowerShell
# 2.0 (Win7 SP1 / Server 2008 R2 without WMF) those throw and the probe emits
# empty DIAG-JSON with no grade. Rather than crash cryptically, emit a legible,
# parseable result (hand-built JSON - ConvertTo-Json is itself PS3+) so the
# runner still extracts a clean object plus a remediation hint. A true
# PS2-native probe is tracked separately in RMM_THOUGHTS (PS2-compat diagnostic).
# ---------------------------------------------------------------------------
if ($PSVersionTable.PSVersion.Major -lt 3) {
    $legacyHost = $env:COMPUTERNAME
    $legacyVer  = $PSVersionTable.PSVersion.ToString()
    $nowUtc     = (Get-Date).ToUniversalTime()
    $legacyUtc  = $nowUtc.ToString('yyyy-MM-dd') + 'T' + $nowUtc.ToString('HH:mm:ss') + 'Z'
    $detail = 'This host runs Windows PowerShell ' + $legacyVer + '. The onboarding probe requires PowerShell 3.0+ (it uses Get-CimInstance, [ordered], and ConvertTo-Json), so the full diagnostic could not run. Install WMF 5.1 (KB3191566) to enable it, or run the legacy-native probe when available.'
    # Hand-built JSON - keep ASCII, no ConvertTo-Json on PS2.
    $legacyJson = '{"host":"' + $legacyHost + '","collected_at_utc":"' + $legacyUtc + '",' +
                  '"os":{"powershell_version":"' + $legacyVer + '"},"facts":{},"findings":[' +
                  '{"id":"probe.legacy_powershell","category":"probe","severity":"unknown",' +
                  '"title":"Diagnostic probe requires PowerShell 3.0+",' +
                  '"detail":"' + $detail + '",' +
                  '"evidence":"PSVersion ' + $legacyVer + ' is older than 3.0"}]}'
    Write-Output '===DIAG-JSON-START==='
    Write-Output $legacyJson
    Write-Output '===DIAG-JSON-END==='
    exit 0
}

# ---------------------------------------------------------------------------
# Collectors
# ---------------------------------------------------------------------------
$Findings = New-Object System.Collections.ArrayList
$Facts    = @{}

function Add-Finding {
    param(
        [string]$Id,
        [string]$Category,
        [string]$Severity,   # critical | warning | info | unknown
        [string]$Title,
        [string]$Detail,
        $Evidence
    )
    $ev = ''
    try {
        if ($null -ne $Evidence) {
            if ($Evidence -is [string]) {
                $ev = $Evidence
            } else {
                $ev = ($Evidence | Out-String).Trim()
            }
        }
    } catch { $ev = '' }
    # Keep evidence bounded so a single finding can't bloat the JSON.
    if ($ev.Length -gt 4000) { $ev = $ev.Substring(0, 4000) + ' ...[truncated]' }

    $null = $Findings.Add([ordered]@{
        id       = $Id
        category = $Category
        severity = $Severity
        title    = $Title
        detail   = $Detail
        evidence = $ev
    })
}

# Wrap a check. If the scriptblock throws, record an "unknown" finding instead
# of letting the exception propagate and abort the whole probe.
function Invoke-Check {
    param(
        [string]$Id,
        [string]$Category,
        [string]$Title,
        [scriptblock]$Body
    )
    try {
        & $Body
    } catch {
        $msg = ''
        try { $msg = $_.Exception.Message } catch { $msg = 'unknown error' }
        Add-Finding -Id "$Id.error" -Category $Category -Severity 'unknown' `
            -Title "Check failed: $Title" `
            -Detail "The probe could not complete this check. Manual review recommended." `
            -Evidence $msg
    }
}

# Safe fact setter (never throws).
function Set-Fact {
    param([string]$Key, $Value)
    try { $Facts[$Key] = $Value } catch { }
}

# ---------------------------------------------------------------------------
# Basic host / OS facts (best-effort; never aborts)
# ---------------------------------------------------------------------------
$HostName = $env:COMPUTERNAME
try { if ([string]::IsNullOrEmpty($HostName)) { $HostName = [System.Net.Dns]::GetHostName() } } catch { }

$CollectedAtUtc = (Get-Date).ToUniversalTime().ToString('yyyy-MM-ddTHH:mm:ssZ')

$OsInfo = [ordered]@{
    caption       = ''
    version       = ''
    build         = ''
    install_date  = ''
    last_boot_utc = ''
    architecture  = ''
}

# Chassis / laptop detection result is needed by multiple checks.
$IsLaptop = $false

Invoke-Check -Id 'os.info' -Category 'inventory' -Title 'OS information' -Body {
    $os = Get-CimInstance -ClassName Win32_OperatingSystem -ErrorAction Stop
    $script:OsInfo.caption      = [string]$os.Caption
    $script:OsInfo.version      = [string]$os.Version
    $script:OsInfo.build        = [string]$os.BuildNumber
    $script:OsInfo.architecture = [string]$os.OSArchitecture
    try { $script:OsInfo.install_date  = $os.InstallDate.ToUniversalTime().ToString('yyyy-MM-ddTHH:mm:ssZ') } catch { }
    try { $script:OsInfo.last_boot_utc = $os.LastBootUpTime.ToUniversalTime().ToString('yyyy-MM-ddTHH:mm:ssZ') } catch { }
}

Invoke-Check -Id 'chassis.detect' -Category 'inventory' -Title 'Chassis type (laptop detection)' -Body {
    $enc = Get-CimInstance -ClassName Win32_SystemEnclosure -ErrorAction Stop
    $types = @()
    foreach ($e in $enc) { if ($e.ChassisTypes) { $types += $e.ChassisTypes } }
    Set-Fact 'chassis_types' $types
    # 8=Portable 9=Laptop 10=Notebook 11=Hand Held 12=Docking 14=Sub Notebook
    foreach ($t in $types) {
        if ($t -eq 8 -or $t -eq 9 -or $t -eq 10 -or $t -eq 11 -or $t -eq 14) { $script:IsLaptop = $true }
    }
    Set-Fact 'is_laptop' $script:IsLaptop
}

# ===========================================================================
# SECURITY CHECKS
# ===========================================================================

# --- Microsoft Defender ---
Invoke-Check -Id 'sec.defender' -Category 'security' -Title 'Microsoft Defender status' -Body {
    $mp = $null
    try { $mp = Get-MpComputerStatus -ErrorAction Stop } catch { $mp = $null }
    if ($null -eq $mp) {
        Add-Finding -Id 'sec.defender.unavailable' -Category 'security' -Severity 'warning' `
            -Title 'Defender status unavailable' `
            -Detail 'Get-MpComputerStatus returned nothing. Defender may be disabled, replaced by a 3rd-party AV, or the cmdlet is unavailable. Confirm an active AV exists (see security-center check).' `
            -Evidence 'Get-MpComputerStatus returned null'
        Set-Fact 'defender' @{ available = $false }
        return
    }

    $rtp     = [bool]$mp.RealTimeProtectionEnabled
    $amsvc   = [bool]$mp.AMServiceEnabled
    $sigAge  = 0
    try { $sigAge = [int]$mp.AntispywareSignatureAge } catch { $sigAge = -1 }
    $tamper  = $false
    try { $tamper = [bool]$mp.IsTamperProtected } catch { $tamper = $false }

    # Detect a managed/known 3rd-party AV. When one is active, Windows
    # intentionally disables Defender real-time protection and stops its AM
    # service (Defender steps aside for the registered AV). In that case
    # "Defender RTP off" / "AM service not running" is EXPECTED, not a critical
    # gap, so we downgrade those two findings to INFO below.
    #
    # Primary signal: a non-Defender SecurityCenter2 AntiVirusProduct whose
    # productState reports real-time protection ENABLED. Fallback signal (Server
    # SKUs, where SecurityCenter2 is absent): a running Datto AV/EDR service.
    # Both are independent, cheap, idempotent CIM queries scoped to this check.
    $thirdPartyAv = $false
    $thirdPartyAvEvidence = ''
    try {
        $scAv = Get-CimInstance -Namespace 'root\SecurityCenter2' -ClassName AntiVirusProduct -ErrorAction Stop
        foreach ($p in $scAv) {
            $pn = [string]$p.displayName
            # productState encodes the product's live status. Bit 0x1000 (the
            # RTP byte's 0x10) set => real-time protection is ON. A registered
            # but disabled / expired / snoozed AV (e.g. a lapsed OEM trial)
            # leaves an AntiVirusProduct entry behind but is NOT protecting the
            # endpoint, so it must not suppress the critical Defender finding.
            # Require RTP-enabled before treating it as an active 3rd-party AV.
            $state = 0
            try { $state = [int]$p.productState } catch { $state = 0 }
            $rtpOn = (($state -band 0x1000) -ne 0)
            if ($pn -and $rtpOn -and $pn -notmatch 'Windows Defender' -and $pn -notmatch 'Microsoft Defender') {
                $thirdPartyAv = $true
                $thirdPartyAvEvidence = "SecurityCenter2 AntiVirusProduct: $pn (productState=0x$('{0:X}' -f $state), RTP on)"
            }
        }
    } catch {
        # SecurityCenter2 unavailable (e.g. Server SKU). Fall through to service probe.
    }
    if (-not $thirdPartyAv) {
        # Datto AV / Datto EDR service fallback. A bare "datto" substring is too
        # loose: Datto RMM, Backup, Workplace, Continuity and File Protection all
        # carry "datto" in their service/display names but provide NO antivirus,
        # and would wrongly suppress the critical Defender finding. Require a
        # Datto name AND an AV/EDR token, and explicitly exclude the non-AV Datto
        # product lines. False negatives here are safe-side (critical preserved).
        try {
            $dattoSvc = Get-CimInstance -ClassName Win32_Service -ErrorAction Stop |
                Where-Object {
                    $n = [string]$_.Name
                    $d = [string]$_.DisplayName
                    ((($n -match 'datto') -and ($n -match 'edr|antivirus|av')) -or
                     (($d -match 'datto') -and ($d -match 'edr|antivirus|av'))) -and
                    ($n -notmatch 'rmm|backup|workplace|continuity|file|networking') -and
                    ($d -notmatch 'rmm|backup|workplace|continuity|file|networking')
                } |
                Where-Object { [string]$_.State -eq 'Running' } |
                Select-Object -First 1
            if ($dattoSvc) {
                $thirdPartyAv = $true
                $thirdPartyAvEvidence = "Active Datto AV/EDR service: $([string]$dattoSvc.Name) ($([string]$dattoSvc.DisplayName)) Running"
            }
        } catch { }
    }
    Set-Fact 'third_party_av_active' $thirdPartyAv

    Set-Fact 'defender' @{
        available                  = $true
        real_time_protection       = $rtp
        am_service_enabled         = $amsvc
        antispyware_signature_age  = $sigAge
        tamper_protected           = $tamper
        antivirus_enabled          = [bool]$mp.AntivirusEnabled
        nis_enabled                = [bool]$mp.NISEnabled
    }

    $ev = "RealTimeProtectionEnabled=$rtp; AMServiceEnabled=$amsvc; AntispywareSignatureAge=$sigAge days; IsTamperProtected=$tamper"

    if (-not $rtp) {
        if ($thirdPartyAv) {
            # A known/managed 3rd-party AV is active; Windows disables Defender
            # RTP by design when another AV registers. Expected, not a gap.
            Add-Finding -Id 'sec.defender.rtp_off' -Category 'security' -Severity 'info' `
                -Title 'Defender real-time protection is OFF (3rd-party AV active)' `
                -Detail 'Defender real-time protection is off because a managed/known 3rd-party AV is active. Windows disables Defender real-time protection when another AV registers, so this is expected. Confirm the 3rd-party AV is providing real-time protection.' `
                -Evidence ($ev + '; ' + $thirdPartyAvEvidence)
        } else {
            Add-Finding -Id 'sec.defender.rtp_off' -Category 'security' -Severity 'critical' `
                -Title 'Defender real-time protection is OFF' `
                -Detail 'Real-time protection is disabled. The endpoint is unprotected against active threats. Re-enable immediately or confirm a managed 3rd-party AV is providing real-time protection.' `
                -Evidence $ev
        }
    }
    if (-not $amsvc) {
        if ($thirdPartyAv) {
            # Defender stands down its AM service when a 3rd-party AV registers. Expected.
            Add-Finding -Id 'sec.defender.amservice_off' -Category 'security' -Severity 'info' `
                -Title 'Defender antimalware service is not running (3rd-party AV active)' `
                -Detail 'The Defender antimalware service is not active because a managed/known 3rd-party AV is registered. Windows stands Defender down when another AV provides protection, so this is expected. Confirm the 3rd-party AV is running.' `
                -Evidence ($ev + '; ' + $thirdPartyAvEvidence)
        } else {
            Add-Finding -Id 'sec.defender.amservice_off' -Category 'security' -Severity 'critical' `
                -Title 'Defender antimalware service is not running' `
                -Detail 'The Defender antimalware service is not active. If no 3rd-party AV is present, this endpoint has no antivirus protection.' `
                -Evidence $ev
        }
    }
    if ($sigAge -lt 0) {
        # Sentinel: AntispywareSignatureAge could not be read. Never let this
        # vanish silently - emit an explicit unknown so currency is flagged for
        # manual review instead of being treated as "fine".
        Add-Finding -Id 'sec.defender.sig_unknown' -Category 'security' -Severity 'unknown' `
            -Title 'Defender signature currency could not be determined' `
            -Detail 'AntispywareSignatureAge could not be read from Get-MpComputerStatus. Signature freshness is unknown. Verify manually (Get-MpComputerStatus, or Update-MpSignature to force a refresh).' `
            -Evidence $ev
    } elseif ($sigAge -gt 7) {
        Add-Finding -Id 'sec.defender.sig_stale' -Category 'security' -Severity 'warning' `
            -Title "Defender signatures are $sigAge days old" `
            -Detail 'Antispyware signatures are more than 7 days old. The endpoint may not detect recent threats. Force a signature update (Update-MpSignature).' `
            -Evidence $ev
    }
    if (-not $tamper) {
        Add-Finding -Id 'sec.defender.tamper_off' -Category 'security' -Severity 'warning' `
            -Title 'Defender tamper protection is OFF' `
            -Detail 'Tamper protection is disabled, so malware or a local admin can silently disable Defender. Enable tamper protection (typically via Intune / Security Center).' `
            -Evidence $ev
    }
    if ($rtp -and $amsvc -and $sigAge -ge 0 -and $sigAge -le 7) {
        Add-Finding -Id 'sec.defender.ok' -Category 'security' -Severity 'info' `
            -Title 'Defender active and current' `
            -Detail 'Real-time protection on, service running, signatures current.' `
            -Evidence $ev
    }
}

# --- 3rd-party AV via SecurityCenter2 ---
Invoke-Check -Id 'sec.av_products' -Category 'security' -Title 'Registered antivirus products' -Body {
    $avList = @()
    try {
        $av = Get-CimInstance -Namespace 'root\SecurityCenter2' -ClassName AntiVirusProduct -ErrorAction Stop
    } catch {
        # SecurityCenter2 does not exist on Server SKUs; that is expected, not an error.
        $av = $null
    }
    if ($null -eq $av) {
        Set-Fact 'antivirus_products' @()
        Add-Finding -Id 'sec.av_products.none_registered' -Category 'security' -Severity 'info' `
            -Title 'No AV products registered in Security Center' `
            -Detail 'SecurityCenter2 returned no AntiVirusProduct entries. This is normal on Windows Server SKUs (Security Center is a client feature). On a workstation, confirm Defender or a managed AV is active.' `
            -Evidence 'root\SecurityCenter2 AntiVirusProduct: none'
        return
    }

    $nonDefender = @()
    foreach ($p in $av) {
        $name = [string]$p.displayName
        $avList += $name
        if ($name -notmatch 'Windows Defender' -and $name -notmatch 'Microsoft Defender') {
            $nonDefender += $name
        }
    }
    Set-Fact 'antivirus_products' $avList

    if ($nonDefender.Count -gt 0) {
        $names = ($nonDefender -join ', ')
        Add-Finding -Id 'sec.av_products.third_party' -Category 'security' -Severity 'warning' `
            -Title "Third-party AV present: $names" `
            -Detail 'A non-Defender antivirus is registered. Running two real-time AV engines causes conflicts, performance loss, and detection gaps. Confirm the intended AV and ensure only one provides real-time protection.' `
            -Evidence ("Registered AV: " + ($avList -join ', '))
    } else {
        Add-Finding -Id 'sec.av_products.defender_only' -Category 'security' -Severity 'info' `
            -Title 'Defender is the only registered AV' `
            -Detail 'Only Microsoft/Windows Defender is registered in Security Center.' `
            -Evidence ($avList -join ', ')
    }
}

# --- Leftover / competitor management agents (CRITICAL at onboarding) ---
Invoke-Check -Id 'sec.foreign_agents' -Category 'security' -Title 'Competitor / leftover management agents' -Body {
    # name regex -> friendly label.
    # IMPORTANT: anchor short/ambiguous tokens with \b word boundaries. Bare
    # substrings cause false positives (e.g. 'cove' matches 'Recovery'/'Discovery',
    # 'ltsvc' matches 'VaultSvc'). \b requires a word boundary on each side.
    $patterns = @(
        @{ rx = 'screenconnect|connectwise control|connectwisecontrol';   label = 'ScreenConnect / ConnectWise Control' },
        @{ rx = 'ninjarmm|ninja rmm';                                      label = 'NinjaRMM' },
        @{ rx = 'datto rmm|\bcagservice\b|\baemagent\b|centrastage';       label = 'Datto RMM' },
        @{ rx = '\batera\b|ateraagent';                                    label = 'Atera' },
        @{ rx = '\bkaseya\b|\bagentmon\b';                                 label = 'Kaseya' },
        @{ rx = '\bteamviewer\b';                                          label = 'TeamViewer' },
        @{ rx = '\blogmein\b';                                             label = 'LogMeIn' },
        @{ rx = '\banydesk\b';                                             label = 'AnyDesk' },
        @{ rx = '\bsplashtop\b';                                           label = 'Splashtop (SOS/Streamer)' },
        @{ rx = '\bn-able\b|\bnableagent\b|take control|\bbasupport\b';    label = 'N-able / Take Control' },
        @{ rx = '\bsyncro\b|\bkabuto\b';                                   label = 'Syncro / Kabuto' },
        @{ rx = '\baction1\b';                                             label = 'Action1' },
        @{ rx = '\blabtech\b|connectwise automate|\bltservice\b|\bltsvcmon\b'; label = 'ConnectWise Automate / LabTech' },
        @{ rx = '\bbeyondtrust\b|\bbomgar\b';                              label = 'BeyondTrust / Bomgar' },
        @{ rx = '\bgotoassist\b|goto assist|goto resolve|\bgotoresolve\b'; label = 'GoToAssist / GoTo Resolve' },
        @{ rx = '\brustdesk\b';                                            label = 'RustDesk' },
        @{ rx = '\bpulseway\b';                                            label = 'Pulseway' },
        @{ rx = '\blevel\.io\b|\blevelio\b|level rmm';                     label = 'Level.io' },
        @{ rx = '\bmanageengine\b|zoho assist|\bzohoassist\b';             label = 'ManageEngine / Zoho Assist' }
    )

    # Collect installed programs from both 64-bit and 32-bit uninstall hives.
    $installed = @()
    $uninstallPaths = @(
        'HKLM:\Software\Microsoft\Windows\CurrentVersion\Uninstall\*',
        'HKLM:\Software\WOW6432Node\Microsoft\Windows\CurrentVersion\Uninstall\*'
    )
    foreach ($up in $uninstallPaths) {
        try {
            $items = Get-ItemProperty $up -ErrorAction SilentlyContinue
            foreach ($it in $items) {
                if ($it.DisplayName) {
                    $installed += [pscustomobject]@{
                        Name      = [string]$it.DisplayName
                        Version   = [string]$it.DisplayVersion
                        Publisher = [string]$it.Publisher
                    }
                }
            }
        } catch { }
    }

    # Collect services (name + display name).
    $svcRows = @()
    try {
        $svcRows = Get-CimInstance -ClassName Win32_Service -ErrorAction Stop |
            Select-Object Name, DisplayName, State, StartMode
    } catch {
        try { $svcRows = Get-Service -ErrorAction SilentlyContinue | Select-Object Name, DisplayName } catch { $svcRows = @() }
    }

    # ACG's OWN management / remote-access / security stack. A match here is
    # EXPECTED tooling (we deploy it), so it is reported as INFO instead of the
    # CRITICAL "foreign agent" finding. Detection of genuinely-foreign agents is
    # NOT weakened: anything that matches a $patterns rule but is NOT on this
    # allowlist is still flagged CRITICAL exactly as before.
    #
    # Matching is the same signal the foreign-agent scan uses (installed program
    # DisplayName, service Name/DisplayName) plus the program Publisher, all
    # case-insensitive (-match is case-insensitive in PS). Keep the aliases
    # broad so common product/service-name variants are covered. To add a tool
    # later, append another regex string to this array.
    $acgOwnStack = @(
        # ConnectWise Control / ScreenConnect (our remote-access tool)
        'screenconnect|connectwise *control|connectwisecontrol',
        # Splashtop (our remote-access tool)
        '\bsplashtop\b',
        # Syncro (our RMM/PSA) - SyncroMSP, kabuto, RepairTech lineage
        '\bsyncro\b|syncromsp|\bkabuto\b|repairtech',
        # Datto RMM (our RMM) - CagService, AEMAgent, CentraStage lineage
        'datto *rmm|\bcagservice\b|\baemagent\b|centrastage',
        # Datto EDR / Datto AV (our endpoint protection) - incl. Infocyte lineage
        'datto *edr|datto *av|datto *antivirus|\bhuntress\b|infocyte|\brts\b'
    )

    # Returns the matching allowlist regex (truthy) if any allowlist entry
    # matches the supplied text, else $null. Never throws.
    function Test-AcgOwnStack {
        param([string]$Text)
        if ([string]::IsNullOrEmpty($Text)) { return $null }
        foreach ($rx in $acgOwnStack) {
            try { if ($Text -match $rx) { return $rx } } catch { }
        }
        return $null
    }

    $hits = @()
    foreach ($pat in $patterns) {
        $rx = $pat.rx
        $matchEvidence = @()
        $isOurs = $false   # true if ANY matching signal for this label is ACG's own stack

        foreach ($prog in $installed) {
            if ($prog.Name -match $rx) {
                $matchEvidence += ("program: " + $prog.Name + " " + $prog.Version)
                # An install can be identified as ours by its DisplayName OR publisher.
                if ((Test-AcgOwnStack $prog.Name) -or (Test-AcgOwnStack $prog.Publisher)) { $isOurs = $true }
            }
        }
        foreach ($s in $svcRows) {
            $sn = [string]$s.Name
            $sd = [string]$s.DisplayName
            if ($sn -match $rx -or $sd -match $rx) {
                $st = ''
                try { $st = [string]$s.State } catch { }
                $matchEvidence += ("service: " + $sn + " (" + $sd + ") " + $st)
                if ((Test-AcgOwnStack $sn) -or (Test-AcgOwnStack $sd)) { $isOurs = $true }
            }
        }

        if ($matchEvidence.Count -gt 0) {
            $hits += [pscustomobject]@{
                Label    = $pat.label
                Evidence = ($matchEvidence | Select-Object -Unique)
                IsOurs   = $isOurs
            }
        }
    }

    Set-Fact 'foreign_agents' ($hits | Where-Object { -not $_.IsOurs } | ForEach-Object { $_.Label })
    Set-Fact 'acg_managed_tools' ($hits | Where-Object { $_.IsOurs } | ForEach-Object { $_.Label })

    $foreignHits = @($hits | Where-Object { -not $_.IsOurs })
    $ourHits     = @($hits | Where-Object { $_.IsOurs })

    if ($foreignHits.Count -gt 0) {
        foreach ($h in $foreignHits) {
            Add-Finding -Id ('sec.foreign_agents.' + ($h.Label -replace '[^A-Za-z0-9]+','_').ToLower()) `
                -Category 'security' -Severity 'critical' `
                -Title ('Foreign management/remote-access agent: ' + $h.Label) `
                -Detail 'A competitor RMM or unmanaged remote-access tool is present. At onboarding this is a security and control risk (a prior MSP or attacker may retain remote access). Verify it is authorized; if not, remove it.' `
                -Evidence ($h.Evidence -join "`n")
        }
    } else {
        Add-Finding -Id 'sec.foreign_agents.none' -Category 'security' -Severity 'info' `
            -Title 'No competitor/leftover management agents detected' `
            -Detail 'No known competitor RMM or unmanaged remote-access agents found in installed programs or services.' `
            -Evidence 'Scanned uninstall hives (HKLM + WOW6432Node) and Win32_Service'
    }

    # ACG's own stack is expected tooling. Report as INFO so it is visible in the
    # inventory without being treated as a control/security risk.
    foreach ($h in $ourHits) {
        Add-Finding -Id ('sec.foreign_agents.acg.' + ($h.Label -replace '[^A-Za-z0-9]+','_').ToLower()) `
            -Category 'security' -Severity 'info' `
            -Title ('Expected ACG management tooling present: ' + $h.Label) `
            -Detail 'This is Arizona Computer Guru managed/remote-access tooling that we deploy. Its presence is expected and not a foreign-agent risk.' `
            -Evidence ($h.Evidence -join "`n")
    }
}

# --- Firewall profiles ---
Invoke-Check -Id 'sec.firewall' -Category 'security' -Title 'Windows Firewall profiles' -Body {
    $profiles = Get-NetFirewallProfile -ErrorAction Stop
    $state = @{}
    $disabled = @()
    foreach ($p in $profiles) {
        $en = [bool]$p.Enabled
        $state[[string]$p.Name] = $en
        if (-not $en) { $disabled += [string]$p.Name }
    }
    Set-Fact 'firewall_profiles' $state

    if ($disabled.Count -gt 0) {
        Add-Finding -Id 'sec.firewall.disabled' -Category 'security' -Severity 'critical' `
            -Title ('Firewall disabled on profile(s): ' + ($disabled -join ', ')) `
            -Detail 'One or more firewall profiles are OFF. The endpoint is exposed to lateral movement and inbound attacks on those networks. Re-enable all profiles.' `
            -Evidence ("Profile states: " + (($state.GetEnumerator() | ForEach-Object { "$($_.Key)=$($_.Value)" }) -join '; '))
    } else {
        Add-Finding -Id 'sec.firewall.ok' -Category 'security' -Severity 'info' `
            -Title 'All firewall profiles enabled' `
            -Detail 'Domain, Private, and Public firewall profiles are all enabled.' `
            -Evidence (($state.GetEnumerator() | ForEach-Object { "$($_.Key)=$($_.Value)" }) -join '; ')
    }
}

# --- BitLocker ---
Invoke-Check -Id 'sec.bitlocker' -Category 'security' -Title 'BitLocker on OS volume' -Body {
    $sysDrive = $env:SystemDrive
    if ([string]::IsNullOrEmpty($sysDrive)) { $sysDrive = 'C:' }

    $vol = $null
    try { $vol = Get-BitLockerVolume -MountPoint $sysDrive -ErrorAction Stop } catch { $vol = $null }
    if ($null -eq $vol) {
        Add-Finding -Id 'sec.bitlocker.unavailable' -Category 'security' -Severity 'unknown' `
            -Title 'BitLocker status unavailable' `
            -Detail 'Get-BitLockerVolume failed for the OS volume. BitLocker may not be installed (Home edition) or the cmdlet is unavailable. Verify encryption manually (manage-bde -status).' `
            -Evidence "MountPoint=$sysDrive, Get-BitLockerVolume returned null"
        Set-Fact 'bitlocker' @{ os_volume = $sysDrive; available = $false }
        return
    }

    $protection = [string]$vol.ProtectionStatus   # On / Off
    $pct = 0
    try { $pct = [int]$vol.EncryptionPercentage } catch { $pct = -1 }
    $hasRecovery = $false
    $protTypes = @()
    try {
        foreach ($kp in $vol.KeyProtector) {
            $protTypes += [string]$kp.KeyProtectorType
            if ([string]$kp.KeyProtectorType -match 'RecoveryPassword') { $hasRecovery = $true }
        }
    } catch { }

    Set-Fact 'bitlocker' @{
        os_volume          = $sysDrive
        available          = $true
        protection_status  = $protection
        encryption_percent = $pct
        recovery_key_present = $hasRecovery
        key_protectors     = $protTypes
    }

    $ev = "Volume=$sysDrive; ProtectionStatus=$protection; EncryptionPercentage=$pct; KeyProtectors=$($protTypes -join ',')"

    $encrypted = ($protection -eq 'On')
    if (-not $encrypted) {
        $sev = 'warning'
        $extra = ''
        if ($script:IsLaptop) { $sev = 'critical'; $extra = ' This is a laptop (portable chassis), so the data-at-rest risk if lost or stolen is high.' }
        Add-Finding -Id 'sec.bitlocker.unencrypted' -Category 'security' -Severity $sev `
            -Title 'OS volume is NOT encrypted with BitLocker' `
            -Detail ('The operating system volume is unencrypted. Data is exposed if the disk is removed or the device is lost.' + $extra + ' Enable BitLocker and escrow the recovery key.') `
            -Evidence $ev
    } else {
        if (-not $hasRecovery) {
            Add-Finding -Id 'sec.bitlocker.no_recovery' -Category 'security' -Severity 'warning' `
                -Title 'BitLocker on, but no recovery password protector' `
                -Detail 'The OS volume is encrypted but has no RecoveryPassword key protector. Without an escrowed recovery key, a TPM/boot change can permanently lock out the data. Add a recovery password and escrow it.' `
                -Evidence $ev
        } else {
            Add-Finding -Id 'sec.bitlocker.ok' -Category 'security' -Severity 'info' `
                -Title 'OS volume encrypted with recovery protector present' `
                -Detail 'BitLocker is on for the OS volume and a recovery password protector exists.' `
                -Evidence $ev
        }
    }
}

# --- Local administrators / privileged accounts ---
Invoke-Check -Id 'sec.local_admins' -Category 'security' -Title 'Local administrators and privileged accounts' -Body {
    $adminMembers = @()
    try {
        $members = Get-LocalGroupMember -Group 'Administrators' -ErrorAction Stop
        foreach ($m in $members) { $adminMembers += [string]$m.Name }
    } catch {
        # Fallback via net localgroup if the cmdlet is unavailable.
        try {
            $raw = (net localgroup Administrators) 2>$null
            $capture = $false
            foreach ($line in $raw) {
                if ($line -match '^----') { $capture = $true; continue }
                if ($line -match 'completed successfully') { $capture = $false; continue }
                if ($capture -and $line.Trim().Length -gt 0) { $adminMembers += $line.Trim() }
            }
        } catch { }
    }
    Set-Fact 'local_administrators' $adminMembers

    # Built-in Administrator (RID 500) enabled?
    $builtinAdminEnabled = $null
    $neverExpire = @()
    try {
        $users = Get-LocalUser -ErrorAction Stop
        foreach ($u in $users) {
            try {
                if ([string]$u.SID -match '-500$') { $builtinAdminEnabled = [bool]$u.Enabled }
            } catch { }
            try {
                if ($u.Enabled -and $u.PasswordNeverExpires) { $neverExpire += [string]$u.Name }
            } catch { }
        }
    } catch { }

    Set-Fact 'builtin_admin_enabled' $builtinAdminEnabled
    Set-Fact 'accounts_password_never_expires' $neverExpire

    Add-Finding -Id 'sec.local_admins.list' -Category 'security' -Severity 'info' `
        -Title ('Local administrators (' + $adminMembers.Count + ')') `
        -Detail 'Members of the local Administrators group. Review for unexpected or unknown accounts (especially leftover MSP/vendor accounts from a prior provider).' `
        -Evidence ($adminMembers -join "`n")

    if ($builtinAdminEnabled -eq $true) {
        Add-Finding -Id 'sec.local_admins.builtin_enabled' -Category 'security' -Severity 'warning' `
            -Title 'Built-in Administrator account is enabled' `
            -Detail 'The built-in Administrator (RID 500) is enabled. It is a well-known target for brute force and lateral movement. Disable it or ensure it is managed by LAPS with a strong unique password.' `
            -Evidence 'Get-LocalUser SID ...-500 Enabled=True'
    }

    if ($neverExpire.Count -gt 0) {
        Add-Finding -Id 'sec.local_admins.never_expire' -Category 'security' -Severity 'warning' `
            -Title ('Enabled accounts with non-expiring passwords: ' + ($neverExpire -join ', ')) `
            -Detail 'These enabled local accounts have PasswordNeverExpires set. Non-expiring passwords increase the window of exposure if credentials leak. Review and apply a rotation policy or LAPS.' `
            -Evidence ($neverExpire -join "`n")
    }
}

# --- Patch posture ---
Invoke-Check -Id 'sec.patch' -Category 'security' -Title 'Patch posture and OS support' -Body {
    # Last installed hotfix
    $lastHotfix = $null
    try {
        $hf = Get-HotFix -ErrorAction Stop | Where-Object { $_.InstalledOn } | Sort-Object InstalledOn -Descending | Select-Object -First 1
        if ($hf) {
            $lastHotfix = @{
                hotfix_id    = [string]$hf.HotFixID
                installed_on = $hf.InstalledOn.ToUniversalTime().ToString('yyyy-MM-ddTHH:mm:ssZ')
            }
        }
    } catch { }
    Set-Fact 'last_hotfix' $lastHotfix

    # Pending Windows Update count (COM). Best-effort; can be slow or blocked.
    $pendingCount = $null
    try {
        $session  = New-Object -ComObject Microsoft.Update.Session
        $searcher = $session.CreateUpdateSearcher()
        $result   = $searcher.Search("IsInstalled=0 and IsHidden=0")
        $pendingCount = [int]$result.Updates.Count
    } catch { $pendingCount = $null }
    Set-Fact 'pending_updates' $pendingCount

    # OS build + EOL map
    $build = $script:OsInfo.build
    $caption = $script:OsInfo.caption
    Set-Fact 'os_build' $build

    # EOL map keyed by build number (end-of-servicing for that build).
    # IMPORTANT: client and server SKUs SHARE build numbers (17763 = Win10 1809 AND
    # Server 2019; 14393 = Win10 1607 AND Server 2016; 26100 = Win11 24H2 AND Server
    # 2025) but have very different lifecycles. Keying a Server build against the client
    # map produces a FALSE end-of-life critical (e.g. Server 2019 flagged EOL-2020). So
    # branch on SKU first.
    $isServer = ($caption -match 'Server')
    $eolMapClient = @{
        # Windows 10 builds
        '10240' = @{ name='Win10 1507'; eol='2017-05-09' }
        '10586' = @{ name='Win10 1511'; eol='2017-10-10' }
        '14393' = @{ name='Win10 1607'; eol='2018-04-10' }
        '15063' = @{ name='Win10 1703'; eol='2018-10-09' }
        '16299' = @{ name='Win10 1709'; eol='2019-04-09' }
        '17134' = @{ name='Win10 1803'; eol='2019-11-12' }
        '17763' = @{ name='Win10 1809'; eol='2020-11-10' }
        '18362' = @{ name='Win10 1903'; eol='2019-12-08' }
        '18363' = @{ name='Win10 1909'; eol='2021-05-11' }
        '19041' = @{ name='Win10 2004'; eol='2021-12-14' }
        '19042' = @{ name='Win10 20H2'; eol='2022-05-10' }
        '19043' = @{ name='Win10 21H1'; eol='2022-12-13' }
        '19044' = @{ name='Win10 21H2'; eol='2023-06-13' }
        '19045' = @{ name='Win10 22H2'; eol='2025-10-14' }
        # Windows 11 builds
        '22000' = @{ name='Win11 21H2'; eol='2023-10-10' }
        '22621' = @{ name='Win11 22H2'; eol='2024-10-08' }
        '22631' = @{ name='Win11 23H2'; eol='2025-11-11' }
        '26100' = @{ name='Win11 24H2'; eol='2026-10-13' }
        '26200' = @{ name='Win11 25H2'; eol='2027-10-12' }
    }
    # Windows Server builds (extended-support end dates).
    $eolMapServer = @{
        '9200'  = @{ name='Server 2012';    eol='2023-10-10' }
        '9600'  = @{ name='Server 2012 R2'; eol='2023-10-10' }
        '14393' = @{ name='Server 2016';    eol='2027-01-12' }
        '17763' = @{ name='Server 2019';    eol='2029-01-09' }
        '20348' = @{ name='Server 2022';    eol='2031-10-14' }
        '25398' = @{ name='Server 23H2';    eol='2025-10-24' }
        '26100' = @{ name='Server 2025';    eol='2034-10-10' }
    }
    $eolMap = if ($isServer) { $eolMapServer } else { $eolMapClient }

    $now = (Get-Date).ToUniversalTime()
    $eolEntry = $null
    if ($eolMap.ContainsKey([string]$build)) { $eolEntry = $eolMap[[string]$build] }

    if ($eolEntry) {
        $eolDate = $null
        try { $eolDate = [datetime]::ParseExact($eolEntry.eol, 'yyyy-MM-dd', $null) } catch { }
        Set-Fact 'os_eol' @{ release=$eolEntry.name; eol_date=$eolEntry.eol }
        if ($eolDate -and $now -gt $eolDate) {
            Add-Finding -Id 'sec.patch.os_eol' -Category 'security' -Severity 'critical' `
                -Title ('OS build is end-of-life: ' + $eolEntry.name) `
                -Detail ('This OS build (' + $build + ', ' + $eolEntry.name + ') passed end-of-servicing on ' + $eolEntry.eol + '. It no longer receives security updates. Plan a feature update or OS upgrade.') `
                -Evidence "$caption build $build; EOL $($eolEntry.eol)"
        } elseif ($eolDate -and $now -gt $eolDate.AddDays(-90)) {
            Add-Finding -Id 'sec.patch.os_eol_soon' -Category 'security' -Severity 'warning' `
                -Title ('OS build nearing end-of-life: ' + $eolEntry.name) `
                -Detail ('This OS build reaches end-of-servicing on ' + $eolEntry.eol + ' (within 90 days). Schedule a feature update.') `
                -Evidence "$caption build $build; EOL $($eolEntry.eol)"
        } else {
            Add-Finding -Id 'sec.patch.os_supported' -Category 'security' -Severity 'info' `
                -Title ('OS build supported: ' + $eolEntry.name) `
                -Detail ('Build ' + $build + ' (' + $eolEntry.name + ') is in support until ' + $eolEntry.eol + '.') `
                -Evidence "$caption build $build"
        }
    } else {
        Add-Finding -Id 'sec.patch.os_build_unknown' -Category 'security' -Severity 'unknown' `
            -Title ('OS build not in EOL map: ' + $build) `
            -Detail 'The build number is not in the local EOL reference map. Verify support status manually. This may be a Server SKU or a build newer than the map.' `
            -Evidence "$caption build $build"
    }

    if ($pendingCount -ne $null -and $pendingCount -gt 0) {
        $sev = 'warning'
        Add-Finding -Id 'sec.patch.pending' -Category 'security' -Severity $sev `
            -Title ($pendingCount.ToString() + ' pending Windows updates') `
            -Detail 'Windows Update reports pending (not installed, not hidden) updates. Some may be security updates. Approve/install on the next maintenance window.' `
            -Evidence "Microsoft.Update.Session search IsInstalled=0 and IsHidden=0 -> $pendingCount"
    }

    if ($lastHotfix) {
        Add-Finding -Id 'sec.patch.last_hotfix' -Category 'security' -Severity 'info' `
            -Title ('Last hotfix: ' + $lastHotfix.hotfix_id) `
            -Detail 'Most recently installed update (from Get-HotFix; reflects CBS/MSU packages, not all cumulative metadata).' `
            -Evidence ("$($lastHotfix.hotfix_id) installed $($lastHotfix.installed_on)")
    }
}

# --- Exposure surface: RDP/NLA, SMBv1, UAC, LAPS ---
Invoke-Check -Id 'sec.exposure' -Category 'security' -Title 'Exposure surface (RDP, SMBv1, UAC, LAPS)' -Body {
    $exposure = @{}

    # RDP enabled? fDenyTSConnections = 0 means RDP enabled.
    $rdpEnabled = $null
    try {
        $deny = (Get-ItemProperty -Path 'HKLM:\System\CurrentControlSet\Control\Terminal Server' -Name 'fDenyTSConnections' -ErrorAction Stop).fDenyTSConnections
        $rdpEnabled = ($deny -eq 0)
    } catch { $rdpEnabled = $null }
    $exposure['rdp_enabled'] = $rdpEnabled

    # NLA (UserAuthentication = 1 means NLA required)
    $nla = $null
    try {
        $ua = (Get-ItemProperty -Path 'HKLM:\System\CurrentControlSet\Control\Terminal Server\WinStations\RDP-Tcp' -Name 'UserAuthentication' -ErrorAction Stop).UserAuthentication
        $nla = ($ua -eq 1)
    } catch { $nla = $null }
    $exposure['rdp_nla'] = $nla

    if ($rdpEnabled -eq $true) {
        if ($nla -eq $false) {
            Add-Finding -Id 'sec.exposure.rdp_no_nla' -Category 'security' -Severity 'critical' `
                -Title 'RDP enabled WITHOUT Network Level Authentication' `
                -Detail 'RDP is on and NLA is not required. This exposes the logon screen pre-auth and is vulnerable to pre-auth exploits and brute force. Require NLA, restrict RDP to VPN/allow-listed IPs, or disable RDP.' `
                -Evidence "fDenyTSConnections=0; UserAuthentication=0"
        } else {
            Add-Finding -Id 'sec.exposure.rdp_on' -Category 'security' -Severity 'warning' `
                -Title 'RDP is enabled' `
                -Detail 'Remote Desktop is enabled (NLA required). Confirm it is restricted to VPN or specific source IPs and not exposed to the internet.' `
                -Evidence "fDenyTSConnections=0; UserAuthentication=$([int]($nla -eq $true))"
        }
    }

    # SMBv1
    $smb1 = $null
    try {
        $cfg = Get-SmbServerConfiguration -ErrorAction Stop
        $smb1 = [bool]$cfg.EnableSMB1Protocol
    } catch { $smb1 = $null }
    $exposure['smb1_enabled'] = $smb1
    if ($smb1 -eq $true) {
        Add-Finding -Id 'sec.exposure.smb1' -Category 'security' -Severity 'critical' `
            -Title 'SMBv1 is ENABLED' `
            -Detail 'SMBv1 is an obsolete, insecure protocol (WannaCry/EternalBlue vector). Disable it: Set-SmbServerConfiguration -EnableSMB1Protocol $false and remove the SMB1 feature.' `
            -Evidence 'Get-SmbServerConfiguration EnableSMB1Protocol=True'
    } elseif ($smb1 -eq $false) {
        Add-Finding -Id 'sec.exposure.smb1_off' -Category 'security' -Severity 'info' `
            -Title 'SMBv1 disabled' `
            -Detail 'SMBv1 server protocol is disabled.' `
            -Evidence 'EnableSMB1Protocol=False'
    }

    # UAC (EnableLUA = 1 means UAC on)
    $uac = $null
    try {
        $lua = (Get-ItemProperty -Path 'HKLM:\Software\Microsoft\Windows\CurrentVersion\Policies\System' -Name 'EnableLUA' -ErrorAction Stop).EnableLUA
        $uac = ($lua -eq 1)
    } catch { $uac = $null }
    $exposure['uac_enabled'] = $uac
    if ($uac -eq $false) {
        Add-Finding -Id 'sec.exposure.uac_off' -Category 'security' -Severity 'critical' `
            -Title 'UAC is disabled' `
            -Detail 'User Account Control is turned off (EnableLUA=0). All processes run elevated silently, removing a key barrier against malware. Re-enable UAC.' `
            -Evidence 'EnableLUA=0'
    }

    # LAPS presence (legacy AdmPwd or Windows LAPS)
    $lapsPresent = $false
    $lapsEvidence = @()
    try {
        if (Test-Path 'HKLM:\Software\Microsoft\Windows\CurrentVersion\LAPS') { $lapsPresent = $true; $lapsEvidence += 'Windows LAPS reg key' }
    } catch { }
    try {
        if (Test-Path 'HKLM:\Software\Policies\Microsoft Services\AdmPwd') { $lapsPresent = $true; $lapsEvidence += 'Legacy AdmPwd policy' }
    } catch { }
    try {
        $lapsDll = "$env:ProgramFiles\LAPS\CSE\AdmPwd.dll"
        if (Test-Path $lapsDll) { $lapsPresent = $true; $lapsEvidence += 'AdmPwd CSE installed' }
    } catch { }
    try {
        $lapsSvc = Get-Service -Name 'LAPS' -ErrorAction SilentlyContinue
        if ($lapsSvc) { $lapsPresent = $true; $lapsEvidence += 'LAPS service' }
    } catch { }
    $exposure['laps_present'] = $lapsPresent

    if (-not $lapsPresent) {
        Add-Finding -Id 'sec.exposure.no_laps' -Category 'security' -Severity 'info' `
            -Title 'LAPS not detected' `
            -Detail 'No LAPS (Windows LAPS or legacy AdmPwd) detected. Without LAPS, the local admin password is likely static/shared across the fleet. Consider deploying LAPS to randomize and escrow local admin passwords.' `
            -Evidence 'No LAPS registry keys, CSE, or service found'
    } else {
        Add-Finding -Id 'sec.exposure.laps_present' -Category 'security' -Severity 'info' `
            -Title 'LAPS detected' `
            -Detail 'A LAPS mechanism is present.' `
            -Evidence ($lapsEvidence -join '; ')
    }

    Set-Fact 'exposure' $exposure
}

# ===========================================================================
# HEALTH CHECKS
# ===========================================================================

# --- Disks: free space + SMART/reliability ---
Invoke-Check -Id 'health.disk_space' -Category 'health' -Title 'Disk free space' -Body {
    $volRows = @()
    $vols = $null
    try { $vols = Get-Volume -ErrorAction Stop | Where-Object { $_.DriveType -eq 'Fixed' -and $_.Size -gt 0 } } catch { $vols = $null }
    if ($null -eq $vols) {
        # Fallback to Win32_LogicalDisk (DriveType 3 = fixed)
        $ld = Get-CimInstance -ClassName Win32_LogicalDisk -Filter 'DriveType=3' -ErrorAction SilentlyContinue
        foreach ($d in $ld) {
            $size = [double]$d.Size
            if ($size -le 0) { continue }
            $free = [double]$d.FreeSpace
            $pct = [math]::Round(($free / $size) * 100, 1)
            $volRows += [pscustomobject]@{ Drive=[string]$d.DeviceID; FreeGB=[math]::Round($free/1GB,1); SizeGB=[math]::Round($size/1GB,1); FreePct=$pct }
        }
    } else {
        foreach ($v in $vols) {
            $size = [double]$v.Size
            if ($size -le 0) { continue }
            $free = [double]$v.SizeRemaining
            $pct = [math]::Round(($free / $size) * 100, 1)
            $letter = [string]$v.DriveLetter
            $hasLetter = -not [string]::IsNullOrEmpty($letter)
            if ($hasLetter) { $drv = $letter + ':' }
            elseif (-not [string]::IsNullOrEmpty([string]$v.FileSystemLabel)) { $drv = '[' + [string]$v.FileSystemLabel + ']' }
            else { $drv = '[unlabeled]' }
            $volRows += [pscustomobject]@{ Drive=$drv; HasLetter=$hasLetter; FreeGB=[math]::Round($free/1GB,1); SizeGB=[math]::Round($size/1GB,1); FreePct=$pct }
        }
    }

    Set-Fact 'volumes' ($volRows | ForEach-Object { @{ drive=$_.Drive; free_gb=$_.FreeGB; size_gb=$_.SizeGB; free_pct=$_.FreePct } })

    foreach ($r in $volRows) {
        # Only alert on lettered (user/data) volumes. Unlettered partitions
        # (EFI, recovery, WinRE) are near-full by design and not actionable.
        $rHasLetter = $true
        try { $rHasLetter = [bool]$r.HasLetter } catch { $rHasLetter = $true }
        if (-not $rHasLetter) { continue }

        $ev = "$($r.Drive) free $($r.FreeGB) GB of $($r.SizeGB) GB ($($r.FreePct)%)"
        # Finding id MUST be severity-independent (id keyed only on the drive, not
        # the tier) so the runner's diff can see a warn->crit transition on the
        # same drive as a REGRESSION rather than a resolved+new pair. The tier
        # lives only in the -Severity field.
        $driveKey = ($r.Drive -replace '[^A-Za-z0-9]','')
        if ($r.FreePct -lt 8) {
            Add-Finding -Id ("health.disk_space." + $driveKey) -Category 'health' -Severity 'critical' `
                -Title ("Disk critically low: $($r.Drive) at $($r.FreePct)% free") `
                -Detail 'Less than 8 percent free. Risk of failed updates, crashes, and corruption. Free space or expand the volume urgently.' `
                -Evidence $ev
        } elseif ($r.FreePct -lt 15) {
            Add-Finding -Id ("health.disk_space." + $driveKey) -Category 'health' -Severity 'warning' `
                -Title ("Disk low: $($r.Drive) at $($r.FreePct)% free") `
                -Detail 'Less than 15 percent free. Plan cleanup or expansion.' `
                -Evidence $ev
        }
    }
}

Invoke-Check -Id 'health.disk_smart' -Category 'health' -Title 'Physical disk health (SMART)' -Body {
    $disks = $null
    try { $disks = Get-PhysicalDisk -ErrorAction Stop } catch { $disks = $null }
    if ($null -eq $disks) {
        Add-Finding -Id 'health.disk_smart.unavailable' -Category 'health' -Severity 'unknown' `
            -Title 'Physical disk health unavailable' `
            -Detail 'Get-PhysicalDisk is unavailable (older OS / RAID controller hiding disks). Verify drive health via vendor tools.' `
            -Evidence 'Get-PhysicalDisk returned null'
        return
    }
    $diskFacts = @()
    foreach ($d in $disks) {
        $hs = [string]$d.HealthStatus
        $model = [string]$d.FriendlyName
        $diskFacts += @{ model=$model; health=$hs; media_type=[string]$d.MediaType }

        # Reliability counters (best-effort; not all disks expose them)
        $relEv = ''
        try {
            $rel = $d | Get-StorageReliabilityCounter -ErrorAction Stop
            if ($rel) {
                $relEv = "Wear=$($rel.Wear); ReadErrorsTotal=$($rel.ReadErrorsTotal); Temperature=$($rel.Temperature)"
            }
        } catch { }

        if ($hs -and $hs -ne 'Healthy') {
            # Severity-independent id (keyed on disk model only, not the health
            # tier) so the runner diff treats a health change on the same disk as
            # a regression, not new+resolved. Tier lives only in -Severity.
            Add-Finding -Id ("health.disk_smart." + ($model -replace '[^A-Za-z0-9]+','_').ToLower()) -Category 'health' -Severity 'critical' `
                -Title ("Disk not healthy: $model ($hs)") `
                -Detail 'A physical disk reports a non-Healthy SMART/health status. Imminent failure risk. Back up immediately and plan replacement.' `
                -Evidence (("HealthStatus=$hs; " + $relEv).Trim())
        }
    }
    Set-Fact 'physical_disks' $diskFacts
}

# --- Stability: unexpected shutdowns, BSOD, disk errors (last 14 days) ---
Invoke-Check -Id 'health.stability' -Category 'health' -Title 'Stability events (14 days)' -Body {
    $start = (Get-Date).AddDays(-14)

    function Count-Events {
        param([string]$LogName, [int[]]$Ids, [string]$Source)
        $filter = @{ LogName = $LogName; StartTime = $start; Id = $Ids }
        $cnt = 0
        try {
            $evts = Get-WinEvent -FilterHashtable $filter -ErrorAction Stop
            if ($Source) { $evts = $evts | Where-Object { $_.ProviderName -eq $Source } }
            $cnt = @($evts).Count
        } catch {
            # "No events found" throws on PS5.1; treat as zero.
            $cnt = 0
        }
        return $cnt
    }

    $unexpected = Count-Events -LogName 'System' -Ids @(41)
    $bugcheck   = Count-Events -LogName 'System' -Ids @(1001) -Source 'Microsoft-Windows-WER-SystemErrorReporting'
    if ($bugcheck -eq 0) { $bugcheck = Count-Events -LogName 'System' -Ids @(1001) -Source 'BugCheck' }
    # ids 7/51/153 are SHARED across providers: provider 'disk' (and other storage drivers)
    # = a real I/O error, but 'Microsoft-Windows-Kernel-Boot' id 153 = "VBS disabled" boot
    # noise — NOT a disk error. The old unfiltered fallback counted that noise as disk errors
    # (false-positive stability warning on healthy servers). Count only true storage providers.
    $diskProviders = @('disk','Disk','storahci','stornvme','iaStorA','iaStorAC','EhStorClass','Microsoft-Windows-Disk','volmgr','Ntfs')
    $diskErr = 0
    try {
        $de = Get-WinEvent -FilterHashtable @{ LogName='System'; StartTime=$start; Id=@(7,51,153) } -ErrorAction Stop
        $diskErr = @($de | Where-Object { $diskProviders -contains $_.ProviderName }).Count
    } catch { $diskErr = 0 }

    Set-Fact 'stability_14d' @{
        unexpected_shutdowns = $unexpected
        bugchecks            = $bugcheck
        disk_errors          = $diskErr
    }

    $report = "Unexpected shutdowns (id 41)=$unexpected; Bugchecks/BSOD (id 1001)=$bugcheck; Disk errors (id 7/51/153)=$diskErr"

    if ($unexpected -ge 3 -or $bugcheck -ge 3 -or $diskErr -ge 3) {
        Add-Finding -Id 'health.stability.recurring' -Category 'health' -Severity 'critical' `
            -Title 'Recurring stability events in the last 14 days' `
            -Detail 'Three or more of one event class (unexpected shutdown, BSOD, or disk error) in 14 days indicates a hardware or driver problem. Investigate memory, disk, PSU, and drivers.' `
            -Evidence $report
    } elseif ($unexpected -gt 0 -or $bugcheck -gt 0 -or $diskErr -gt 0) {
        Add-Finding -Id 'health.stability.some' -Category 'health' -Severity 'warning' `
            -Title 'Stability events present in the last 14 days' `
            -Detail 'One or more unexpected shutdowns, BSODs, or disk errors occurred recently. Monitor and correlate with user reports.' `
            -Evidence $report
    } else {
        Add-Finding -Id 'health.stability.clean' -Category 'health' -Severity 'info' `
            -Title 'No stability events in the last 14 days' `
            -Detail 'No unexpected shutdowns, BSODs, or disk errors logged.' `
            -Evidence $report
    }
}

# --- Pending reboot + uptime + failed auto-start services ---
Invoke-Check -Id 'health.reboot_uptime' -Category 'health' -Title 'Pending reboot and uptime' -Body {
    $pending = $false
    $reasons = @()
    try { if (Test-Path 'HKLM:\Software\Microsoft\Windows\CurrentVersion\Component Based Servicing\RebootPending') { $pending = $true; $reasons += 'CBS RebootPending' } } catch { }
    try { if (Test-Path 'HKLM:\Software\Microsoft\Windows\CurrentVersion\WindowsUpdate\Auto Update\RebootRequired') { $pending = $true; $reasons += 'WU RebootRequired' } } catch { }
    try {
        $pfro = (Get-ItemProperty -Path 'HKLM:\System\CurrentControlSet\Control\Session Manager' -Name 'PendingFileRenameOperations' -ErrorAction Stop).PendingFileRenameOperations
        if ($pfro) { $pending = $true; $reasons += 'PendingFileRenameOperations' }
    } catch { }
    Set-Fact 'pending_reboot' $pending

    if ($pending) {
        Add-Finding -Id 'health.reboot_uptime.pending' -Category 'health' -Severity 'warning' `
            -Title 'Reboot pending' `
            -Detail 'A reboot is pending. Pending reboots can block patches and leave the system in a half-updated state. Schedule a restart.' `
            -Evidence ($reasons -join '; ')
    }

    # Uptime
    try {
        $lastBoot = (Get-CimInstance -ClassName Win32_OperatingSystem -ErrorAction Stop).LastBootUpTime
        $uptimeDays = [math]::Round(((Get-Date) - $lastBoot).TotalDays, 1)
        Set-Fact 'uptime_days' $uptimeDays
        if ($uptimeDays -gt 30) {
            Add-Finding -Id 'health.reboot_uptime.long_uptime' -Category 'health' -Severity 'warning' `
                -Title ("Uptime is $uptimeDays days") `
                -Detail 'Uptime exceeds 30 days. Long uptime usually means pending updates have not been applied (reboots deferred). Schedule maintenance.' `
                -Evidence ("LastBootUpTime=" + $lastBoot.ToString('u'))
        }
    } catch { }
}

Invoke-Check -Id 'health.failed_services' -Category 'health' -Title 'Failed auto-start services' -Body {
    $failed = @()
    try {
        $svcs = Get-CimInstance -ClassName Win32_Service -ErrorAction Stop |
            Where-Object { $_.StartMode -eq 'Auto' -and $_.State -ne 'Running' }
        foreach ($s in $svcs) {
            # Ignore services with delayed start that are simply not yet started? Keep them; they should be running.
            $failed += [pscustomobject]@{ Name=[string]$s.Name; Display=[string]$s.DisplayName; State=[string]$s.State }
        }
    } catch { }

    # Filter out known benign trigger-start services masquerading as Auto in some images.
    $benign = 'gupdate|gupdatem|MapsBroker|RemoteRegistry|sppsvc|edgeupdate|edgeupdatem|cbdhsvc|WbioSrvc'
    $real = @()
    foreach ($f in $failed) {
        if ($f.Name -notmatch $benign) { $real += $f }
    }
    Set-Fact 'failed_autostart_services' ($real | ForEach-Object { @{ name=$_.Name; display=$_.Display; state=$_.State } })

    if ($real.Count -gt 0) {
        $list = ($real | ForEach-Object { "$($_.Name) ($($_.Display)) = $($_.State)" }) -join "`n"
        Add-Finding -Id 'health.failed_services.stopped' -Category 'health' -Severity 'warning' `
            -Title ($real.Count.ToString() + ' auto-start service(s) not running') `
            -Detail 'These services are set to start automatically but are not running. Some may be benign; review for security agents, backup agents, or AV that should be running.' `
            -Evidence $list
    } else {
        Add-Finding -Id 'health.failed_services.ok' -Category 'health' -Severity 'info' `
            -Title 'All auto-start services running' `
            -Detail 'No automatic-start services found stopped (excluding known trigger-start/update services).' `
            -Evidence 'Win32_Service StartMode=Auto State!=Running -> none significant'
    }
}

# --- Domain secure channel + time skew ---
Invoke-Check -Id 'health.domain' -Category 'health' -Title 'Domain membership and secure channel' -Body {
    $cs = Get-CimInstance -ClassName Win32_ComputerSystem -ErrorAction Stop
    $partOfDomain = [bool]$cs.PartOfDomain
    $domain = [string]$cs.Domain
    Set-Fact 'domain_joined' $partOfDomain
    Set-Fact 'domain' $domain

    if ($partOfDomain) {
        $scOk = $null
        try { $scOk = Test-ComputerSecureChannel -ErrorAction Stop } catch { $scOk = $null }
        Set-Fact 'secure_channel_ok' $scOk
        if ($scOk -eq $false) {
            Add-Finding -Id 'health.domain.secure_channel_broken' -Category 'health' -Severity 'critical' `
                -Title 'Domain secure channel is BROKEN' `
                -Detail 'Test-ComputerSecureChannel returned false. The machine trust relationship with the domain is broken (Group Policy, Kerberos, and domain logon will fail). Repair with Test-ComputerSecureChannel -Repair or rejoin.' `
                -Evidence "PartOfDomain=True; Test-ComputerSecureChannel=False; Domain=$domain"
        } elseif ($scOk -eq $true) {
            Add-Finding -Id 'health.domain.secure_channel_ok' -Category 'health' -Severity 'info' `
                -Title 'Domain secure channel healthy' `
                -Detail 'Machine trust relationship with the domain is intact.' `
                -Evidence "Domain=$domain"
        }
    } else {
        Add-Finding -Id 'health.domain.workgroup' -Category 'health' -Severity 'info' `
            -Title 'Not domain-joined (workgroup)' `
            -Detail ('This machine is in workgroup/Azure AD only mode (Domain=' + $domain + '). No on-prem AD secure channel applies.') `
            -Evidence "PartOfDomain=False; Domain=$domain"
    }
}

Invoke-Check -Id 'health.time' -Category 'health' -Title 'Time service status' -Body {
    # w32tm /query /status is non-blocking (unlike /stripchart). Parse leniently.
    $statusText = ''
    try { $statusText = (w32tm /query /status 2>&1 | Out-String) } catch { $statusText = '' }
    $source = ''
    try {
        $src = (w32tm /query /source 2>&1 | Out-String).Trim()
        if ($src) { $source = $src }
    } catch { }
    Set-Fact 'time_source' $source

    if ($statusText -match 'Local CMOS Clock' -or $source -match 'Local CMOS Clock') {
        Add-Finding -Id 'health.time.local_cmos' -Category 'health' -Severity 'warning' `
            -Title 'Time source is local CMOS clock (not NTP)' `
            -Detail 'The system is not syncing time from an NTP source. Clock drift breaks Kerberos and certificate validation. Configure a reliable time source (domain hierarchy or pool.ntp.org).' `
            -Evidence ("Source=$source")
    } else {
        Add-Finding -Id 'health.time.source' -Category 'health' -Severity 'info' `
            -Title 'Time service source' `
            -Detail 'Current Windows Time service source.' `
            -Evidence ("Source=$source")
    }
}

# --- Battery health (laptops) ---
Invoke-Check -Id 'health.battery' -Category 'health' -Title 'Battery health' -Body {
    if (-not $script:IsLaptop) {
        Set-Fact 'battery' @{ present = $false }
        return
    }
    $bat = Get-CimInstance -ClassName Win32_Battery -ErrorAction SilentlyContinue
    if ($null -eq $bat) {
        Set-Fact 'battery' @{ present = $false }
        Add-Finding -Id 'health.battery.none' -Category 'health' -Severity 'info' `
            -Title 'Laptop chassis but no battery reported' `
            -Detail 'Chassis indicates a portable device but Win32_Battery returned nothing (battery removed, or detection failed).' `
            -Evidence 'Win32_Battery returned null'
        return
    }
    $b = $bat | Select-Object -First 1
    $charge = ''
    try { $charge = [string]$b.EstimatedChargeRemaining } catch { }
    Set-Fact 'battery' @{ present = $true; estimated_charge_remaining = $charge; status = [string]$b.BatteryStatus }
    Add-Finding -Id 'health.battery.present' -Category 'health' -Severity 'info' `
        -Title 'Battery present' `
        -Detail 'Battery detected. (Wear-level / design-vs-full-capacity requires a powercfg battery report, not collected here.)' `
        -Evidence ("EstimatedChargeRemaining=$charge%; BatteryStatus=$($b.BatteryStatus)")
}

# --- Backup agent presence ---
Invoke-Check -Id 'health.backup' -Category 'health' -Title 'Backup agent presence' -Body {
    # Anchor with \b to avoid substring false positives (e.g. bare 'cove'
    # matched 'Recovery'/'Discovery' service display names).
    $patterns = @(
        @{ rx='datto.*workplace|datto.*backup';                      label='Datto Workplace' },
        @{ rx='\bmsp360\b|\bcloudberry\b';                           label='MSP360 / CloudBerry' },
        @{ rx='\bveeam\b';                                           label='Veeam' },
        @{ rx='\bacronis\b';                                         label='Acronis' },
        @{ rx='\bcarbonite\b';                                       label='Carbonite' },
        @{ rx='\bbackblaze\b';                                       label='Backblaze' },
        @{ rx='shadowprotect|storagecraft';                          label='ShadowProtect / StorageCraft' },
        @{ rx='\baxcient\b|\breplibit\b|\bx360\b';                   label='Axcient' },
        @{ rx='\bcove\b|n-able backup';                              label='Cove / N-able Backup' },
        @{ rx='\bveritas\b|backup exec|\bbackupexec\b';              label='Veritas / Backup Exec' },
        @{ rx='datto.*siris|datto.*alto|\bsiris\b';                  label='Datto SIRIS/ALTO agent' },
        @{ rx='comet backup|\bcometbackup\b|\bcomet-backup\b';       label='Comet Backup' },
        @{ rx='\bdruva\b|\binsync\b';                                label='Druva' }
    )
    $svcRows = @()
    try { $svcRows = Get-CimInstance -ClassName Win32_Service -ErrorAction Stop | Select-Object Name, DisplayName, State } catch { }

    $found = @()
    foreach ($pat in $patterns) {
        foreach ($s in $svcRows) {
            $sn = [string]$s.Name; $sd = [string]$s.DisplayName
            if ($sn -match $pat.rx -or $sd -match $pat.rx) {
                $found += [pscustomobject]@{ Label=$pat.label; Service=$sn; Display=$sd; State=[string]$s.State }
            }
        }
    }
    Set-Fact 'backup_agents' ($found | ForEach-Object { @{ label=$_.Label; service=$_.Service; state=$_.State } })

    if (@($found).Count -gt 0) {
        $running = @($found | Where-Object { $_.State -eq 'Running' })
        $list = ($found | ForEach-Object { "$($_.Label): $($_.Service) = $($_.State)" }) -join "`n"
        if ($running.Count -gt 0) {
            Add-Finding -Id 'health.backup.present' -Category 'health' -Severity 'info' `
                -Title 'Backup agent installed and running' `
                -Detail 'A backup agent service is present and running. Confirm the backup is actually configured and reporting successful jobs (presence != working backup).' `
                -Evidence $list
        } else {
            Add-Finding -Id 'health.backup.stopped' -Category 'health' -Severity 'warning' `
                -Title 'Backup agent installed but NOT running' `
                -Detail 'A backup agent service exists but is not running. Backups may not be occurring. Investigate.' `
                -Evidence $list
        }
    } else {
        Add-Finding -Id 'health.backup.none' -Category 'health' -Severity 'info' `
            -Title 'No backup agent detected' `
            -Detail 'No known backup agent service found. Backup expectation varies by endpoint; confirm whether this machine is supposed to have local/cloud backup and whether server-side or M365 backup covers it.' `
            -Evidence 'No matching backup service in Win32_Service'
    }
}

# ===========================================================================
# INVENTORY BASELINE (facts, info-level)
# ===========================================================================

Invoke-Check -Id 'inv.hardware' -Category 'inventory' -Title 'Hardware inventory' -Body {
    $cs   = Get-CimInstance -ClassName Win32_ComputerSystem -ErrorAction SilentlyContinue
    $bios = Get-CimInstance -ClassName Win32_BIOS -ErrorAction SilentlyContinue
    $cpu  = Get-CimInstance -ClassName Win32_Processor -ErrorAction SilentlyContinue | Select-Object -First 1

    $hw = @{}
    if ($cs) {
        $hw['manufacturer'] = [string]$cs.Manufacturer
        $hw['model']        = [string]$cs.Model
        $hw['ram_gb']       = [math]::Round(([double]$cs.TotalPhysicalMemory)/1GB, 1)
    }
    if ($bios) {
        $hw['serial']        = [string]$bios.SerialNumber
        $hw['bios_version']  = [string]($bios.SMBIOSBIOSVersion)
        try { $hw['bios_date'] = $bios.ReleaseDate.ToUniversalTime().ToString('yyyy-MM-dd') } catch { }
    }
    if ($cpu) {
        $hw['cpu']        = [string]$cpu.Name
        $hw['cpu_cores']  = [int]$cpu.NumberOfCores
        $hw['cpu_logical']= [int]$cpu.NumberOfLogicalProcessors
    }
    Set-Fact 'hardware' $hw
}

Invoke-Check -Id 'inv.tpm_secureboot' -Category 'inventory' -Title 'TPM and Secure Boot' -Body {
    $tpm = @{}
    try {
        $t = Get-Tpm -ErrorAction Stop
        $tpm['present']  = [bool]$t.TpmPresent
        $tpm['ready']    = [bool]$t.TpmReady
        $tpm['enabled']  = [bool]$t.TpmEnabled
    } catch { $tpm['present'] = $null; $tpm['error'] = 'Get-Tpm unavailable' }
    Set-Fact 'tpm' $tpm

    $sb = $null
    try { $sb = Confirm-SecureBootUEFI -ErrorAction Stop } catch { $sb = $null }
    Set-Fact 'secure_boot' $sb
}

Invoke-Check -Id 'inv.activation' -Category 'inventory' -Title 'OS edition and activation' -Body {
    $lic = $null
    try {
        $lic = Get-CimInstance -ClassName SoftwareLicensingProduct -ErrorAction Stop |
            Where-Object { $_.PartialProductKey -and $_.ApplicationID -eq '55c92734-d682-4d71-983e-d6ec3f16059f' } |
            Select-Object -First 1
    } catch { $lic = $null }
    $act = @{}
    if ($lic) {
        $st = [int]$lic.LicenseStatus   # 1 = licensed
        $act['license_status_code'] = $st
        $act['licensed'] = ($st -eq 1)
        $act['description'] = [string]$lic.Description
    } else {
        $act['licensed'] = $null
    }
    $act['edition'] = $script:OsInfo.caption
    Set-Fact 'activation' $act
}

Invoke-Check -Id 'inv.software' -Category 'inventory' -Title 'Installed software' -Body {
    $sw = @()
    $paths = @(
        'HKLM:\Software\Microsoft\Windows\CurrentVersion\Uninstall\*',
        'HKLM:\Software\WOW6432Node\Microsoft\Windows\CurrentVersion\Uninstall\*'
    )
    foreach ($p in $paths) {
        try {
            $items = Get-ItemProperty $p -ErrorAction SilentlyContinue
            foreach ($it in $items) {
                if ($it.DisplayName) {
                    $sw += [pscustomobject]@{
                        name      = [string]$it.DisplayName
                        version   = [string]$it.DisplayVersion
                        publisher = [string]$it.Publisher
                    }
                }
            }
        } catch { }
    }
    # De-dup by name+version, sort by name.
    $uniq = $sw | Sort-Object name, version -Unique
    Set-Fact 'installed_software' ($uniq | ForEach-Object { @{ name=$_.name; version=$_.version; publisher=$_.publisher } })
    Set-Fact 'installed_software_count' @($uniq).Count
}

Invoke-Check -Id 'inv.users' -Category 'inventory' -Title 'Local users and groups' -Body {
    $users = @()
    try {
        foreach ($u in (Get-LocalUser -ErrorAction Stop)) {
            $ll = ''
            try { if ($u.LastLogon) { $ll = $u.LastLogon.ToString('yyyy-MM-dd') } } catch { }
            $users += @{
                name                  = [string]$u.Name
                enabled               = [bool]$u.Enabled
                password_never_expires= [bool]$u.PasswordNeverExpires
                last_logon            = $ll
            }
        }
    } catch { }
    Set-Fact 'local_users' $users

    $groups = @()
    try {
        foreach ($g in (Get-LocalGroup -ErrorAction Stop)) { $groups += [string]$g.Name }
    } catch { }
    Set-Fact 'local_groups' $groups
}

Invoke-Check -Id 'inv.network' -Category 'inventory' -Title 'Network configuration' -Body {
    $net = @()
    try {
        $cfgs = Get-CimInstance -ClassName Win32_NetworkAdapterConfiguration -Filter 'IPEnabled=True' -ErrorAction Stop
        foreach ($c in $cfgs) {
            $net += @{
                description = [string]$c.Description
                mac         = [string]$c.MACAddress
                ip          = @($c.IPAddress)
                gateway     = @($c.DefaultIPGateway)
                dns         = @($c.DNSServerSearchOrder)
                dhcp        = [bool]$c.DHCPEnabled
            }
        }
    } catch { }
    Set-Fact 'network_adapters' $net
}

Invoke-Check -Id 'inv.autoruns' -Category 'inventory' -Title 'Autoruns (Run keys) and scheduled tasks' -Body {
    # Run keys (HKLM + HKCU + WOW6432Node)
    $runEntries = @()
    $runKeys = @(
        'HKLM:\Software\Microsoft\Windows\CurrentVersion\Run',
        'HKLM:\Software\WOW6432Node\Microsoft\Windows\CurrentVersion\Run',
        'HKCU:\Software\Microsoft\Windows\CurrentVersion\Run',
        'HKLM:\Software\Microsoft\Windows\CurrentVersion\RunOnce'
    )
    foreach ($rk in $runKeys) {
        try {
            if (Test-Path $rk) {
                $props = Get-ItemProperty -Path $rk -ErrorAction SilentlyContinue
                foreach ($prop in $props.PSObject.Properties) {
                    if ($prop.Name -notmatch '^PS') {
                        $runEntries += @{ key=$rk; name=$prop.Name; value=[string]$prop.Value }
                    }
                }
            }
        } catch { }
    }
    Set-Fact 'autoruns_run_keys' $runEntries

    # Scheduled tasks (non-Microsoft, enabled) snapshot
    $taskList = @()
    try {
        $tasks = Get-ScheduledTask -ErrorAction Stop |
            Where-Object { $_.State -ne 'Disabled' -and $_.TaskPath -notmatch '^\\Microsoft\\' }
        foreach ($t in $tasks) {
            $taskList += @{ name=[string]$t.TaskName; path=[string]$t.TaskPath; state=[string]$t.State }
        }
    } catch { }
    Set-Fact 'scheduled_tasks' $taskList
    Set-Fact 'scheduled_tasks_count' @($taskList).Count
}

# ---------------------------------------------------------------------------
# Assemble and emit
# ---------------------------------------------------------------------------
$report = [ordered]@{
    host             = $HostName
    collected_at_utc = $CollectedAtUtc
    os               = $OsInfo
    facts            = $Facts
    findings         = @($Findings)
}

$json = ''
try {
    $json = $report | ConvertTo-Json -Depth 12 -Compress
} catch {
    # Last-ditch fallback: emit a minimal valid object so the runner never gets garbage.
    $errMsg = ''
    try { $errMsg = $_.Exception.Message } catch { }
    $fallback = [ordered]@{
        host             = $HostName
        collected_at_utc = $CollectedAtUtc
        os               = $OsInfo
        facts            = @{}
        findings         = @(
            [ordered]@{ id='probe.serialize_error'; category='probe'; severity='unknown';
                        title='JSON serialization failed';
                        detail='The probe ran but the result could not be serialized to JSON. Re-run.';
                        evidence=$errMsg }
        )
    }
    $json = $fallback | ConvertTo-Json -Depth 6 -Compress
}

# Strip any non-ASCII that may have crept in (defensive; replace with '?').
$sb = New-Object System.Text.StringBuilder
foreach ($ch in $json.ToCharArray()) {
    if ([int]$ch -ge 32 -and [int]$ch -le 126) { [void]$sb.Append($ch) }
    elseif ([int]$ch -eq 9 -or [int]$ch -eq 10 -or [int]$ch -eq 13) { [void]$sb.Append($ch) }
    else { [void]$sb.Append('?') }
}
$json = $sb.ToString()

Write-Output '===DIAG-JSON-START==='
Write-Output $json
Write-Output '===DIAG-JSON-END==='
