#
# GuruScan.psm1
# Multi-engine malware scan orchestrator for GuruRMM.
# PowerShell 5.1 compatible -- no ternary, no ??, no ?. operators.
#

Set-StrictMode -Version Latest
$ErrorActionPreference = 'Continue'
$ProgressPreference    = 'SilentlyContinue'

# ---------------------------------------------------------------------------
# Module-level constants and scanner definition loading
# ---------------------------------------------------------------------------

$script:ModuleRoot  = $PSScriptRoot
$script:Base        = 'C:\GuruScan'
$script:LogRoot     = 'C:\ScanLogs'

$script:ScannersJson = Join-Path $script:ModuleRoot 'scanners.json'
$script:ScannerDefs  = if (Test-Path $script:ScannersJson) {
    (Get-Content $script:ScannersJson -Raw | ConvertFrom-Json).scanners
} else {
    @()
}

# ---------------------------------------------------------------------------
# Private helpers
# ---------------------------------------------------------------------------

function Expand-TokenizedArgs {
    <#
    .SYNOPSIS
        Replaces {LOG_ROOT} and {EXE_DIR} tokens in a list of argument strings.
    #>
    param(
        [string[]]$ArgList,
        [string]$LogRoot,
        [string]$ExeDir
    )
    $out = foreach ($a in $ArgList) {
        $a = $a -replace '\{LOG_ROOT\}', $LogRoot
        $a = $a -replace '\{EXE_DIR\}',  $ExeDir
        $a
    }
    return $out
}

function Expand-EnvPath {
    <#
    .SYNOPSIS
        Expands environment variable references in a path string.
    #>
    param([string]$Path)
    return [System.Environment]::ExpandEnvironmentVariables($Path)
}

function Remove-PathSilent {
    <#
    .SYNOPSIS
        Removes a file or directory silently if it exists.
    #>
    param([string]$Path)
    $expanded = Expand-EnvPath $Path
    if (Test-Path $expanded) {
        Remove-Item -Path $expanded -Recurse -Force -ErrorAction SilentlyContinue
    }
}

function Wait-ProcessWithTimeout {
    <#
    .SYNOPSIS
        Waits for a process to exit within a deadline; kills if it exceeds timeout.
    .OUTPUTS
        $true if the process exited cleanly within the timeout, $false if it was killed.
    .NOTES
        Calls WaitForExit(5000) before returning to flush the exit code -- required
        before the ExitCode property is reliably readable on fast-exit processes.
    #>
    param(
        [System.Diagnostics.Process]$Process,
        [int]$TimeoutSeconds
    )
    $deadline = (Get-Date).AddSeconds($TimeoutSeconds)
    while (-not $Process.HasExited) {
        if ((Get-Date) -gt $deadline) {
            try { $Process.Kill() } catch {}
            $Process.WaitForExit(5000) | Out-Null
            return $false
        }
        Start-Sleep -Seconds 5
    }
    # Flush exit code -- required before ExitCode property is readable
    $Process.WaitForExit(5000) | Out-Null
    return $true
}

function Wait-ServicesToStop {
    <#
    .SYNOPSIS
        Waits until all named services have stopped (or the deadline passes).
    #>
    param(
        [string[]]$ServiceNames,
        [int]$TimeoutSeconds = 120
    )
    if (-not $ServiceNames -or $ServiceNames.Count -eq 0) { return }
    $deadline = (Get-Date).AddSeconds($TimeoutSeconds)
    foreach ($svc in $ServiceNames) {
        while ((Get-Date) -lt $deadline) {
            $s = Get-Service -Name $svc -ErrorAction SilentlyContinue
            if (-not $s -or $s.Status -ne 'Running') { break }
            Start-Sleep -Seconds 3
        }
    }
}

function Wait-ScannerCompletion {
    <#
    .SYNOPSIS
        Waits for a scanner process to complete, then waits for any named child
        process and scanner services to stop.
    .OUTPUTS
        $true if the main process exited cleanly, $false if it timed out.
    #>
    param(
        [System.Diagnostics.Process]$Process,
        [string]$WaitOnProcess,
        [string[]]$ServiceNames,
        [int]$TimeoutSeconds
    )

    # Wait for the main process
    $completed = Wait-ProcessWithTimeout -Process $Process -TimeoutSeconds $TimeoutSeconds
    if (-not $completed) { return $false }

    # Wait for named child process if specified
    if ($WaitOnProcess) {
        $deadline = (Get-Date).AddSeconds(60)
        while ((Get-Date) -lt $deadline) {
            $child = Get-Process -Name $WaitOnProcess -ErrorAction SilentlyContinue
            if (-not $child) { break }
            Start-Sleep -Seconds 3
        }
    }

    # Wait for services to stop
    if ($ServiceNames -and $ServiceNames.Count -gt 0) {
        Wait-ServicesToStop -ServiceNames $ServiceNames -TimeoutSeconds 120
    }

    return $true
}

function Invoke-HitmanProTrialReset {
    <#
    .SYNOPSIS
        Wipes and recreates HitmanPro registry keys to reset the trial window.
    #>
    Remove-Item -Path 'HKLM:\SOFTWARE\HitmanPro'       -Recurse -Force -ErrorAction SilentlyContinue
    Remove-Item -Path 'HKLM:\SOFTWARE\HitmanPro.Alert' -Recurse -Force -ErrorAction SilentlyContinue
    New-Item -Path 'HKLM:\SOFTWARE\HitmanPro'       -Force | Out-Null
    New-Item -Path 'HKLM:\SOFTWARE\HitmanPro.Alert' -Force | Out-Null
}

function Get-ExitCodeThreats {
    <#
    .SYNOPSIS
        Maps a scanner exit code to a threat indicator (0 = clean, 1 = threats found).
    .NOTES
        Exit code semantics per scanner:
          AdwCleaner : 1=cleaned(no reboot), 2=cleaned(reboot needed), 3=found/not cleaned
          HitmanPro  : 1=cleaned, 2=cleaned(reboot needed)
          Emsisoft   : >=1 = threats found or cleaned
          MSERT      : non-zero = threats
          TDSSKiller  : 1=threats
          Stinger    : 13=threats
          RKill      : exit 1 = processes killed (NOT a threat indicator)
    #>
    param([string]$ScannerName, [int]$ExitCode)
    switch ($ScannerName) {
        'AdwCleaner' { if ($ExitCode -in @(1, 2, 3)) { return 1 } }
        'HitmanPro'  { if ($ExitCode -in @(1, 2))    { return 1 } }
        'Emsisoft'   { if ($ExitCode -ge 1)           { return 1 } }
        'MSERT'      { if ($ExitCode -ne 0)           { return 1 } }
        'TDSSKiller' { if ($ExitCode -eq 1)           { return 1 } }
        'Stinger'    { if ($ExitCode -eq 13)          { return 1 } }
        # RKill: exit 1 = processes were killed -- not a threat count
    }
    return 0
}

function Get-ExitCodeReboot {
    <#
    .SYNOPSIS
        Returns $true if the scanner exit code signals a reboot is required.
    #>
    param([string]$ScannerName, [int]$ExitCode)
    switch ($ScannerName) {
        'AdwCleaner' { if ($ExitCode -eq 2) { return $true } }
        'HitmanPro'  { if ($ExitCode -eq 2) { return $true } }
        'Emsisoft'   { if ($ExitCode -eq 2) { return $true } }
    }
    return $false
}

