Private/New-TargetedDocumentation.ps1

function New-TargetedDocumentation {
    # Returns the EXPECTED capture path (a truthy string) on success — the caller passes it to
    # Wait-ForDocumentationAndProcess -ExpectedJsonPath so the wait can never be satisfied by a
    # stale capture from a previous run. Failure paths return $false.
    [CmdletBinding()]
    param(
        [string]$ProjectPath,
        [string]$ProjectName,
        [object]$AppInfo,

        # Which test/capture backend drives the generated script. 'Sandbox' builds + launches the .wsb;
        # 'HyperV' generates the (backend-aware) script but leaves execution to the Hyper-V provider.
        [ValidateSet('Sandbox', 'HyperV')]
        [string]$Backend = 'Sandbox',

        # Tests only: prepare everything (incl. clearing stale captures) but do not launch the sandbox.
        [switch]$SkipLaunch
    )

    try {
        Write-Verbose "Creating targeted documentation configuration..."

        # Create unique timestamp for this documentation session; the sandbox script writes
        # Documentation\InstallationChanges_<timestamp>.json with this exact name.
        $timestamp = Get-Date -Format "yyyyMMdd_HHmmss"
        $expectedJson = Join-Path $ProjectPath "Documentation\InstallationChanges_$timestamp.json"

        # Detect installer scope from YAML (user vs machine) to target the right registry paths
        $installerScope = 'unknown'
        $yamlFiles = Get-ChildItem -Path (Join-Path $ProjectPath 'Files') -Filter '*.yaml' -File -ErrorAction SilentlyContinue
        if ($yamlFiles) {
            $yamlRaw = Get-Content $yamlFiles[0].FullName -Raw -ErrorAction SilentlyContinue
            if ($yamlRaw -match '(?m)^\s*Scope:\s*(.+)') {
                $scopeVal = $matches[1].Trim().ToLower()
                if ($scopeVal -eq 'user') { $installerScope = 'user' }
                elseif ($scopeVal -eq 'machine') { $installerScope = 'machine' }
            }
        }
        Write-Verbose "Installer scope detected: $installerScope"
        
        # Define paths
        $supportFilesFolder = Join-Path $ProjectPath "SupportFiles"
        $sandboxConfigFile = Join-Path $ProjectPath "${ProjectName}_TargetedDocumentation.wsb"
        
        # Ensure SupportFiles folder exists
        if (-not (Test-Path $supportFilesFolder)) {
            New-Item -ItemType Directory -Path $supportFilesFolder -Force | Out-Null
        }
        
        # Create the documentation script content
        $documentationScript = @'
# Targeted PSADT Installation Documentation Script
# Auto-generated by CreateAppProject.ps1
 
$projectName = '__PROJECTNAME__'
$timestamp = '__TIMESTAMP__'
$installerScope = '__SCOPE__'
$backend = '__BACKEND__'
# Under Hyper-V this runs via PowerShell Direct (session 0, no interactive host): silence the progress
# bar so Write-Progress can't error, and the Sandbox-only init/teardown timing below is skipped.
if ($backend -eq 'HyperV') { $ProgressPreference = 'SilentlyContinue' }
$outputPath = 'C:\PSADT\Documentation'
$logPath = 'C:\PSADT\SupportFiles\Targeted_Documentation_Log___TIMESTAMP__.txt'
 
Write-Host "======================================"
Write-Host "PSADT Installation Documentation (Targeted)"
Write-Host "Project: $projectName"
Write-Host "Timestamp: $timestamp"
Write-Host "======================================"
 
# The settle wait is a Windows Sandbox artifact (services still coming up at logon). Under Hyper-V
# the provider already gated readiness (heartbeat + PS Direct) before this script runs, so skip it.
if ($backend -eq 'Sandbox') {
    Write-Host "`nWaiting for Windows Sandbox to fully initialize..." -ForegroundColor Yellow
    Write-Host "This ensures all system services are ready before documentation begins." -ForegroundColor Gray
 
    if ($installerScope -eq 'machine') {
        # MACHINE-scope captures: proceed as soon as the guest is actually ready — desktop up, the
        # service control manager answering, and the user-profile churn settled (two consecutive
        # samples) — CAPPED at the old 60 s, so the worst case is identical to the fixed wait.
        # USER/UNKNOWN scope keeps the fixed 60 s below: those captures diff LOCALAPPDATA/APPDATA/
        # USERPROFILE, exactly where the fresh WDAG profile's first-logon churn lands, and an early
        # PRE scan would push that churn into the install diff as noise.
        Write-Host "Probing readiness (machine scope; up to 60 seconds)..." -ForegroundColor Cyan
        # Machine scope still scans LOCALAPPDATA/APPDATA (installers can write there even machine-wide),
        # so the churn condition watches BOTH profile roots at the snapshot's own depth ballpark and
        # demands TWO consecutive stable comparisons — the desktop being up alone proves nothing about
        # first-logon profile churn. Earliest exit ~9 s; the 60 s cap keeps the worst case identical.
        $settleDeadline = (Get-Date).AddSeconds(60)
        $prevCount = -1
        $stableRuns = 0
        do {
            $ok = $false
            try {
                $explorerUp = [bool](Get-Process -Name explorer -ErrorAction SilentlyContinue)
                $count = @(Get-ChildItem -Path $env:LOCALAPPDATA -Recurse -Depth 2 -ErrorAction SilentlyContinue).Count +
                         @(Get-ChildItem -Path $env:APPDATA -Recurse -Depth 2 -ErrorAction SilentlyContinue).Count
                if ($prevCount -ge 0 -and [Math]::Abs($count - $prevCount) -le 2) { $stableRuns++ } else { $stableRuns = 0 }
                $prevCount = $count
                $ok = $explorerUp -and ($stableRuns -ge 2)
            } catch { $ok = $false }
            if ($ok) { break }
            Start-Sleep -Seconds 3
        } until ((Get-Date) -gt $settleDeadline)
    }
    else {
        Write-Host "Please wait 60 seconds..." -ForegroundColor Cyan
        for ($i = 60; $i -gt 0; $i--) {
            Write-Progress -Activity "Initializing Windows Sandbox" -Status "Waiting for system to stabilize..." -SecondsRemaining $i -PercentComplete ((60 - $i) / 60 * 100)
            Start-Sleep -Seconds 1
        }
        Write-Progress -Activity "Initializing Windows Sandbox" -Completed
    }
    Write-Host "Sandbox initialization complete!" -ForegroundColor Green
}
 
# Create documentation output folder
New-Item -Path $outputPath -ItemType Directory -Force | Out-Null
 
# Initialize logging function
function Write-Log {
    param([string]$Message, [string]$Level = "INFO")
    $logEntry = "$(Get-Date -Format 'yyyy-MM-dd HH:mm:ss') [$Level] $Message"
    Write-Host $logEntry
    Add-Content -Path $logPath -Value $logEntry -Encoding UTF8
}
 
# Start logging
Write-Log "========================================" "INFO"
Write-Log "PSADT Installation Documentation Started (Targeted)" "INFO"
Write-Log "Project: $projectName" "INFO"
Write-Log "Timestamp: $timestamp" "INFO"
Write-Log "Output Path: $outputPath" "INFO"
Write-Log "Log Path: $logPath" "INFO"
Write-Log "Windows Sandbox initialization wait started (60 seconds)" "INFO"
Write-Log "Waiting for system services to stabilize before baseline capture" "INFO"
Write-Log "Windows Sandbox initialization completed successfully" "INFO"
Write-Log "========================================" "INFO"
 
# Function to export data with error handling and proper formatting
function Export-DataSafely {
    param([string]$FilePath, [object]$Data, [string]$Description)
    try {
        $itemCount = if ($Data -is [array]) { $Data.Count } else { 1 }
        Write-Log "Attempting to export $Description ($itemCount items) to: $FilePath" "INFO"
         
        # Use Out-String with width to prevent truncation, then save to file
        $Data | Out-String -Width 4096 | Set-Content -Path $FilePath -Encoding UTF8 -Force
         
        Write-Host "Exported: $Description" -ForegroundColor Green
        Write-Log "Successfully exported $Description ($itemCount items)" "SUCCESS"
    } catch {
        Write-Host "Failed to export $Description - $($_.Exception.Message)" -ForegroundColor Red
        Write-Log "Failed to export $Description - $($_.Exception.Message)" "ERROR"
    }
}
 
# Function to scan targeted directories
function Get-TargetedDirectorySnapshot {
    param([string]$Description, [string[]]$Paths, [int]$Depth = 3)
     
    Write-Host "Scanning $Description..." -ForegroundColor Gray
    Write-Log "Beginning $Description scan with depth $Depth" "INFO"
 
    # List[object], not @()+= : the array re-allocation is quadratic over tens of thousands of files.
    $results = New-Object System.Collections.Generic.List[object]
 
    foreach ($path in $Paths) {
        if (Test-Path $path) {
            try {
                Write-Log "Scanning path: $path" "INFO"
                $items = @(Get-ChildItem -Path $path -Recurse -Depth $Depth -ErrorAction SilentlyContinue |
                         Select-Object FullName, Name, Length, CreationTime, LastWriteTime, Attributes)
                foreach ($it in $items) { $results.Add($it) }
                Write-Log "Found $($items.Count) items in $path" "INFO"
            } catch {
                Write-Log "Error scanning $path - $($_.Exception.Message)" "WARNING"
            }
        } else {
            Write-Log "Path not found: $path" "INFO"
        }
    }
 
    Write-Log "Total items found in $Description - $($results.Count)" "SUCCESS"
    return $results.ToArray()
}
 
# Function to scan targeted registry locations
function Get-TargetedRegistrySnapshot {
    param([string]$Description, [string[]]$RegistryPaths)
     
    Write-Host "Scanning $Description..." -ForegroundColor Gray
    Write-Log "Beginning $Description scan" "INFO"
 
    # List[object], not @()+= : quadratic re-allocation over thousands of registry keys.
    $results = New-Object System.Collections.Generic.List[object]
 
    foreach ($regPath in $RegistryPaths) {
        try {
            Write-Log "Scanning registry path: $regPath" "INFO"
 
            if (Test-Path $regPath) {
                # Get all subkeys and their properties
                $keys = Get-ChildItem -Path $regPath -Recurse -ErrorAction SilentlyContinue
 
                foreach ($key in $keys) {
                    try {
                        $properties = Get-ItemProperty -Path $key.PSPath -ErrorAction SilentlyContinue
                        if ($properties) {
                            # Create detailed registry entry with full paths and values
                            $propList = $properties.PSObject.Properties | Where-Object { $_.Name -notlike "PS*" }
                            $propString = ""
                            foreach ($prop in $propList) {
                                $value = if ($prop.Value -is [array]) { $prop.Value -join ", " } else { $prop.Value }
                                $propString += "`n $($prop.Name) = $value"
                            }
 
                            $results.Add([PSCustomObject]@{
                                FullPath = $key.Name
                                KeyName = $key.PSChildName
                                Properties = $propString
                                Summary = "Key: $($key.PSChildName) | Properties: $($propList.Count)"
                            })
                        }
                    } catch {
                        Write-Log "Error reading properties from $($key.PSPath) - $($_.Exception.Message)" "WARNING"
                    }
                }
 
                Write-Log "Found $($keys.Count) registry keys in $regPath" "INFO"
            } else {
                Write-Log "Registry path not found: $regPath" "INFO"
            }
        } catch {
            Write-Log "Error scanning registry path $regPath - $($_.Exception.Message)" "WARNING"
        }
    }
 
    Write-Log "Total registry entries found in $Description - $($results.Count)" "SUCCESS"
    return $results.ToArray()
}
 
# --- Declared dependencies: install BEFORE the baseline snapshot ---------------------------------
# Intune installs app dependencies FIRST on a real device (mobileAppDependency), so the test bench must
# too. Critically this runs BEFORE the pre-install baseline: if the dependency went in afterwards, its own
# files / registry keys / services would show up in the diff as changes made by THIS app — poisoning the
# generated detection rule, uninstall logic and processes-to-close.
if (Test-Path -LiteralPath 'C:\PSADT\Sandbox\InstallDependencies.ps1') {
    Write-Host "`nInstalling declared dependencies (before the baseline)..." -ForegroundColor Yellow
    Write-Log "Installing declared dependencies BEFORE the baseline capture" "INFO"
    try {
        & 'C:\PSADT\Sandbox\InstallDependencies.ps1'
        Write-Log "Declared dependencies installed" "SUCCESS"
    }
    catch {
        Write-Host "Dependency install FAILED - the capture may be unreliable." -ForegroundColor Red
        Write-Log "Dependency install FAILED: $($_.Exception.Message)" "ERROR"
    }
}
 
Write-Host "`nCapturing PRE-INSTALLATION baseline..." -ForegroundColor Yellow
Write-Log "Starting PRE-INSTALLATION baseline capture" "INFO"
 
# Set registry and directory targets based on installer scope
if ($installerScope -eq 'user') {
    Write-Host "Scope: USER — targeting HKCU registry and user profile directories" -ForegroundColor Cyan
    Write-Log "Installer scope: USER — monitoring HKCU paths and user profile directories" "INFO"
    $targetDirectories = @(
        "${env:LOCALAPPDATA}",
        "${env:APPDATA}",
        "${env:USERPROFILE}",
        "${env:ProgramFiles}",
        "${env:ProgramFiles(x86)}"
    )
    $targetRegistryPaths = @(
        "HKCU:\SOFTWARE\Microsoft\Windows\CurrentVersion\Uninstall",
        "HKCU:\SOFTWARE\Microsoft\Windows\CurrentVersion\App Paths",
        "HKCU:\SOFTWARE\Classes\Applications",
        # Some user-scope installers also write to HKLM
        "HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\Uninstall",
        "HKLM:\SOFTWARE\WOW6432Node\Microsoft\Windows\CurrentVersion\Uninstall",
        "HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\App Paths",
        "HKLM:\SOFTWARE\WOW6432Node\Microsoft\Windows\CurrentVersion\App Paths",
        "HKLM:\SOFTWARE\Classes\Applications"
    )
} elseif ($installerScope -eq 'machine') {
    Write-Host "Scope: MACHINE — targeting HKLM registry and system directories" -ForegroundColor Cyan
    Write-Log "Installer scope: MACHINE — monitoring HKLM paths and system directories" "INFO"
    $targetDirectories = @(
        "${env:ProgramFiles}",
        "${env:ProgramFiles(x86)}",
        "${env:ProgramData}",
        "${env:LOCALAPPDATA}",
        "${env:APPDATA}"
    )
    $targetRegistryPaths = @(
        "HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\Uninstall",
        "HKLM:\SOFTWARE\WOW6432Node\Microsoft\Windows\CurrentVersion\Uninstall",
        "HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\App Paths",
        "HKLM:\SOFTWARE\WOW6432Node\Microsoft\Windows\CurrentVersion\App Paths",
        "HKLM:\SOFTWARE\Classes\Applications",
        "HKCU:\SOFTWARE\Microsoft\Windows\CurrentVersion\Uninstall"
    )
} else {
    Write-Host "Scope: UNKNOWN — checking both HKCU and HKLM (no scope found in manifest)" -ForegroundColor Yellow
    Write-Log "Installer scope: UNKNOWN — monitoring all HKCU + HKLM paths and all directories" "INFO"
    $targetDirectories = @(
        "${env:ProgramFiles}",
        "${env:ProgramFiles(x86)}",
        "${env:ProgramData}",
        "${env:LOCALAPPDATA}",
        "${env:APPDATA}",
        "${env:USERPROFILE}"
    )
    $targetRegistryPaths = @(
        "HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\Uninstall",
        "HKLM:\SOFTWARE\WOW6432Node\Microsoft\Windows\CurrentVersion\Uninstall",
        "HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\App Paths",
        "HKLM:\SOFTWARE\WOW6432Node\Microsoft\Windows\CurrentVersion\App Paths",
        "HKLM:\SOFTWARE\Classes\Applications",
        "HKCU:\SOFTWARE\Microsoft\Windows\CurrentVersion\Uninstall",
        "HKCU:\SOFTWARE\Microsoft\Windows\CurrentVersion\App Paths",
        "HKCU:\SOFTWARE\Classes\Applications"
    )
}
 
# Capture baseline file system state (no export - keep in memory only)
$preFiles = Get-TargetedDirectorySnapshot -Description "Pre-installation files" -Paths $targetDirectories -Depth 3
Write-Log "Pre-installation files captured: $($preFiles.Count) files" "INFO"
 
# Capture baseline registry state (no export - keep in memory only)
$preRegistry = Get-TargetedRegistrySnapshot -Description "Pre-installation registry" -RegistryPaths $targetRegistryPaths
Write-Log "Pre-installation registry captured: $($preRegistry.Count) keys" "INFO"
 
# Capture baseline services (keep in memory only)
Write-Host "Capturing baseline services..." -ForegroundColor Gray
Write-Log "Beginning baseline services scan" "INFO"
$preServices = Get-Service | Select-Object Name, DisplayName, Status, StartType
Write-Log "Pre-installation services captured: $($preServices.Count) services" "INFO"
 
# NOTE: deliberately NO WMI product-class baseline here — enumerating the installer product class
# makes Windows Installer run a consistency check/reconfigure of EVERY registered MSI (minutes of
# wall-clock, repair side effects, event-log spam). New programs are derived from the Uninstall
# registry diff instead (see the NewPrograms derivation block below).
 
Write-Host "`nStarting PSADT installation..." -ForegroundColor Yellow
Write-Log "Starting PSADT installation phase" "INFO"
Write-Host "===============================================" -ForegroundColor Cyan
 
# Change to PSADT directory
Set-Location C:\PSADT
Write-Log "Changed directory to C:\PSADT" "INFO"
 
Write-Host "`nThe installation will now begin automatically (silent mode)..." -ForegroundColor Yellow
 
# Run the PSADT installation silently
Write-Host "Running: .\Invoke-AppDeployToolkit.ps1 -DeployMode Silent" -ForegroundColor White
Write-Log "Launching PSADT installation: .\Invoke-AppDeployToolkit.ps1 -DeployMode Silent" "INFO"
 
# Start installation silently and wait for completion
Write-Host "Starting installation..." -ForegroundColor Gray
Write-Host "Waiting for installation to complete (this may take several minutes)..." -ForegroundColor Yellow
 
$installProcess = Start-Process -FilePath "powershell.exe" -ArgumentList "-ExecutionPolicy", "Bypass", "-File", ".\Invoke-AppDeployToolkit.ps1", "-DeployMode", "Silent" -PassThru -Wait
 
Write-Host "Installation process completed with exit code: $($installProcess.ExitCode)" -ForegroundColor Green
Write-Log "PSADT installation process completed with exit code: $($installProcess.ExitCode)" "INFO"
 
Write-Host "`nWaiting for installation to fully finalize..." -ForegroundColor Yellow
Write-Host "Allowing time for background processes, registry updates, and file operations to complete." -ForegroundColor Gray
 
# The fixed 30 s stays for EVERY installer type — deliberately. An msiexec-quiescence early exit was
# built and then REVERTED by adversarial review: late FILE drops from detached children (MSI async EXE
# custom actions, NSIS/Inno spawns) have NO rescan backstop — the late-writer loop below rescans
# REGISTRY only — so any early exit here can permanently lose files from the diff that feeds the
# generated uninstall logic and processes-to-close. Do not shorten this wait without adding a
# file-side rescan.
Write-Host "Please wait 30 seconds..." -ForegroundColor Cyan
for ($i = 30; $i -gt 0; $i--) {
    Write-Progress -Activity "Finalizing Installation" -Status "Waiting for background processes and registry updates to complete..." -SecondsRemaining $i -PercentComplete ((30 - $i) / 30 * 100)
    Start-Sleep -Seconds 1
}
Write-Progress -Activity "Finalizing Installation" -Completed
Write-Log "Installation finalization wait completed (30 seconds)" "INFO"
Write-Host "Installation finalization complete!" -ForegroundColor Green
 
Write-Host "`nCapturing POST-INSTALLATION state..." -ForegroundColor Yellow
Write-Log "Starting POST-INSTALLATION state capture" "INFO"
 
# Capture post-installation file system state
$postFiles = Get-TargetedDirectorySnapshot -Description "Post-installation files" -Paths $targetDirectories -Depth 3
Write-Log "Post-installation files captured: $($postFiles.Count) files" "INFO"
 
# Capture post-installation registry state
$postRegistry = Get-TargetedRegistrySnapshot -Description "Post-installation registry" -RegistryPaths $targetRegistryPaths
Write-Log "Post-installation registry captured: $($postRegistry.Count) keys" "INFO"
 
# Capture post-installation services
Write-Host "Scanning post-installation services..." -ForegroundColor Gray
Write-Log "Beginning post-installation services scan" "INFO"
$postServices = Get-Service | Select-Object Name, DisplayName, Status, StartType
Write-Log "Post-installation services captured: $($postServices.Count) services" "INFO"
 
Write-Host "`nAnalyzing changes..." -ForegroundColor Yellow
Write-Log "Starting change analysis" "INFO"
 
# Compare file system changes
try {
    Write-Log "Comparing file system changes" "INFO"
    $newFiles = Compare-Object -ReferenceObject $preFiles.FullName -DifferenceObject $postFiles.FullName |
                Where-Object { $_.SideIndicator -eq '=>' } | Select-Object @{Name='NewFile';Expression={$_.InputObject}}
    Write-Log "Found $($newFiles.Count) new files" "INFO"
     
    $modifiedFiles = Compare-Object -ReferenceObject $preFiles -DifferenceObject $postFiles -Property FullName, LastWriteTime |
                     Where-Object { $_.SideIndicator -eq '=>' -and $_.FullName -in $preFiles.FullName } |
                     Select-Object @{Name='ModifiedFile';Expression={$_.FullName}}, LastWriteTime
    Write-Log "Found $($modifiedFiles.Count) modified files" "INFO"
} catch {
    Write-Host "Warning: Could not compare file changes" -ForegroundColor Yellow
    Write-Log "Warning: Could not compare file changes: $($_.Exception.Message)" "WARNING"
}
 
# Compare registry changes with retry logic
try {
    Write-Log "Comparing registry changes" "INFO"
    Write-Host "Registry comparison: PRE entries = $($preRegistry.Count), POST entries = $($postRegistry.Count)" -ForegroundColor Gray
 
    # Build a HashSet of pre-install FullPath values for reliable set-difference detection
    $preFullPathSet = [System.Collections.Generic.HashSet[string]]::new([System.StringComparer]::OrdinalIgnoreCase)
    foreach ($preEntry in $preRegistry) { if ($preEntry.FullPath) { [void]$preFullPathSet.Add($preEntry.FullPath) } }
 
    function Get-NewRegEntries {
        param($Snapshot)
        @($Snapshot | Where-Object { $_.FullPath -and -not $preFullPathSet.Contains($_.FullPath) } |
            ForEach-Object {
                [PSCustomObject]@{
                    NewRegistryEntry = $_.FullPath
                    KeyName = $_.KeyName
                    Properties = $_.Properties
                    Summary = $_.Summary
                }
            })
    }
 
    $newRegEntries = @(Get-NewRegEntries -Snapshot $postRegistry)
 
    if ($newRegEntries.Count -eq 0) {
        # An empty diff usually means a delayed writer (or a portable app). The old loop slept a blind
        # 30 s and re-scanned ALL 6-8 hives, up to three times (90 s + 3 full scans, every time). Same
        # 90 s TOTAL BUDGET, but: poll every 5 s against ONLY the Uninstall hives (the "did anything
        # land" signal — cheap), and when the budget ends or the probe trips, do ONE full-hive rescan +
        # full diff — mandatory, because an app whose only late writes are App Paths / Classes would
        # never trip the Uninstall probe, and today's loop caught those on its full-scan retries.
        Write-Host "No registry changes detected — polling the Uninstall hives for late writers (up to 90 s)..." -ForegroundColor Yellow
        Write-Log "Registry diff empty - polling Uninstall hives every 5 s (90 s budget) for delayed writes" "INFO"
        $uninstallHives = @($targetRegistryPaths | Where-Object { $_ -like '*CurrentVersion\Uninstall*' })
        $retryStart = Get-Date
        $retryBudget = $retryStart.AddSeconds(90)
        do {
            Start-Sleep -Seconds 5
            $probe = @(Get-TargetedRegistrySnapshot -Description "Uninstall-hive probe" -RegistryPaths $uninstallHives)
            $probeNew = @($probe | Where-Object { $_.FullPath -and -not $preFullPathSet.Contains($_.FullPath) })
            if ($probeNew.Count -gt 0) {
                Write-Log "Uninstall-hive probe detected $($probeNew.Count) new key(s) - running the full rescan" "INFO"
                break
            }
        } until ((Get-Date) -gt $retryBudget)
 
        # The final rescan is a ONE-SHOT diff — never before t=30 s into the budget. The old loop's
        # earliest full rescan was its first 30 s retry; a probe hit at t=6 s must not pull the single
        # rescan earlier than that, or writes landing between the early hit and t=30 (which the old
        # code caught) would be permanently lost.
        $elapsed = ((Get-Date) - $retryStart).TotalSeconds
        if ($elapsed -lt 30) { Start-Sleep -Seconds ([int][Math]::Ceiling(30 - $elapsed)) }
 
        # Final full-hive rescan + full diff (runs on probe hit AND on budget exhaustion).
        Write-Host "Re-scanning all registry hives (final)..." -ForegroundColor Cyan
        $postRegistry = Get-TargetedRegistrySnapshot -Description "Post-installation registry (final)" -RegistryPaths $targetRegistryPaths
        Write-Log "Registry re-scan completed: $($postRegistry.Count) keys" "INFO"
        $newRegEntries = @(Get-NewRegEntries -Snapshot $postRegistry)
    }
 
    if ($newRegEntries.Count -gt 0) {
        Write-Host "Found $($newRegEntries.Count) new registry entries" -ForegroundColor Green
        Write-Log "Found $($newRegEntries.Count) new registry entries" "INFO"
    } else {
        Write-Host "No registry changes detected after the 90 s late-writer budget" -ForegroundColor Yellow
        Write-Log "No registry changes detected after the 90 s late-writer budget" "WARNING"
    }
 
} catch {
    Write-Host "Warning: Could not compare registry changes" -ForegroundColor Yellow
    Write-Log "Warning: Could not compare registry changes: $($_.Exception.Message)" "WARNING"
}
 
# Compare services
try {
    Write-Log "Comparing service changes" "INFO"
    $newServices = Compare-Object -ReferenceObject $preServices.Name -DifferenceObject $postServices.Name |
                    Where-Object { $_.SideIndicator -eq '=>' } | Select-Object @{Name='NewService';Expression={$_.InputObject}}
    Write-Log "Found $($newServices.Count) new services" "INFO"
} catch {
    Write-Host "Warning: Could not compare service changes" -ForegroundColor Yellow
    Write-Log "Warning: Could not compare service changes: $($_.Exception.Message)" "WARNING"
}
 
# Create JSON output for automation (ONLY output besides log)
Write-Host "`nGenerating JSON documentation for automation..." -ForegroundColor Cyan
Write-Log "Creating JSON documentation for new files and registry keys" "INFO"
 
# Prepare JSON data structure
$jsonData = @{
    InstallationInfo = @{
        ProjectName = $projectName
        Timestamp = $timestamp
        DocumentationDate = Get-Date -Format "yyyy-MM-dd HH:mm:ss"
    }
    NewFiles = @()
    ModifiedFiles = @()
    NewRegistryKeys = @()
    NewServices = @()
    NewPrograms = @()
}
 
# Process new files for JSON
if ($newFiles -and $newFiles.Count -gt 0) {
    foreach ($file in $newFiles) {
        $fileInfo = @{
            Path = $file.NewFile
            Size = if (Test-Path $file.NewFile) { (Get-Item $file.NewFile -ErrorAction SilentlyContinue).Length } else { $null }
            CreatedDate = if (Test-Path $file.NewFile) { (Get-Item $file.NewFile -ErrorAction SilentlyContinue).CreationTime.ToString("yyyy-MM-dd HH:mm:ss") } else { $null }
            Type = if (Test-Path $file.NewFile) {
                if ((Get-Item $file.NewFile -ErrorAction SilentlyContinue).PSIsContainer) { "Directory" } else { "File" }
            } else { "Unknown" }
        }
        $jsonData.NewFiles += $fileInfo
    }
}
 
# Process modified files for JSON
if ($modifiedFiles -and $modifiedFiles.Count -gt 0) {
    foreach ($file in $modifiedFiles) {
        $fileInfo = @{
            Path = $file.ModifiedFile
            Size = if (Test-Path $file.ModifiedFile) { (Get-Item $file.ModifiedFile -ErrorAction SilentlyContinue).Length } else { $null }
            ModifiedDate = if (Test-Path $file.ModifiedFile) { (Get-Item $file.ModifiedFile -ErrorAction SilentlyContinue).LastWriteTime.ToString("yyyy-MM-dd HH:mm:ss") } else { $null }
        }
        $jsonData.ModifiedFiles += $fileInfo
    }
}
 
# Process new registry entries for JSON
if ($newRegEntries -and $newRegEntries.Count -gt 0) {
    Write-Log "Processing $($newRegEntries.Count) new registry entries for JSON" "INFO"
    foreach ($regEntry in $newRegEntries) {
        # Get the full registry information for this path
        $regPath = $regEntry.NewRegistryEntry
        Write-Log "Processing registry path for JSON: $regPath" "INFO"
        try {
            $regItem = Get-Item -Path "Registry::$regPath" -ErrorAction SilentlyContinue
            if ($regItem) {
                $regInfo = @{
                    Path = $regPath
                    KeyName = $regEntry.KeyName
                    ValueCount = $regItem.ValueCount
                    SubKeyCount = $regItem.SubKeyCount
                    Values = @{}
                    Properties = $regEntry.Properties
                }
                 
                # Get all values in this registry key
                try {
                    $regValues = Get-ItemProperty -Path "Registry::$regPath" -ErrorAction SilentlyContinue
                    if ($regValues) {
                        $regValues.PSObject.Properties | ForEach-Object {
                            if ($_.Name -notin @('PSPath', 'PSParentPath', 'PSChildName', 'PSDrive', 'PSProvider')) {
                                $regInfo.Values[$_.Name] = $_.Value
                            }
                        }
                    }
                } catch {
                    # Registry key might not have values or access denied
                }
                 
                $jsonData.NewRegistryKeys += $regInfo
            }
        } catch {
            # Fallback for inaccessible registry keys - still include basic info
            Write-Log "Registry key not accessible, using fallback data: $regPath" "WARNING"
            $regInfo = @{
                Path = $regPath
                KeyName = $regEntry.KeyName
                ValueCount = "Unknown"
                SubKeyCount = "Unknown"
                Values = @{}
                Properties = $regEntry.Properties
                Note = "Access denied or key not accessible"
            }
            $jsonData.NewRegistryKeys += $regInfo
        }
    }
} else {
    Write-Log "No new registry entries found for JSON processing" "INFO"
    Write-Host "No new registry entries detected" -ForegroundColor Yellow
}
 
# Process new services for JSON
if ($newServices -and $newServices.Count -gt 0) {
    foreach ($service in $newServices) {
        $serviceInfo = @{
            Name = $service.NewService
            DisplayName = (Get-Service -Name $service.NewService -ErrorAction SilentlyContinue).DisplayName
            Status = (Get-Service -Name $service.NewService -ErrorAction SilentlyContinue).Status
            StartType = (Get-Service -Name $service.NewService -ErrorAction SilentlyContinue).StartType
        }
        $jsonData.NewServices += $serviceInfo
    }
}
 
# BEGIN NewPrograms derivation (from Uninstall registry diff — replaces the WMI product-class scan)
# Derived after the NewRegistryKeys loop has settled. Keeps the legacy Name/Path keys for schema
# compatibility; the other fields are additive. Note: the recursive registry snapshot can include
# SUBKEYS of a new Uninstall entry — any subkey with its own DisplayName yields an extra row, which
# the DisplayName|DisplayVersion dedupe below tolerates.
$seenPrograms = @{}
foreach ($regKey in $jsonData.NewRegistryKeys) {
    if ($regKey.Path -match '\\Microsoft\\Windows\\CurrentVersion\\Uninstall\\' -and $regKey.Values -and $regKey.Values['DisplayName']) {
        $dedupeKey = "$($regKey.Values['DisplayName'])|$($regKey.Values['DisplayVersion'])"
        if (-not $seenPrograms.ContainsKey($dedupeKey)) {
            $seenPrograms[$dedupeKey] = $true
            $jsonData.NewPrograms += @{
                Name = $regKey.Values['DisplayName']
                DisplayName = $regKey.Values['DisplayName']
                DisplayVersion = $regKey.Values['DisplayVersion']
                Publisher = $regKey.Values['Publisher']
                UninstallString = $regKey.Values['UninstallString']
                Path = $regKey.Values['InstallLocation']
                Source = 'UninstallRegistry'
            }
        }
    }
}
Write-Log "Derived $($jsonData.NewPrograms.Count) new programs from Uninstall registry diff" "INFO"
# END NewPrograms derivation
 
# --- Capture the installed application's icon (fills in when winget has no icon, and for manual apps) ---
# Sources, in priority order: each new ARP 'DisplayIcon' (already captured in NewRegistryKeys above), then
# the largest .exe under each new InstallLocation. Extracted at up to 256x256 as a REAL PNG with the alpha
# channel intact, then written to Sandbox\Logs\ so it rides back to the host for BOTH backends (Sandbox maps
# it live; the Hyper-V provider already copies Sandbox\Logs\*). The host side (finalize) decides whether to
# promote it, honoring any winget/manual icon already applied.
#
# SECURITY: every value used here (DisplayIcon strings, InstallLocation) is RUNTIME registry data. It is only
# ever passed as an ARGUMENT to Get-Item / the icon APIs — never spliced into generated code — so there is no
# script-injection surface even though this runs as the capture user.
try {
    Write-Host "`nExtracting the installed application icon..." -ForegroundColor Cyan
    Write-Log "Starting installed-app icon extraction" "INFO"
 
    $iconOutDir = 'C:\PSADT\Sandbox\Logs'
    if (-not (Test-Path $iconOutDir)) { New-Item -ItemType Directory -Path $iconOutDir -Force | Out-Null }
    $iconOut = Join-Path $iconOutDir 'AppIcon_Captured.png'
    $iconMinSize = 48 # skip-low-res gate: never upscale a frame this small or smaller
 
    Add-Type -AssemblyName System.Drawing -ErrorAction SilentlyContinue
    if (-not ('Win32IconApi.Native' -as [type])) {
        Add-Type -Namespace Win32IconApi -Name Native -MemberDefinition @"
[System.Runtime.InteropServices.DllImport("user32.dll", CharSet = System.Runtime.InteropServices.CharSet.Unicode)]
public static extern uint PrivateExtractIcons(string szFileName, int nIconIndex, int cxIcon, int cyIcon, System.IntPtr[] phicon, uint[] piconid, uint nIcons, uint flags);
[System.Runtime.InteropServices.DllImport("user32.dll")]
public static extern bool DestroyIcon(System.IntPtr hIcon);
"@ -ErrorAction SilentlyContinue
    }
 
    # Mirror of ConvertFrom-Win32ToolkitDisplayIcon (kept 5.1-safe & self-contained for the guest).
    function ConvertFrom-DisplayIcon {
        param([string]$Value)
        if (-not $Value) { return $null }
        $v = $Value.Trim()
        if (-not $v) { return $null }
        $idx = 0
        if ($v.StartsWith('"')) {
            $end = $v.IndexOf('"', 1)
            if ($end -lt 0) { return $null }
            $p = $v.Substring(1, $end - 1)
            $rest = $v.Substring($end + 1).Trim()
            if ($rest -match '^,\s*(-?\d+)') { $idx = [int]$matches[1] }
        } else {
            if ($v -match '^(.*),\s*(-?\d+)\s*$') { $p = $matches[1].Trim(); $idx = [int]$matches[2] }
            else { $p = $v }
        }
        if (-not $p) { return $null }
        $p = [Environment]::ExpandEnvironmentVariables($p)
        return New-Object PSObject -Property @{ Path = $p; Index = $idx }
    }
 
    function Save-IconViaApi {
        param([string]$Path, [int]$Index, [string]$OutFile, [int]$Size)
        if (-not ('Win32IconApi.Native' -as [type])) { return $false }
        $phicon = New-Object 'System.IntPtr[]' 1
        # 'uint[]' is NOT a valid type name on the guest's Windows PowerShell 5.1 ('uint' became an
        # accelerator only in PS 6+). Use the full CLR name so PrivateExtractIcons' piconid marshals.
        $piconid = New-Object 'System.UInt32[]' 1
        $n = [Win32IconApi.Native]::PrivateExtractIcons($Path, $Index, $Size, $Size, $phicon, $piconid, 1, 0)
        if ($n -lt 1 -or $phicon[0] -eq [System.IntPtr]::Zero) { return $false }
        $hicon = $phicon[0]
        try {
            $bmp = New-Object System.Drawing.Bitmap($Size, $Size, [System.Drawing.Imaging.PixelFormat]::Format32bppArgb)
            try {
                $g = [System.Drawing.Graphics]::FromImage($bmp)
                try {
                    $g.Clear([System.Drawing.Color]::Transparent)
                    $ico = [System.Drawing.Icon]::FromHandle($hicon)
                    try { $g.DrawIcon($ico, (New-Object System.Drawing.Rectangle(0, 0, $Size, $Size))) }
                    finally { $ico.Dispose() }
                }
                finally { $g.Dispose() }
                $bmp.Save($OutFile, [System.Drawing.Imaging.ImageFormat]::Png)
                return $true
            }
            finally { $bmp.Dispose() }
        }
        finally { [void][Win32IconApi.Native]::DestroyIcon($hicon) }
    }
 
    function Save-AppIcon {
        param([string]$Path, [int]$Index, [string]$OutFile, [int]$MinSize)
        if (-not $Path) { return $false }
        if (-not (Test-Path -LiteralPath $Path)) { return $false }
        $ext = [System.IO.Path]::GetExtension($Path)
        if ($ext) { $ext = $ext.ToLower() }
 
        # .ico file: pick the largest frame; copy an embedded PNG frame byte-for-byte (best quality).
        if ($ext -eq '.ico') {
            try {
                $b = [System.IO.File]::ReadAllBytes($Path)
                if ($b.Length -lt 6 -or $b[0] -ne 0 -or $b[1] -ne 0 -or $b[2] -ne 1 -or $b[3] -ne 0) { return $false }
                $count = [System.BitConverter]::ToUInt16($b, 4)
                $bestSize = -1; $bestOff = 0; $bestLen = 0
                for ($i = 0; $i -lt $count; $i++) {
                    $e = 6 + ($i * 16)
                    if ($e + 16 -gt $b.Length) { break }
                    $w = $b[$e]; if ($w -eq 0) { $w = 256 }
                    $h = $b[$e + 1]; if ($h -eq 0) { $h = 256 }
                    $size = [Math]::Max($w, $h)
                    if ($size -gt $bestSize) {
                        $bestSize = $size
                        $bestLen = [System.BitConverter]::ToInt32($b, $e + 8)
                        $bestOff = [System.BitConverter]::ToInt32($b, $e + 12)
                    }
                }
                if ($bestSize -lt $MinSize) { return $false }
                if ($bestOff -gt 0 -and $bestLen -gt 8 -and ($bestOff + $bestLen) -le $b.Length -and
                    $b[$bestOff] -eq 0x89 -and $b[$bestOff + 1] -eq 0x50 -and $b[$bestOff + 2] -eq 0x4E -and $b[$bestOff + 3] -eq 0x47) {
                    $frame = New-Object 'byte[]' $bestLen
                    [System.Array]::Copy($b, $bestOff, $frame, 0, $bestLen)
                    [System.IO.File]::WriteAllBytes($OutFile, $frame)
                    return $true
                }
                return (Save-IconViaApi -Path $Path -Index 0 -OutFile $OutFile -Size ([Math]::Min($bestSize, 256)))
            }
            catch { return $false }
        }
 
        # A raster image used directly as the DisplayIcon: gate on real dimensions, re-encode PNG.
        if ($ext -eq '.png' -or $ext -eq '.jpg' -or $ext -eq '.jpeg' -or $ext -eq '.bmp' -or $ext -eq '.gif') {
            try {
                $img = [System.Drawing.Image]::FromFile($Path)
                try {
                    if ([Math]::Max($img.Width, $img.Height) -lt $MinSize) { return $false }
                    $img.Save($OutFile, [System.Drawing.Imaging.ImageFormat]::Png)
                    return $true
                }
                finally { $img.Dispose() }
            }
            catch { return $false }
        }
 
        # PE (.exe/.dll) or unknown: extract the embedded icon at up to 256x256 (best-effort; the exact
        # low-res gate applies to .ico/raster sources where the native frame size is known cheaply).
        return (Save-IconViaApi -Path $Path -Index $Index -OutFile $OutFile -Size 256)
    }
 
    # Build the candidate list from the new Uninstall keys: DisplayIcon values first, InstallLocations fallback.
    $iconCandidates = @()
    $exeLocations = @()
    foreach ($rk in $jsonData.NewRegistryKeys) {
        if (-not $rk.Path) { continue }
        if ($rk.Path -notmatch '\\Uninstall\\') { continue }
        $vals = $rk.Values
        if (-not $vals) { continue }
        $di = [string]$vals['DisplayIcon']
        $il = [string]$vals['InstallLocation']
        if ($di) {
            $parsed = ConvertFrom-DisplayIcon -Value $di
            if ($parsed) { $iconCandidates += $parsed }
        }
        if ($il) {
            $loc = [Environment]::ExpandEnvironmentVariables($il).Trim()
            if ($loc) { $exeLocations += $loc }
        }
    }
 
    $iconDone = $false
    foreach ($c in $iconCandidates) {
        if (Save-AppIcon -Path $c.Path -Index $c.Index -OutFile $iconOut -MinSize $iconMinSize) { $iconDone = $true; break }
    }
 
    if (-not $iconDone) {
        # Fallback: the largest .exe under each InstallLocation (skip system roots — never recurse those).
        $systemRoots = @()
        foreach ($r in @($env:ProgramFiles, ${env:ProgramFiles(x86)}, $env:ProgramData, $env:SystemRoot, $env:USERPROFILE, 'C:\')) {
            if ($r) { $systemRoots += $r.TrimEnd('\').ToLower() }
        }
        foreach ($loc in $exeLocations) {
            if (-not (Test-Path -LiteralPath $loc)) { continue }
            $full = ''
            try { $full = [System.IO.Path]::GetFullPath($loc).TrimEnd('\').ToLower() } catch { $full = '' }
            if ($full -and ($systemRoots -contains $full)) { continue }
            $exe = Get-ChildItem -LiteralPath $loc -Filter *.exe -Recurse -Depth 2 -ErrorAction SilentlyContinue |
                   Sort-Object Length -Descending | Select-Object -First 1
            if ($exe -and (Save-AppIcon -Path $exe.FullName -Index 0 -OutFile $iconOut -MinSize $iconMinSize)) { $iconDone = $true; break }
        }
    }
 
    if ($iconDone) {
        Write-Host "Captured application icon -> $iconOut" -ForegroundColor Green
        Write-Log "Captured application icon to $iconOut" "SUCCESS"
    } else {
        Write-Host "No suitable application icon found to capture." -ForegroundColor Yellow
        Write-Log "No suitable application icon found to capture" "INFO"
    }
}
catch {
    Write-Host "Icon extraction failed: $($_.Exception.Message)" -ForegroundColor Yellow
    Write-Log "Icon extraction failed: $($_.Exception.Message)" "WARNING"
}
 
# Export JSON data
try {
    $jsonOutput = $jsonData | ConvertTo-Json -Depth 10 -Compress:$false
    $jsonFilePath = "$outputPath\InstallationChanges_$timestamp.json"
    Set-Content -Path $jsonFilePath -Value $jsonOutput -Encoding UTF8
    Write-Host "JSON documentation exported: InstallationChanges_$timestamp.json" -ForegroundColor Green
    Write-Log "JSON documentation exported successfully to: $jsonFilePath" "SUCCESS"
     
} catch {
    Write-Host "Warning: Could not export JSON documentation" -ForegroundColor Yellow
    Write-Log "Warning: Could not export JSON documentation: $($_.Exception.Message)" "WARNING"
}
 
# Copy log file to documentation output folder
try {
    Copy-Item -Path $logPath -Destination "$outputPath\Targeted_Documentation_Log_$timestamp.txt" -Force
    Write-Log "Log file copied to documentation output folder" "SUCCESS"
} catch {
    Write-Log "Failed to copy log file to documentation folder: $($_.Exception.Message)" "WARNING"
}
 
# Copy PSADT / MSI install logs back to the project (survives sandbox teardown)
try {
    if (Test-Path 'C:\PSADT\Sandbox\CollectLogs.ps1') {
        & 'C:\PSADT\Sandbox\CollectLogs.ps1'
        Write-Log "Install logs collected to Sandbox\Logs" "SUCCESS"
    } else {
        Write-Log "CollectLogs.ps1 not found in Sandbox folder" "WARNING"
    }
} catch {
    Write-Log "Failed to collect install logs: $($_.Exception.Message)" "WARNING"
}
 
Write-Host "`n======================================"
Write-Host "Targeted Documentation completed successfully!" -ForegroundColor Green
Write-Log "========================================" "SUCCESS"
Write-Log "PSADT Installation Documentation Completed (Targeted)" "SUCCESS"
Write-Log "All documentation files saved to: $outputPath" "SUCCESS"
Write-Log "Log file saved to: $logPath" "SUCCESS"
Write-Log "========================================" "SUCCESS"
Write-Host "======================================"
Write-Host "Output folder: $outputPath" -ForegroundColor Cyan
Write-Host "`nGenerated Files:" -ForegroundColor Yellow
Write-Host "• JSON format for automation and uninstall/requirement script generation" -ForegroundColor White
Write-Host "• Complete execution log with detailed change analysis" -ForegroundColor White
Write-Host "`nNext steps:" -ForegroundColor Yellow
Write-Host "1. Copy the Documentation folder to your host system" -ForegroundColor Green
Write-Host "2. Use InstallationChanges JSON for automated uninstall and requirement script generation" -ForegroundColor Green
Write-Host "3. Review detailed log for installation analysis" -ForegroundColor Green
 
Write-Log "Documentation process completed successfully" "SUCCESS"
 
# The auto-close countdown + Stop-Computer are Windows Sandbox teardown. Under Hyper-V the host
# reverts the checkpoint AFTER the provider copies the capture back — the guest must NOT shut down
# (that would kill the PowerShell Direct session mid-copy) and there is no sandbox client to disconnect.
# The duration is mode-dependent (host decides): 30 s WATCHED — the operator's Ctrl+C window to keep the
# sandbox and inspect the captured install; 5 s UNATTENDED — just enough for the VSMB mapped-folder
# write-back to flush, so a chained test's single-instance guard clears quickly.
if ($backend -eq 'Sandbox') {
    # Quoted-cast placeholder so the raw template itself stays parseable (5.1-safe).
    $autoClose = [int]'__AUTOCLOSE__'
    Write-Host "`n======================================"
    Write-Host "AUTO-CLOSING SANDBOX IN $autoClose SECONDS" -ForegroundColor Red
    Write-Host "======================================"
    Write-Host "The Windows Sandbox will automatically close to clean up resources." -ForegroundColor Yellow
    Write-Host "All documentation files have been saved to the mapped folder." -ForegroundColor Green
    Write-Host "`nPress Ctrl+C to cancel auto-close..." -ForegroundColor Gray
 
    for ($i = $autoClose; $i -gt 0; $i--) {
        Write-Progress -Activity "Auto-closing Windows Sandbox" -Status "Sandbox will close automatically to free resources..." -SecondsRemaining $i -PercentComplete ((($autoClose - $i) / $autoClose) * 100)
        Start-Sleep -Seconds 1
    }
    Write-Progress -Activity "Auto-closing Windows Sandbox" -Completed
 
    Write-Host "`nClosing Windows Sandbox..." -ForegroundColor Red
    Write-Log "Sandbox auto-close initiated after $autoClose-second countdown" "INFO"
 
    # Gracefully shut down the sandbox so the host client can disconnect cleanly
    Stop-Computer
}
'@


        # Replace placeholders with actual values. .Replace(), NOT -replace: the regex operator treats
        # the replacement as a .NET SUBSTITUTION string, so a project name containing $_ / $& would
        # splice template text into itself. Values landing inside '...' assignments are single-quote
        # escaped so an apostrophe in a project name cannot break the generated script's parse.
        $documentationScript = $documentationScript.Replace('__PROJECTNAME__', ($ProjectName -replace "'", "''"))
        $documentationScript = $documentationScript.Replace('__TIMESTAMP__', [string]$timestamp)
        $documentationScript = $documentationScript.Replace('__SCOPE__', [string]$installerScope)
        $documentationScript = $documentationScript.Replace('__BACKEND__', [string]$Backend)
        # Auto-close: 30 s watched (the operator's Ctrl+C inspect window) / 5 s unattended (VSMB flush
        # only). Mode comes from the SHARED resolver so the -Unattended/config/non-interactive-host
        # precedence cannot drift from the test scenarios. Fail-safe to 30: a resolver hiccup must never
        # leave the placeholder empty or shorten the operator's window.
        $autoCloseSeconds = 30
        try {
            if ((Get-Win32ToolkitTestMode -Backend Sandbox) -eq 'Unattended') { $autoCloseSeconds = 5 }
        } catch { $autoCloseSeconds = 30 }
        $documentationScript = $documentationScript.Replace('__AUTOCLOSE__', [string]$autoCloseSeconds)

        # Save the documentation script inside the PSADT project's SupportFiles folder. Write UTF-8 WITH a
        # BOM: the guest runs it under Windows PowerShell 5.1, which decodes a BOM-less file as ANSI and
        # mojibakes non-ASCII (matches New-UpdateAssertionScript). PS7 `Set-Content -Encoding UTF8` is BOM-less.
        $docScriptPath = Join-Path $supportFilesFolder "TargetedDocumentationScript.ps1"
        [System.IO.File]::WriteAllText($docScriptPath, $documentationScript, (New-Object System.Text.UTF8Encoding($true)))
        Write-Verbose "Targeted documentation script created: $docScriptPath"

        # Create the log collector used inside the sandbox (Sandbox\CollectLogs.ps1),
        # so the documentation run's PSADT/MSI install logs are copied back to the project.
        $null = New-LogCollectorScript -ProjectPath $ProjectPath
        Write-Verbose "Log collector created for documentation sandbox"

        # Stage declared dependencies into Sandbox\Dependencies\ so the guest can install them BEFORE the
        # baseline. Without this the capture runs on a machine missing the app's runtime, and the detection
        # / uninstall rules would be derived from a broken install. No-op when none are declared.
        $null = Initialize-Win32ToolkitDependencyStaging -ProjectPath $ProjectPath

        # Create the sandbox configuration (Sandbox backend only — Hyper-V runs the same generated script
        # over PowerShell Direct with no .wsb). Built via the shared .wsb builder (test-backend seam, Phase 0).
        if ($Backend -eq 'Sandbox') {
            $sandboxConfigContent = New-Win32ToolkitSandboxConfig `
                -Mount @{ HostPath = $ProjectPath; GuestPath = 'C:\PSADT'; ReadOnly = $false } `
                -LogonCommandXml 'powershell.exe -NoExit -ExecutionPolicy Bypass -File C:\PSADT\SupportFiles\TargetedDocumentationScript.ps1'

            # Write the sandbox configuration to the file
            $sandboxConfigContent | Set-Content -Path $sandboxConfigFile -Encoding UTF8
            Write-Verbose "Targeted documentation sandbox configuration created!"
        }

        # Clear stale captures from previous runs BEFORE launching, so any capture file present after
        # this point postdates this launch (re-finalize previously picked up the old JSON instantly).
        # Only the two generated patterns are removed — user notes in Documentation\ survive.
        $docFolder = Join-Path $ProjectPath 'Documentation'
        if (Test-Path -LiteralPath $docFolder) {
            $stale = @(Get-ChildItem -Path $docFolder -File -ErrorAction SilentlyContinue |
                Where-Object { $_.Name -like 'InstallationChanges_*.json' -or $_.Name -like 'Targeted_Documentation_Log_*.txt' })
            foreach ($f in $stale) {
                try { Remove-Item -LiteralPath $f.FullName -Force }
                catch { Write-Warning "Could not remove stale capture '$($f.Name)': $($_.Exception.Message)" }
            }
            if ($stale.Count -gt 0) {
                Write-Verbose "Cleared $($stale.Count) stale capture file(s) from Documentation\"
            }
        }

        # Also clear a stale captured icon, for the same reason: if THIS run extracts no icon, the finalize
        # reconcile must not promote the previous run's leftover AppIcon_Captured.png.
        $staleIcon = Join-Path $ProjectPath 'Sandbox\Logs\AppIcon_Captured.png'
        if (Test-Path -LiteralPath $staleIcon) {
            try { Remove-Item -LiteralPath $staleIcon -Force }
            catch { Write-Warning "Could not remove stale captured icon: $($_.Exception.Message)" }
        }

        # Hyper-V: the generated script + log collector are prepared and stale captures cleared. The
        # Hyper-V provider copies the project into the VM, runs the script over PowerShell Direct, and
        # copies the capture back — no .wsb build, feature probe, or WindowsSandbox.exe launch.
        if ($Backend -eq 'HyperV') {
            Write-Host '✓ Documentation prepared for the Hyper-V backend (the provider runs it in the VM).' -ForegroundColor Green
            return $expectedJson
        }

        # Check if Windows Sandbox is available. The DISM probe costs 2-10 s, and the answer cannot change
        # while this process runs (enabling the feature needs a reboot) — so probe ONCE per process and
        # reuse the verdict. Deliberately a cache, not a cheaper Get-Command check: the probe's value is
        # the feature-DISABLED advisory (WindowsSandbox.exe can exist with the feature off).
        if ($null -eq $script:SandboxFeatureEnabled) {
            try {
                $sandboxFeature = Get-WindowsOptionalFeature -Online -FeatureName "Containers-DisposableClientVM" -ErrorAction SilentlyContinue
                $script:SandboxFeatureEnabled = if ($sandboxFeature) { $sandboxFeature.State -eq 'Enabled' } else { $null }
                if ($null -eq $script:SandboxFeatureEnabled) {
                    Write-Warning "Unable to check Windows Sandbox feature status. Windows Sandbox may not be available on this system."
                    $script:SandboxFeatureEnabled = $true   # unknown -> don't re-probe and don't nag; launch failure still warns
                }
            } catch {
                Write-Warning "Unable to check Windows Sandbox feature status. Windows Sandbox may not be available on this system."
                $script:SandboxFeatureEnabled = $true
            }
        }
        if ($script:SandboxFeatureEnabled -eq $false) {
            Write-Warning "Windows Sandbox feature is not enabled. Please enable it in Windows Features."
            Write-Warning "To enable: Control Panel > Programs > Turn Windows features on or off > Windows Sandbox"
        }

        Write-Host "`nTargeted Documentation Setup Complete!" -ForegroundColor Cyan
        Write-Host "===============================================" -ForegroundColor Cyan
        Write-Host "Files created:" -ForegroundColor White
        Write-Host "• Sandbox Config: $sandboxConfigFile" -ForegroundColor Green
        Write-Host "• Documentation Script: $docScriptPath" -ForegroundColor Green
        
        # Launch Windows Sandbox automatically
        Write-Host "`nLaunching Windows Sandbox for targeted documentation..." -ForegroundColor Yellow
        Write-Host "===============================================" -ForegroundColor Cyan
        Write-Host "The sandbox will:" -ForegroundColor White
        Write-Host "• Scan targeted directories (Program Files, ProgramData, AppData)" -ForegroundColor Green
        Write-Host "• Monitor key registry locations (Uninstall, App Paths)" -ForegroundColor Green
        Write-Host "• Capture services baseline (programs derived from the registry diff)" -ForegroundColor Green
        Write-Host "• Run your PSADT installation" -ForegroundColor Green
        Write-Host "• Compare before/after states to identify changes" -ForegroundColor Green
        Write-Host "• Generate detailed change reports" -ForegroundColor Green
        Write-Host "• Much faster than full system scans (2-5 minutes vs 10-30!)" -ForegroundColor Green
        Write-Host "===============================================" -ForegroundColor Cyan

        if ($SkipLaunch) {
            Write-Host '(Sandbox launch skipped — test mode.)' -ForegroundColor DarkYellow
            return $expectedJson
        }

        # Stale captures were just cleared, so nothing will satisfy the wait until a capture from THIS
        # run appears — if the operator doesn't launch manually, the wait times out (30 min).
        if ((Invoke-Win32ToolkitTestRun -Backend Sandbox -SandboxConfigPath $sandboxConfigFile).Launched) {
            Write-Host "✓ Windows Sandbox launched successfully!" -ForegroundColor Green
            Write-Host "`nThe targeted documentation will be available in the sandbox at:" -ForegroundColor Cyan
            Write-Host "C:\PSADT\Documentation" -ForegroundColor White
        } else {
            Write-Warning "The sandbox did NOT auto-launch — start it manually by double-clicking:"
            Write-Warning " $sandboxConfigFile"
            Write-Warning 'Otherwise the documentation wait will time out after 30 minutes.'
        }

        return $expectedJson
    }
    catch {
        Write-Warning "Failed to create targeted documentation: $($_.Exception.Message)"
        return $false
    }
}