function Invoke-ForceRemoveList {
    <#
    .SYNOPSIS
        Force-removes a list of blacklisted items (paths, registry keys/values,
        services, scheduled tasks, and processes).
    .PARAMETER RemoveList
        Array of hashtables with Type and Value (and Key for RegValue entries).
    #>
    param([array]$RemoveList)
    if (-not $RemoveList -or $RemoveList.Count -eq 0) { return }
    Write-Host ''
    Write-Host '------------------------------------------------------------' -ForegroundColor DarkGray
    Write-Host '  Force-Remove Blacklist' -ForegroundColor Cyan
    foreach ($entry in $RemoveList) {
        $type  = $entry.Type
        $value = $entry.Value
        try {
            switch ($type) {
                'Path' {
                    $expanded = [System.Environment]::ExpandEnvironmentVariables($value)
                    if (Test-Path $expanded) {
                        Remove-Item -Path $expanded -Recurse -Force -ErrorAction Stop
                        Write-Host "  [OK] Removed path: $expanded" -ForegroundColor Green
                    } else {
                        Write-Host "  [SKIP] Path not found: $expanded" -ForegroundColor Gray
                    }
                }
                'Registry' {
                    if (Test-Path $value) {
                        Remove-Item -Path $value -Recurse -Force -ErrorAction Stop
                        Write-Host "  [OK] Removed registry key: $value" -ForegroundColor Green
                    } else {
                        Write-Host "  [SKIP] Registry key not found: $value" -ForegroundColor Gray
                    }
                }
                'RegValue' {
                    if (Test-Path $entry.Key) {
                        Remove-ItemProperty -Path $entry.Key -Name $value -Force -ErrorAction Stop
                        Write-Host "  [OK] Removed registry value: $($entry.Key)\$value" -ForegroundColor Green
                    } else {
                        Write-Host "  [SKIP] Registry key not found: $($entry.Key)" -ForegroundColor Gray
                    }
                }
                'Service' {
                    $svc = Get-Service -Name $value -ErrorAction SilentlyContinue
                    if ($svc) {
                        Stop-Service -Name $value -Force -ErrorAction SilentlyContinue
                        Start-Sleep -Seconds 2
                        & sc.exe delete $value | Out-Null
                        Write-Host "  [OK] Removed service: $value" -ForegroundColor Green
                    } else {
                        Write-Host "  [SKIP] Service not found: $value" -ForegroundColor Gray
                    }
                }
                'Task' {
                    $task = Get-ScheduledTask -TaskName $value -ErrorAction SilentlyContinue
                    if ($task) {
                        Unregister-ScheduledTask -TaskName $value -Confirm:$false -ErrorAction Stop
                        Write-Host "  [OK] Removed scheduled task: $value" -ForegroundColor Green
                    } else {
                        Write-Host "  [SKIP] Scheduled task not found: $value" -ForegroundColor Gray
                    }
                }
                'Process' {
                    $procs = Get-Process -Name $value -ErrorAction SilentlyContinue
                    if ($procs) {
                        $procs | Stop-Process -Force -ErrorAction SilentlyContinue
                        Write-Host "  [OK] Killed process: $value ($($procs.Count) instance(s))" -ForegroundColor Green
                    } else {
                        Write-Host "  [SKIP] Process not running: $value" -ForegroundColor Gray
                    }
                }
                default {
                    Write-Host "  [WARNING] Unknown ForceRemove type '$type' for: $value" -ForegroundColor Yellow
                }
            }
        }
        catch {
            Write-Host "  [ERROR] ForceRemove failed ($type): $value - $_" -ForegroundColor Red
        }
    }
}

function Invoke-ScannerInstallPrep {
    <#
    .SYNOPSIS
        Runs a two-step installer + optional update, then closes any Explorer
        folder window opened by the NSIS installer.
    .PARAMETER InstallerExePath
        Full path to the installer EXE.
    .PARAMETER MainExePath
        Full path to the main scanner EXE that appears after install.
    .PARAMETER InstallerArgs
        Arguments to pass to the installer. If null or empty, the installer is
        launched without any ArgumentList parameter.
    .PARAMETER RunUpdateAfterInstall
        When $true, runs the main EXE with /update after installation completes.
    .OUTPUTS
        $true on success, $false if the installer timed out or the main EXE is missing.
    #>
    param(
        [string]$InstallerExePath,
        [string]$MainExePath,
        [string]$ScannerName = '',
        [string[]]$InstallerArgs = @('/S'),
        [bool]$RunUpdateAfterInstall = $false
    )

    Write-Host "  [INFO] Running installer: $InstallerExePath" -ForegroundColor Cyan
    try {
        $startParams = @{
            FilePath    = $InstallerExePath
            PassThru    = $true
            WindowStyle = 'Hidden'
        }
        if ($InstallerArgs -and $InstallerArgs.Count -gt 0) {
            $startParams['ArgumentList'] = $InstallerArgs
        }
        $instProc = Start-Process @startParams
        $instCompleted = Wait-ProcessWithTimeout -Process $instProc -TimeoutSeconds 300

        # Close any Explorer folder window the NSIS installer opened
        try {
            (New-Object -ComObject Shell.Application).Windows() |
                Where-Object { $_.LocationURL -like '*EmsisoftCmd*' -or $_.LocationName -like '*EmsisoftCmd*' } |
                ForEach-Object { $_.Quit() }
        } catch {}

        if (-not $instCompleted) {
            Write-Host '  [WARNING] Installer timed out after 5 min - skipping scanner' -ForegroundColor Yellow
            return $false
        }
    }
    catch {
        Write-Host "  [ERROR] Installer failed: $_" -ForegroundColor Red
        return $false
    }

    if (-not (Test-Path $MainExePath)) {
        Write-Host "  [ERROR] Main EXE not found after install: $MainExePath" -ForegroundColor Red
        return $false
    }

    if ($RunUpdateAfterInstall -eq $true) {
        Write-Host '  [INFO] Updating scanner definitions...' -ForegroundColor Cyan
        try {
            $updateProc = Start-Process -FilePath $MainExePath -ArgumentList '/update' -PassThru -WindowStyle Hidden
            Wait-ProcessWithTimeout -Process $updateProc -TimeoutSeconds 300 | Out-Null
        }
        catch {
            Write-Host "  [WARNING] Update step failed: $_ - continuing with existing definitions" -ForegroundColor Yellow
        }
    }

    return $true
}

function Invoke-ScanPass {
    <#
    .SYNOPSIS
        Runs one complete pass of the scanner loop.
    .DESCRIPTION
        Iterates over the provided scanner list in the given mode, launches each
        scanner, waits for completion, collects logs, and accumulates results.
        Sets $script:LastPassRebootRequired and $script:LastPassTotalThreats.
    .PARAMETER ScannerList
        The filtered list of scanner definition objects to run.
    .PARAMETER ScanMode
        'scan' for detect-only, 'clean' for remediation.
    .PARAMETER RunLogRoot
        The per-scan log directory for this pass.
    .PARAMETER TimeoutMinOverride
        If > 0, overrides the per-scanner timeout_min for all scanners.
    .PARAMETER Headless
        When set, launches scanner processes with WindowStyle=Hidden so no UI
        windows appear. Use when dispatching from an RMM agent with no desktop.
    .OUTPUTS
        [System.Collections.Generic.List[pscustomobject]] of result objects.
    #>
    param(
        [object[]]$ScannerList,
        [string]$ScanMode,
        [string]$RunLogRoot,
        [int]$TimeoutMinOverride = 0,
        [switch]$Headless
    )

    $script:LastPassRebootRequired = $false
    $script:LastPassTotalThreats   = 0

    $results = [System.Collections.Generic.List[pscustomobject]]::new()

    foreach ($s in $ScannerList) {
        Write-Host '------------------------------------------------------------' -ForegroundColor DarkGray
        Write-Host "  Scanner : $($s.Name)" -ForegroundColor Cyan
        Write-Host "  Mode    : $ScanMode"

        # Normalize property names -- JSON objects use snake_case, PS hashtables use PascalCase
        $sName                  = if ($s.PSObject.Properties['Name'])          { $s.Name }          else { $s.name }
        $sExe                   = if ($s.PSObject.Properties['Exe'])           { $s.Exe }           else { $s.exe }
        $sInstallerExe          = if ($s.PSObject.Properties['InstallerExe'])  { $s.InstallerExe }  else { $s.installer_exe }
        $sScanArgs              = if ($s.PSObject.Properties['ScanArgs'])      { $s.ScanArgs }      else { $s.scan_args }
        $sCleanArgs             = if ($s.PSObject.Properties['CleanArgs'])     { $s.CleanArgs }     else { $s.clean_args }
        $sLogSrc                = if ($s.PSObject.Properties['LogSrc'])        { $s.LogSrc }        else { $s.log_src }
        $sTimeoutMin            = if ($s.PSObject.Properties['TimeoutMin'])    { $s.TimeoutMin }    else { $s.timeout_min }
        $sRandomizeExe          = if ($s.PSObject.Properties['RandomizeExe'])  { $s.RandomizeExe }  else { $s.randomize_exe }
        $sPreClose              = if ($s.PSObject.Properties['PreCloseProcesses']) { $s.PreCloseProcesses } else { $s.pre_close_processes }
        $sPreClean              = if ($s.PSObject.Properties['PreCleanPaths']) { $s.PreCleanPaths } else { $s.pre_clean_paths }
        $sPostClean             = if ($s.PSObject.Properties['PostCleanPaths']) { $s.PostCleanPaths } else { $s.post_clean_paths }
        $sServiceNames          = if ($s.PSObject.Properties['ServiceNames'])  { $s.ServiceNames }  else { $s.service_names }
        $sHmpReset              = if ($s.PSObject.Properties['HitmanProReset']) { $s.HitmanProReset } else { $s.hitmanpro_trial_reset }
        $sWaitOnProcess         = if ($s.PSObject.Properties['WaitOnProcess']) { $s.WaitOnProcess } else { $s.wait_on_process }
        $sInstallerArgs         = if ($s.PSObject.Properties['InstallerArgs'])          { $s.InstallerArgs }          else { $s.installer_args }
        $sRunUpdateAfterInstall = if ($s.PSObject.Properties['RunUpdateAfterInstall'])  { $s.RunUpdateAfterInstall }  else { $s.run_update_after_install }
        $sSession0Compatible    = if ($s.PSObject.Properties['Session0Compatible'])    { $s.Session0Compatible }    else { $s.session0_compatible }
        if ($null -eq $sInstallerArgs)          { $sInstallerArgs = @('/S') }
        if ($null -eq $sRunUpdateAfterInstall)  { $sRunUpdateAfterInstall = $false }
        if ($null -eq $sSession0Compatible)     { $sSession0Compatible = $true }

        # Coerce null JSON values to empty arrays / nulls
        if ($null -eq $sInstallerExe) { $sInstallerExe = $null }
        if ($null -eq $sLogSrc)       { $sLogSrc = $null }
        if ($null -eq $sWaitOnProcess) { $sWaitOnProcess = $null }
        if ($null -eq $sPreClose)     { $sPreClose = @() }
        if ($null -eq $sPreClean)     { $sPreClean = @() }
        if ($null -eq $sPostClean)    { $sPostClean = @() }
        if ($null -eq $sServiceNames) { $sServiceNames = @() }

        # Skip GUI-only scanners when running as SYSTEM in Session 0 (no interactive desktop)
        if (-not $sSession0Compatible -and (Test-RunningAsSystem)) {
            Write-Host "  [WARNING] $sName requires an interactive user session -- skipping (running as SYSTEM)" -ForegroundColor Yellow
            $results.Add([pscustomobject]@{
                Scanner      = $sName
                Status       = 'SKIPPED (requires user session)'
                ExitCode     = $null
                ThreatsFound = 0
                Duration     = '0 min'
                LogPath      = ''
            })
            continue
        }

        $mainExePath      = $sExe
        $installerExePath = $sInstallerExe
        $exeToCheck       = if ($installerExePath) { $installerExePath } else { $mainExePath }

        if (-not (Test-Path $exeToCheck)) {
            Write-Host "  [WARNING] EXE not found - skipping: $exeToCheck" -ForegroundColor Yellow
            $results.Add([pscustomobject]@{
                Scanner      = $sName
                Status       = 'SKIPPED (missing)'
                ExitCode     = $null
                ThreatsFound = 0
                Duration     = '0 min'
                LogPath      = ''
            })
            continue
        }

        $exeDir = Split-Path $mainExePath -Parent

        if ($ScanMode -eq 'scan') {
            $rawArgs = @($sScanArgs)
        } else {
            $rawArgs = @($sCleanArgs)
        }

        $expandedArgs = Expand-TokenizedArgs -ArgList $rawArgs -LogRoot $RunLogRoot -ExeDir $exeDir

        # HitmanPro trial registry pre-seed
        if ($sHmpReset -eq $true) {
            Write-Host '  [INFO] Resetting HitmanPro trial registry...' -ForegroundColor Cyan
            Invoke-HitmanProTrialReset
        }

        # Pre-clean paths
        foreach ($p in $sPreClean) {
            Remove-PathSilent $p
        }

        # Effective timeout
        $effectiveTimeoutMin = if ($TimeoutMinOverride -gt 0) { $TimeoutMinOverride } else { $sTimeoutMin }
        $timeoutSec          = $effectiveTimeoutMin * 60

        # Two-step install (scanners with an installer_exe defined)
        if ($installerExePath) {
            $installOk = Invoke-ScannerInstallPrep -InstallerExePath $installerExePath -MainExePath $mainExePath -ScannerName $sName -InstallerArgs $sInstallerArgs -RunUpdateAfterInstall $sRunUpdateAfterInstall
            if (-not $installOk) {
                $results.Add([pscustomobject]@{
                    Scanner      = $sName
                    Status       = 'FAILED (installer)'
                    ExitCode     = $null
                    ThreatsFound = 0
                    Duration     = '0 min'
                    LogPath      = ''
                })
                continue
            }
        }

        # Randomize EXE (TDSSKiller pattern)
        $randomizedExePath = $null
        $launchExe         = $mainExePath

        if ($sRandomizeExe -eq $true) {
            $tempName          = [System.IO.Path]::GetRandomFileName() -replace '\..*$', ''
            $randomizedExePath = Join-Path $env:TEMP "$tempName.exe"
            Copy-Item -Path $mainExePath -Destination $randomizedExePath -Force
            $launchExe = $randomizedExePath
            Write-Host "  [INFO] EXE randomized to: $randomizedExePath" -ForegroundColor Cyan
        }

        # Kill processes that would block this scanner
        if ($sPreClose -and $sPreClose.Count -gt 0) {
            foreach ($proc in $sPreClose) {
                Get-Process -Name $proc -ErrorAction SilentlyContinue | Stop-Process -Force -ErrorAction SilentlyContinue
            }
            Start-Sleep -Seconds 2
        }

        # Launch scanner
        Write-Host "  [....] Launching $sName..." -ForegroundColor Cyan
        $scanStart = Get-Date
        $proc      = $null
        $status    = 'completed'
        $exitCode  = $null

        try {
            $startParams = @{
                FilePath = $launchExe
                PassThru = $true
            }
            # Use WindowStyle=Hidden (not NoNewWindow) so scanner processes get
            # their own window/console and do NOT inherit the PowerShell pipe
            # handles. With NoNewWindow the child shares the parent console and
            # inherits its stdout/stderr pipes; if a scanner hangs the pipe stays
            # open after PowerShell exits, blocking the GuruRMM agent for hours.
            if ($Headless) {
                $startParams['WindowStyle'] = 'Hidden'
            }
            if ($expandedArgs -and $expandedArgs.Count -gt 0) {
                $startParams['ArgumentList'] = $expandedArgs
            }

            $proc = Start-Process @startParams
            $null = $proc.Handle  # force handle caching -- Start-Process -PassThru can lose the handle before exit, making ExitCode return null

            $serviceArr = @()
            if ($sServiceNames -and $sServiceNames.Count -gt 0) {
                $serviceArr = @($sServiceNames)
            }

            $wpStr = $null
            if ($sWaitOnProcess) { $wpStr = [string]$sWaitOnProcess }

            $completed = Wait-ScannerCompletion -Process $proc -WaitOnProcess $wpStr -ServiceNames $serviceArr -TimeoutSeconds $timeoutSec

            if (-not $completed) {
                $status = 'TIMED OUT'
                Write-Host "  [WARNING] $sName timed out after $effectiveTimeoutMin min" -ForegroundColor Yellow
            } else {
                $proc.WaitForExit() | Out-Null  # no-arg form guarantees ExitCode is readable
                $exitCode = $proc.ExitCode
            }
        }
        catch {
            $status = 'FAILED'
            Write-Host "  [ERROR] $sName failed to launch: $_" -ForegroundColor Red
        }

        # Calculate duration
        $scanEnd = Get-Date
        $durMin  = [math]::Round(($scanEnd - $scanStart).TotalMinutes, 2)

        # Collect logs from scanner-specific log source
        $logDestDir = Join-Path $RunLogRoot "$($sName)_Logs"

        if ($sLogSrc) {
            $logSrcExpanded = @(Expand-TokenizedArgs -ArgList @($sLogSrc) -LogRoot $RunLogRoot -ExeDir $exeDir)
            if ($logSrcExpanded.Count -gt 0 -and $logSrcExpanded[0]) {
                $logSrcPath = Expand-EnvPath $logSrcExpanded[0]
                if (Test-Path $logSrcPath) {
                    New-Item -ItemType Directory -Path $logDestDir -Force | Out-Null
                    Copy-Item -Path $logSrcPath -Destination $logDestDir -Recurse -Force -ErrorAction SilentlyContinue
                    Write-Host "  [INFO] Logs copied to: $logDestDir" -ForegroundColor Cyan
                } else {
                    Write-Host "  [WARNING] Log source not found: $logSrcPath" -ForegroundColor Yellow
                }
            }
        }

        # Post-clean paths
        foreach ($p in $sPostClean) {
            Remove-PathSilent $p
        }

        # Clean up randomized EXE copy
        if ($randomizedExePath -and (Test-Path $randomizedExePath)) {
            Remove-Item $randomizedExePath -Force -ErrorAction SilentlyContinue
        }

        # Threat and reboot detection from exit code
        $threatsFound = 0
        if ($null -ne $exitCode) {
            $threatsFound = Get-ExitCodeThreats -ScannerName $sName -ExitCode $exitCode
            if (Get-ExitCodeReboot -ScannerName $sName -ExitCode $exitCode) {
                $script:LastPassRebootRequired = $true
                Write-Host "  [WARNING] $sName signals REBOOT REQUIRED to complete removal" -ForegroundColor Yellow
            }
        }
        $script:LastPassTotalThreats += $threatsFound

        # Status reporting
        $color = if ($status -eq 'completed') { 'Green' } elseif ($status -eq 'TIMED OUT') { 'Yellow' } else { 'Red' }
        Write-Host "  [$status] ExitCode=$exitCode Threats=$threatsFound Duration=${durMin}min" -ForegroundColor $color

        $results.Add([pscustomobject]@{
            Scanner      = $sName
            Status       = $status
            ExitCode     = $exitCode
            ThreatsFound = $threatsFound
            Duration     = "$durMin min"
            LogPath      = $logDestDir
        })
    }

    return $results
}

function Register-ScannerCleanupTask {
    <#
    .SYNOPSIS
        Writes cleanup state and registers a SYSTEM scheduled task that fires at
        next user logon + 30 minutes to remove scanner files and unregister itself.
    #>
    param(
        [string]$ScanId,
        [string]$LogRoot
    )

    Write-Host ''
    Write-Host '------------------------------------------------------------' -ForegroundColor Yellow
    Write-Host '  Reboot recommended -- registering post-reboot cleanup task' -ForegroundColor Yellow
    Write-Host '------------------------------------------------------------' -ForegroundColor Yellow

    # Write state for the cleanup script to read
    @{
        scan_id    = $ScanId
        log_root   = $LogRoot
        created_at = (Get-Date).ToUniversalTime().ToString('o')
    } | ConvertTo-Json | Set-Content "$script:Base\cleanup-state.json" -Encoding UTF8

    # Copy the static cleanup script from the module directory to C:\GuruScan\
    $cleanupSrc = Join-Path $script:ModuleRoot 'Invoke-ScannerCleanup.ps1'
    if (Test-Path $cleanupSrc) {
        Copy-Item -Path $cleanupSrc -Destination "$script:Base\Invoke-ScannerCleanup.ps1" -Force
    } else {
        Write-Host "  [WARNING] Invoke-ScannerCleanup.ps1 not found at $cleanupSrc -- cleanup task will not run." -ForegroundColor Yellow
        return
    }

    # Register as SYSTEM logon task with 30-minute delay
    try {
        $action    = New-ScheduledTaskAction -Execute 'powershell.exe' `
                         -Argument "-NoProfile -ExecutionPolicy Bypass -File `"$script:Base\Invoke-ScannerCleanup.ps1`""
        $trigger   = New-ScheduledTaskTrigger -AtLogOn
        $trigger.Delay = 'PT30M'
        $principal = New-ScheduledTaskPrincipal -UserId 'SYSTEM' -RunLevel Highest
        $settings  = New-ScheduledTaskSettingsSet `
                         -ExecutionTimeLimit (New-TimeSpan -Minutes 10) `
                         -MultipleInstances IgnoreNew `
                         -DeleteExpiredTaskAfter (New-TimeSpan -Seconds 0)
        Register-ScheduledTask -TaskName 'GuruRMM-ScannerCleanup' `
            -Action $action -Trigger $trigger -Principal $principal -Settings $settings -Force | Out-Null
        Write-Host '  [OK] Scanner cleanup task registered (runs 30 min after next logon)' -ForegroundColor Green
    }
    catch {
        Write-Host "  [WARNING] Could not register cleanup task: $_" -ForegroundColor Yellow
    }

    Write-Host ''
    Write-Host '  [INFO] Please reboot this machine at your convenience to complete removal.' -ForegroundColor Yellow
    Write-Host '         Scanner files will be cleaned up automatically 30 minutes after login.' -ForegroundColor Yellow
    Write-Host ''
}

function Test-RunningAsSystem {
    $id = [System.Security.Principal.WindowsIdentity]::GetCurrent()
    return ($id.IsSystem)
}

# ---------------------------------------------------------------------------
# Private Ollama helper (used by Get-ScanSummary)
# ---------------------------------------------------------------------------

function Invoke-Ollama {
    param(
        [string]$Prompt,
        [string]$Model,
        [string]$BaseUrl
    )
    $body = @{ model = $Model; prompt = $Prompt; stream = $false } | ConvertTo-Json -Depth 3
    try {
        $r = Invoke-RestMethod -Uri "$BaseUrl/api/generate" -Method POST `
             -Body $body -ContentType 'application/json' -TimeoutSec 180
        return ($r.response -replace '</?think[^>]*>', '' -replace '(?s)<think>.*?</think>', '').Trim()
    } catch {
        return $null
    }
}

# ---------------------------------------------------------------------------
# Exported functions
# ---------------------------------------------------------------------------

function Invoke-GuruScan {
    <#
    .SYNOPSIS
        GuruScan - multi-engine malware scanning orchestrator.
    .DESCRIPTION
        Runs a suite of portable malware scanners in sequence, captures logs,
        and writes a structured results.json plus a zip archive of all logs.
        Scanner definitions are loaded from scanners.json in the module directory.

        By default runs all scanners in clean (remediation) mode.
        Use -ScanOnly to detect without cleaning.

        When a scanner signals a reboot is required, GuruScan registers a
        SYSTEM scheduled task (GuruRMM-ScannerCleanup) that fires 30 minutes
        after the next user logon to remove scanner files and flag logs for pickup.

        NOTE: MSERT is not included in the default scanner list because it takes
        too long for routine runs.
    .PARAMETER ScanOnly
        Use scan args (detect only) instead of clean args for every scanner.
    .PARAMETER AutoRemediate
        After a scan-only pass, if threats are found, automatically re-run all
        scanners in clean mode.
    .PARAMETER Scanners
        Run only the named scanners (comma-separated or multiple values).
        Names must match the Name field in scanners.json exactly.
    .PARAMETER TimeoutMin
        Override the per-scanner timeout (in minutes) for all scanners.
    .PARAMETER SkipScanners
        Skip one or more named scanners by name.
    .PARAMETER Headless
        Suppress scanner windows (used when dispatching via RMM).
    .PARAMETER OutputSink
        'Disk' (default) writes results.json, CSV, and zip to C:\ScanLogs and
        C:\GuruScan\reports. 'RMM' returns the result object to the pipeline
        instead and skips writing to disk.
    .EXAMPLE
        Invoke-GuruScan
        Invoke-GuruScan -ScanOnly -AutoRemediate
        Invoke-GuruScan -OutputSink RMM
    #>
    [CmdletBinding()]
    param(
        [switch]$ScanOnly,
        [switch]$AutoRemediate,
        [string[]]$Scanners,
        [int]$TimeoutMin = 0,
        [string[]]$SkipScanners = @(),
        [switch]$Headless,
        [ValidateSet('Disk', 'RMM')]
        [string]$OutputSink = 'Disk'
    )

    # Whitelist -- written to C:\GuruScan\whitelist.txt before any scanner runs.
    # Emsisoft (/wl=) and HitmanPro (/excludelist=) honour this; RKill does not.
    $whitelist = @('C:\GuruScan')

    # ForceRemove blacklist -- items removed after all scanners complete.
    $forceRemove = @()

    $scanStamp   = "$env:COMPUTERNAME-$(Get-Date -Format yyyyMMdd-HHmmss)"
    $runLogRoot  = "$script:LogRoot\$scanStamp"
    $scanMode    = if ($ScanOnly) { 'scan' } else { 'clean' }

    $runningAsSystem = Test-RunningAsSystem

    # Create required directories
    New-Item -ItemType Directory -Path $script:Base           -Force | Out-Null
    New-Item -ItemType Directory -Path "$script:Base\downloads" -Force | Out-Null
    New-Item -ItemType Directory -Path $runLogRoot             -Force | Out-Null
    New-Item -ItemType Directory -Path "$script:Base\reports"  -Force | Out-Null

    # Write whitelist file
    $whitelist | Set-Content -Path "$script:Base\whitelist.txt" -Encoding UTF8
    Write-Host "[INFO] Whitelist written to $script:Base\whitelist.txt ($($whitelist.Count) entries)" -ForegroundColor Cyan

    Write-Host ''
    Write-Host '=== GuruScan ===' -ForegroundColor Cyan
    Write-Host "  Machine   : $env:COMPUTERNAME"
    Write-Host "  Scan ID   : $scanStamp"
    Write-Host "  Log root  : $runLogRoot"
    Write-Host "  Mode      : $scanMode"
    Write-Host "  As SYSTEM : $runningAsSystem"
    Write-Host ''

    # Load scanner defs from module (already loaded at module scope, but refresh
    # the reference so callers who import the module later get current data)
    $allDefs = $script:ScannerDefs

    # Filter scanners
    $scannerList = @($allDefs)

    if ($SkipScanners -and $SkipScanners.Count -gt 0) {
        $scannerList = @($scannerList | Where-Object {
            $n = if ($_.PSObject.Properties['Name']) { $_.Name } else { $_.name }
            $SkipScanners -notcontains $n
        })
    }

    if ($Scanners -and $Scanners.Count -gt 0) {
        $scannerList = @($scannerList | Where-Object {
            $n = if ($_.PSObject.Properties['Name']) { $_.Name } else { $_.name }
            $Scanners -contains $n
        })
        if ($scannerList.Count -eq 0) {
            Write-Host "[ERROR] No scanners matched the provided names: $($Scanners -join ', ')" -ForegroundColor Red
            if ($OutputSink -eq 'RMM') {
                return $null
            }
            exit 1
        }
    }

    # Pre-flight: warn about missing scanner EXEs so missing scanners are
    # visible up front rather than silently skipped mid-run.
    $missingExes = @()
    foreach ($s in $scannerList) {
        $sName    = if ($s.PSObject.Properties['Name']) { $s.Name } else { $s.name }
        $sExe     = if ($s.PSObject.Properties['Exe'])  { $s.Exe  } else { $s.exe  }
        $sInstExe = if ($s.PSObject.Properties['InstallerExe']) { $s.InstallerExe } else { $s.installer_exe }
        if (-not (Test-Path $sExe) -and (-not $sInstExe -or -not (Test-Path $sInstExe))) {
            $missingExes += $sName
        }
    }
    if ($missingExes.Count -gt 0) {
        Write-Host "[WARNING] Missing scanner EXEs -- these will be skipped: $($missingExes -join ', ')" -ForegroundColor Yellow
        Write-Host "          Run .\Download-Scanners.ps1 to download them." -ForegroundColor Yellow
        Write-Host ''
    }

    # Add Windows Defender exclusions for scanner paths so Defender does not
    # quarantine scanner EXEs or log files mid-run.
    $defenderExclusions = @($script:Base, $script:LogRoot, 'C:\EmsisoftCmd')
    try {
        Add-MpPreference -ExclusionPath $defenderExclusions -ErrorAction SilentlyContinue
        Write-Host "[INFO] Windows Defender exclusions added for scanner paths" -ForegroundColor Cyan
    } catch {}

    # First pass
    $startedAt  = Get-Date
    $passParams = @{
        ScannerList        = $scannerList
        ScanMode           = $scanMode
        RunLogRoot         = $runLogRoot
        TimeoutMinOverride = $TimeoutMin
        Headless           = $Headless
    }
    $results        = Invoke-ScanPass @passParams
    $totalThreats   = $script:LastPassTotalThreats
    $rebootRequired = $script:LastPassRebootRequired

    # AutoRemediate: re-run in clean mode if scan-only found threats
    if ($AutoRemediate -and $ScanOnly -and $totalThreats -gt 0) {
        Write-Host ''
        Write-Host '  [INFO] -AutoRemediate: threats found - re-running all scanners in clean mode...' -ForegroundColor Cyan
        Write-Host ''

        # Rebuild scanner list for the clean pass (same filters honored)
        $remList = @($allDefs)
        if ($SkipScanners -and $SkipScanners.Count -gt 0) {
            $remList = @($remList | Where-Object {
                $n = if ($_.PSObject.Properties['Name']) { $_.Name } else { $_.name }
                $SkipScanners -notcontains $n
            })
        }
        if ($Scanners -and $Scanners.Count -gt 0) {
            $remList = @($remList | Where-Object {
                $n = if ($_.PSObject.Properties['Name']) { $_.Name } else { $_.name }
                $Scanners -contains $n
            })
        }

        $cleanPassParams = @{
            ScannerList        = $remList
            ScanMode           = 'clean'
            RunLogRoot         = $runLogRoot
            TimeoutMinOverride = $TimeoutMin
            Headless           = $Headless
        }
        $results        = Invoke-ScanPass @cleanPassParams
        $totalThreats   = $script:LastPassTotalThreats
        $rebootRequired = $script:LastPassRebootRequired
        $scanMode       = 'clean'
    }

    # Force-remove blacklist stage
    Invoke-ForceRemoveList -RemoveList $forceRemove

    # Build results object
    $completedAt = Get-Date

    $scannerResults = foreach ($r in $results) {
        $durValue = 0
        if ($r.Duration -match '^([\d.]+)') {
            $durValue = [double]$Matches[1]
        }
        [ordered]@{
            name          = $r.Scanner
            status        = $r.Status
            exit_code     = $r.ExitCode
            threats_found = $r.ThreatsFound
            duration_min  = $durValue
            log_path      = $r.LogPath
        }
    }

    $resultsObj = [ordered]@{
        scan_id         = $scanStamp
        machine         = $env:COMPUTERNAME
        started_at      = $startedAt.ToUniversalTime().ToString('o')
        completed_at    = $completedAt.ToUniversalTime().ToString('o')
        total_threats   = $totalThreats
        reboot_required = $rebootRequired
        scan_mode       = $scanMode
        scanners        = @($scannerResults)
    }

    # Write to disk (Disk sink)
    $resultsJsonPath = ''
    $csvPath         = ''
    $zipPath         = ''

    if ($OutputSink -eq 'Disk') {
        $resultsJsonPath = Join-Path $runLogRoot 'results.json'
        $resultsObj | ConvertTo-Json -Depth 10 | Set-Content -Path $resultsJsonPath -Encoding UTF8

        $csvPath = Join-Path $runLogRoot '_summary.csv'
        $results | Export-Csv -Path $csvPath -NoTypeInformation -Encoding UTF8

        $reportsDir = "$script:Base\reports"
        New-Item -ItemType Directory -Path $reportsDir -Force | Out-Null
        $zipPath = "$reportsDir\$scanStamp.zip"
        try {
            Compress-Archive -Path "$runLogRoot\*" -DestinationPath $zipPath -Force
            Write-Host "[INFO] Log archive: $zipPath" -ForegroundColor Cyan
        }
        catch {
            Write-Host "[WARNING] Failed to create log archive: $_" -ForegroundColor Yellow
        }
    }

    # Print summary
    Write-Host ''
    Write-Host '============================================================' -ForegroundColor Cyan
    Write-Host "  SCAN COMPLETE - $scanStamp" -ForegroundColor Cyan
    Write-Host '============================================================' -ForegroundColor Cyan
    Write-Host ''
    $results | Format-Table -AutoSize
    Write-Host ''
    Write-Host "  Total threats detected : $totalThreats"
    if ($OutputSink -eq 'Disk') {
        Write-Host "  Results JSON           : $resultsJsonPath"
        Write-Host "  Summary CSV            : $csvPath"
        Write-Host "  Log archive            : $zipPath"
    }
    Write-Host ''

    if ($totalThreats -gt 0) {
        Write-Host "  [WARNING] THREATS DETECTED - review logs in: $runLogRoot" -ForegroundColor Yellow
    }

    # Reboot handling
    if ($rebootRequired) {
        Register-ScannerCleanupTask -ScanId $scanStamp -LogRoot $runLogRoot
    }

    # Remove the Defender exclusions added at scan start.
    try {
        Remove-MpPreference -ExclusionPath $defenderExclusions -ErrorAction SilentlyContinue
        Write-Host "[INFO] Windows Defender exclusions removed" -ForegroundColor Cyan
    } catch {}

    # Return result object (RMM sink) or suppress it (Disk sink lets the
    # launcher read results.json from disk for structured reporting).
    $resultRecord = [pscustomobject]$resultsObj
    if ($OutputSink -eq 'RMM') {
        return $resultRecord
    }
}

function Invoke-Remediation {
    <#
    .SYNOPSIS
        Re-runs GuruScan scanners in clean mode against a previous scan's log folder.
    .DESCRIPTION
        Reads the results.json from a prior scan run, then re-launches each scanner
        that completed (or any subset via -Scanners) using clean args to remove
        detected threats. Writes remediation-results.json to the same log folder.
    .PARAMETER LogRoot
        Path to the scan results folder produced by Invoke-GuruScan.
        This folder must contain a results.json file.
    .PARAMETER Scanners
        Run only the named scanners. Names must match the "name" field in
        scanners.json exactly. If omitted, all scanners that previously ran
        successfully are re-run.
    .EXAMPLE
        Invoke-Remediation -LogRoot "C:\ScanLogs\DESKTOP-20260523-143000"
        Invoke-Remediation -LogRoot "C:\ScanLogs\DESKTOP-20260523-143000" -Scanners Emsisoft,MSERT
    #>
    [CmdletBinding()]
    param(
        [Parameter(Mandatory = $true)]
        [string]$LogRoot,

        [string[]]$Scanners
    )

    $priorResultsPath = Join-Path $LogRoot 'results.json'

    if (-not (Test-Path $LogRoot)) {
        Write-Host "[ERROR] LogRoot not found: $LogRoot" -ForegroundColor Red
        return
    }

    if (-not (Test-Path $priorResultsPath)) {
        Write-Host "[ERROR] results.json not found in: $LogRoot" -ForegroundColor Red
        return
    }

    if (-not (Test-Path $script:ScannersJson)) {
        Write-Host "[ERROR] scanners.json not found: $script:ScannersJson" -ForegroundColor Red
        return
    }

    $priorData = Get-Content $priorResultsPath -Raw | ConvertFrom-Json

    $priorScannerNames = $priorData.scanners |
        Where-Object { $_.status -eq 'completed' } |
        ForEach-Object { $_.name }

    $allDefs     = $script:ScannerDefs
    $scannerList = @($allDefs | Where-Object { $priorScannerNames -contains $_.name })

    if ($Scanners -and $Scanners.Count -gt 0) {
        $scannerList = @($scannerList | Where-Object { $Scanners -contains $_.name })
    }

    if ($scannerList.Count -eq 0) {
        Write-Host "[WARNING] No eligible scanners to remediate." -ForegroundColor Yellow
        return
    }

    Write-Host ""
    Write-Host "=== GuruScan Remediation ===" -ForegroundColor Cyan
    Write-Host "  Log root : $LogRoot"
    Write-Host "  Scanners : $($scannerList.name -join ', ')"
    Write-Host ""

    $startedAt  = Get-Date
    $passParams = @{
        ScannerList        = $scannerList
        ScanMode           = 'clean'
        RunLogRoot         = $LogRoot
        TimeoutMinOverride = 0
        Headless           = $false
    }
    $results      = Invoke-ScanPass @passParams
    $totalThreats = $script:LastPassTotalThreats

    # Write remediation-results.json
    $completedAt = Get-Date

    $scannerResults = foreach ($r in $results) {
        $durValue = 0
        if ($r.Duration -match '^([\d.]+)') {
            $durValue = [double]$Matches[1]
        }
        [ordered]@{
            name          = $r.Scanner
            status        = $r.Status
            exit_code     = $r.ExitCode
            threats_found = $r.ThreatsFound
            duration_min  = $durValue
            log_path      = $r.LogPath
        }
    }

    $remObj = [ordered]@{
        scan_id         = $priorData.scan_id
        machine         = $env:COMPUTERNAME
        started_at      = $startedAt.ToUniversalTime().ToString('o')
        completed_at    = $completedAt.ToUniversalTime().ToString('o')
        total_threats   = $totalThreats
        reboot_required = $script:LastPassRebootRequired
        scan_mode       = 'clean'
        scanners        = @($scannerResults)
    }

    $remResultsPath = Join-Path $LogRoot 'remediation-results.json'
    $remObj | ConvertTo-Json -Depth 10 | Set-Content -Path $remResultsPath -Encoding UTF8

    # Summary
    Write-Host ""
    Write-Host "============================================================" -ForegroundColor Cyan
    Write-Host "  REMEDIATION COMPLETE" -ForegroundColor Cyan
    Write-Host "============================================================" -ForegroundColor Cyan
    Write-Host ""
    $results | Format-Table -AutoSize
    Write-Host ""

    if ($totalThreats -gt 0) {
        Write-Host "  [WARNING] $totalThreats threat indicator(s) still detected after clean pass." -ForegroundColor Yellow
        Write-Host "            Review logs carefully -- some threats may require a reboot before" -ForegroundColor Yellow
        Write-Host "            they can be fully removed. Reboot and re-scan." -ForegroundColor Yellow
    } else {
        Write-Host "  [OK] Clean pass complete -- no threats detected in output." -ForegroundColor Green
    }

    Write-Host ""
    Write-Host "  Remediation results: $remResultsPath" -ForegroundColor Gray
    Write-Host ""
}

function Get-ScanSummary {
    <#
    .SYNOPSIS
        Parses a GuruScan results.json into a human-readable report.
    .DESCRIPTION
        Locates a results.json (defaults to the most recent scan in C:\ScanLogs\),
        prints a formatted summary with per-scanner results, threat counts, and
        a remediation recommendation if threats were found.
        Use -AI to send log content to a local Ollama model for threat analysis
        and AI-generated remediation recommendations.
    .PARAMETER ResultsFile
        Full path to a specific results.json. If omitted, the latest file in
        C:\ScanLogs\ is used automatically.
    .PARAMETER ShowAll
        Show every scanner including those that completed clean (no threats).
    .PARAMETER AI
        Send scan logs to local Ollama for AI-powered threat analysis and
        prioritized remediation recommendations.
    .PARAMETER OllamaUrl
        Ollama base URL. Defaults to http://localhost:11434.
    .PARAMETER OllamaModel
        Ollama model to use. Defaults to qwen3.6:latest.
    .EXAMPLE
        Get-ScanSummary
        Get-ScanSummary -AI
        Get-ScanSummary -ResultsFile "C:\ScanLogs\DESKTOP-20260523-143000\results.json" -AI
    #>
    [CmdletBinding()]
    param(
        [string]$ResultsFile  = '',
        [switch]$ShowAll,
        [switch]$AI,
        [string]$OllamaUrl    = 'http://localhost:11434',
        [string]$OllamaModel  = 'qwen3.6:latest'
    )

    # Locate results.json
    if (-not $ResultsFile) {
        $scanLogsRoot = $script:LogRoot
        if (-not (Test-Path $scanLogsRoot)) {
            Write-Host "[ERROR] No results file specified and $scanLogsRoot does not exist." -ForegroundColor Red
            return
        }

        $latestJson = Get-ChildItem -Path $scanLogsRoot -Recurse -Filter 'results.json' -ErrorAction SilentlyContinue |
            Sort-Object LastWriteTime -Descending |
            Select-Object -First 1

        if (-not $latestJson) {
            Write-Host "[ERROR] No results.json found under $scanLogsRoot\" -ForegroundColor Red
            return
        }

        $ResultsFile = $latestJson.FullName
        Write-Host "[INFO] Using latest results: $ResultsFile" -ForegroundColor Cyan
    }

    if (-not (Test-Path $ResultsFile)) {
        Write-Host "[ERROR] Results file not found: $ResultsFile" -ForegroundColor Red
        return
    }

    $data = Get-Content $ResultsFile -Raw | ConvertFrom-Json

    # Header
    $startDt     = [datetime]::Parse($data.started_at).ToLocalTime()
    $endDt       = [datetime]::Parse($data.completed_at).ToLocalTime()
    $totalDurMin = [math]::Round(($endDt - $startDt).TotalMinutes, 1)

    Write-Host ""
    Write-Host "================================================================" -ForegroundColor Cyan
    Write-Host "  GuruScan Report" -ForegroundColor Cyan
    Write-Host "================================================================" -ForegroundColor Cyan
    Write-Host "  Machine    : $($data.machine)"
    Write-Host "  Scan ID    : $($data.scan_id)"
    Write-Host "  Mode       : $($data.scan_mode)"
    Write-Host "  Started    : $($startDt.ToString('yyyy-MM-dd HH:mm:ss'))"
    Write-Host "  Completed  : $($endDt.ToString('yyyy-MM-dd HH:mm:ss'))"
    Write-Host "  Duration   : $totalDurMin min total"
    Write-Host "================================================================" -ForegroundColor Cyan
    Write-Host ""

    # Per-scanner table
    $rows = foreach ($s in $data.scanners) {
        $threatStr   = if ($s.threats_found -gt 0) { "YES ($($s.threats_found))" } else { 'Clean' }
        $statusColor = switch ($s.status) {
            'completed'            { 'Green'  }
            { $_ -like 'TIMED*' } { 'Yellow' }
            default                { 'Red'    }
        }
        [pscustomobject]@{
            Scanner  = $s.name
            Status   = $s.status
            ExitCode = $s.exit_code
            Duration = "$($s.duration_min) min"
            Threats  = $threatStr
            '_Color' = $statusColor
        }
    }

    Write-Host "  Scanner Results:" -ForegroundColor Cyan
    Write-Host ""

    $header = '  {0,-18} {1,-28} {2,-10} {3,-12} {4}' -f 'Scanner', 'Status', 'ExitCode', 'Duration', 'Threats'
    Write-Host $header -ForegroundColor Gray
    Write-Host ('  ' + ('-' * 78)) -ForegroundColor DarkGray

    foreach ($row in $rows) {
        $line = '  {0,-18} {1,-28} {2,-10} {3,-12} {4}' -f $row.Scanner, $row.Status, $row.ExitCode, $row.Duration, $row.Threats
        Write-Host $line -ForegroundColor $row.'_Color'
    }

    Write-Host ""

    # Threat summary
    $threatScanners = $data.scanners | Where-Object { $_.threats_found -gt 0 }
    $nonClean       = $data.scanners | Where-Object { $_.status -ne 'completed' }

    if ($data.total_threats -gt 0) {
        Write-Host "================================================================" -ForegroundColor Yellow
        Write-Host "  [WARNING] THREATS DETECTED" -ForegroundColor Yellow
        Write-Host "================================================================" -ForegroundColor Yellow
        Write-Host ""
        Write-Host "  Scanners that detected threats:" -ForegroundColor Yellow
        foreach ($ts in $threatScanners) {
            Write-Host "    - $($ts.name) (exit code $($ts.exit_code), log: $($ts.log_path))" -ForegroundColor Yellow
        }
        Write-Host ""
    } else {
        Write-Host "  [OK] No threats detected across all scanners." -ForegroundColor Green
        Write-Host ""
    }

    if ($nonClean) {
        Write-Host "  [WARNING] Scanners with non-completed status:" -ForegroundColor Yellow
        foreach ($nc in $nonClean) {
            Write-Host "    - $($nc.name): $($nc.status)" -ForegroundColor Yellow
        }
        Write-Host ""
    }

    # Recommendation
    Write-Host "================================================================" -ForegroundColor Cyan
    Write-Host "  Recommendation" -ForegroundColor Cyan
    Write-Host "================================================================" -ForegroundColor Cyan

    if ($data.reboot_required) {
        Write-Host "  [WARNING] A REBOOT IS REQUIRED to complete remediation." -ForegroundColor Yellow
        Write-Host "            Reboot the machine, then re-run GuruScan to verify." -ForegroundColor Yellow
    }
    elseif ($data.total_threats -gt 0 -and $data.scan_mode -eq 'scan') {
        Write-Host "  [INFO] Threats were detected in scan-only mode." -ForegroundColor Cyan
        Write-Host "         Run:  .\Invoke-GuruScan.ps1 (without -ScanOnly)" -ForegroundColor Cyan
        Write-Host "         Or:   .\Invoke-Remediation.ps1 -LogRoot `"$(Split-Path $ResultsFile)`"" -ForegroundColor Cyan
    }
    elseif ($data.total_threats -gt 0) {
        Write-Host "  [INFO] Clean mode was run. Review logs to confirm threats removed." -ForegroundColor Cyan
        Write-Host "         Log folder: $(Split-Path $ResultsFile)" -ForegroundColor Cyan
    }
    else {
        Write-Host "  [OK] System appears clean. No further action required." -ForegroundColor Green
    }

    Write-Host ""
    Write-Host "  Full logs: $(Split-Path $ResultsFile)" -ForegroundColor Gray
    Write-Host ""

    # Ollama AI analysis (optional -- requires -AI switch)
    if ($AI) {
        Write-Host "================================================================" -ForegroundColor Cyan
        Write-Host "  AI Analysis (Ollama)" -ForegroundColor Cyan
        Write-Host "================================================================" -ForegroundColor Cyan
        Write-Host "  Model  : $OllamaModel"
        Write-Host "  Server : $OllamaUrl"
        Write-Host ""

        # Collect log content from all scanner log folders (cap at 8KB each)
        $logFolder  = Split-Path $ResultsFile
        $logContent = [System.Text.StringBuilder]::new()
        Get-ChildItem -Path $logFolder -Recurse -Include '*.log', '*.txt', '*.csv' -ErrorAction SilentlyContinue |
            Where-Object { $_.Length -gt 0 -and $_.Length -lt 200KB } |
            ForEach-Object {
                $snippet = Get-Content $_.FullName -Raw -ErrorAction SilentlyContinue
                if ($snippet) {
                    $snippet = if ($snippet.Length -gt 8192) { $snippet.Substring(0, 8192) + "`n[TRUNCATED]" } else { $snippet }
                    [void]$logContent.AppendLine("=== $($_.Name) ===")
                    [void]$logContent.AppendLine($snippet)
                    [void]$logContent.AppendLine()
                }
            }

        $logText = if ($logContent.Length -gt 0) { $logContent.ToString() } else { 'No log files found.' }
        $scanJson = $data | ConvertTo-Json -Depth 5

        # Step 1: Extract specific threat names / findings
        Write-Host "  [....] Extracting threat details..." -ForegroundColor Cyan
        $threatPrompt = @"
You are a malware analyst. Below is a JSON scan summary and raw log excerpts from a multi-engine malware scan.

SCAN SUMMARY JSON:
$scanJson

RAW LOG EXCERPTS:
$logText

Task: Extract a concise list of specific threat names, file paths, registry keys, or other findings from the logs.
Format your response as a plain numbered list. If no specific threats are named in the logs, say "No named threats found in logs."
Do not add commentary -- only the list.
"@

        $threatDetails = Invoke-Ollama -Prompt $threatPrompt -Model $OllamaModel -BaseUrl $OllamaUrl
        if ($threatDetails) {
            Write-Host ""
            Write-Host "  Identified Threats / Findings:" -ForegroundColor Yellow
            $threatDetails -split "`n" | ForEach-Object { Write-Host "    $_" -ForegroundColor Yellow }
        } else {
            Write-Host "  [WARNING] Ollama unavailable -- skipping threat extraction." -ForegroundColor Yellow
        }

        # Step 2: Prioritized remediation recommendations
        Write-Host ""
        Write-Host "  [....] Generating remediation recommendations..." -ForegroundColor Cyan
        $remediationPrompt = @"
You are an MSP technician. A multi-engine malware scan produced these results:

$scanJson

Threat details extracted from logs:
$threatDetails

Task: Write a short, prioritized remediation checklist (max 8 steps) for the technician.
Include: immediate actions, follow-up verification steps, and whether a reboot is needed.
Plain numbered list, no markdown headers, no padding text. Be specific.
"@

        $recommendations = Invoke-Ollama -Prompt $remediationPrompt -Model $OllamaModel -BaseUrl $OllamaUrl
        if ($recommendations) {
            Write-Host ""
            Write-Host "  Remediation Checklist:" -ForegroundColor Cyan
            $recommendations -split "`n" | ForEach-Object { Write-Host "    $_" -ForegroundColor Cyan }
        } else {
            Write-Host "  [WARNING] Ollama unavailable -- skipping recommendations." -ForegroundColor Yellow
        }

        Write-Host ""
        Write-Host "================================================================" -ForegroundColor DarkGray
        Write-Host "  NOTE: AI output is advisory. Review logs before acting." -ForegroundColor DarkGray
        Write-Host "================================================================" -ForegroundColor DarkGray
        Write-Host ""
    }
}

function Invoke-PostRebootCleanup {
    <#
    .SYNOPSIS
        Manually triggers GuruScan post-scan cleanup (removes scanner files).
        Normally this runs automatically via the GuruRMM-ScannerCleanup scheduled task.
    .PARAMETER StateFile
        Path to cleanup-state.json. Defaults to C:\GuruScan\cleanup-state.json.
        (Parameter kept for backward compatibility -- the cleanup script reads it directly.)
    #>
    [CmdletBinding()]
    param(
        [string]$StateFile = 'C:\GuruScan\cleanup-state.json'
    )
    # Manually run the cleanup script if the scheduled task was missed
    $cleanupScript = 'C:\GuruScan\Invoke-ScannerCleanup.ps1'
    if (Test-Path $cleanupScript) {
        Write-Host '[INFO] Running scanner cleanup...' -ForegroundColor Cyan
        & powershell.exe -NoProfile -ExecutionPolicy Bypass -File $cleanupScript
    } else {
        Write-Host '[INFO] No cleanup script found at C:\GuruScan\Invoke-ScannerCleanup.ps1' -ForegroundColor Yellow
    }
}

# ---------------------------------------------------------------------------
# Export declarations
# ---------------------------------------------------------------------------

Export-ModuleMember -Function @(
    'Invoke-GuruScan',
    'Invoke-Remediation',
    'Get-ScanSummary',
    'Invoke-PostRebootCleanup'
)
