Remove-OrphanedAppxFolders.ps1

<#
.SYNOPSIS
    Deletes the orphaned AppX folders you marked in the scanner's CSV.
 
.DESCRIPTION
    Reads the CSV made by Find-OrphanedAppxFolders.ps1 (-Csv) and deletes
    only the rows where the Delete column is Yes (also accepts Y, True, 1, X).
 
    Each marked folder is checked again before anything is deleted. A folder
    is skipped if any check fails:
      - It must be directly inside %LOCALAPPDATA%\Packages of the current user.
      - The FolderName and FullPath columns must match.
      - It must still exist and must not be a link or junction.
      - It must not be registered to an installed app right now.
      - It must not match an exclude pattern.
      - It must not be a Windows component (_cw5n1h2txyewy or
        windows_ie_ac_*), unless -IncludeWindowsComponents.
      - It must be package-named (Name_publisherid), unless -IncludeOtherFolders.
      - It must not contain any links or junctions.
      - Nothing in it may have changed in the last -MinAgeDays days.
 
    Folders go to the Recycle Bin by default, so they can be restored.
    You are asked to confirm each folder unless you use -Force.
    Every action is written to a log CSV.
 
.PARAMETER CsvPath
    The CSV with your Delete marks. If not given, the newest of these is
    used: Orphaned-Appx-Packages.csv (made by the scanner, marked in Excel)
    and WinPkgScan-marked*.csv (saved from the HTML report), looked for on
    your Desktop and in your Downloads folder.
 
.PARAMETER SearchFolder
    Folders to look in when -CsvPath is not given. Defaults to your Desktop
    and Downloads folders.
 
.PARAMETER AdminChecks
    Auto (default): when running as administrator, also skip folders whose
    app Windows still knows for you in an unfinished state (staged or
    pending). On: always try. Off: never.
 
.PARAMETER Permanent
    Delete permanently instead of moving to the Recycle Bin.
 
.PARAMETER MinAgeDays
    Skip folders with anything modified in the last N days. Default 30.
    Use 0 to turn this check off.
 
.PARAMETER IncludeOtherFolders
    Also allow folders that are not package-named (the scanner's
    "Other folders"). These are often used by Windows itself.
 
.PARAMETER IncludeWindowsComponents
    Also allow Windows component folders (publisher _cw5n1h2txyewy, or
    windows_ie_ac_*). Not recommended: these belong to Windows.
 
.PARAMETER Exclude
    Folder names never to delete. Wildcards allowed.
 
.PARAMETER ExcludeFile
    A text file with one exclude pattern per line (# starts a comment).
    If not given, two files are read: WinPkgScan.exclude.txt next to this
    script, and your own %LOCALAPPDATA%\WinPkgScan\exclude.txt.
 
.PARAMETER LogPath
    Where to save the log CSV. Defaults to a timestamped file next to the
    input CSV.
 
.PARAMETER Force
    Do not ask before each folder (same as -Confirm:$false). Use with care.
 
.PARAMETER NoHistory
    Do not record this run in the scan history
    (%LOCALAPPDATA%\WinPkgScan\history.json), which keeps the total
    space freed with WinPkgScan.
 
.PARAMETER NoPause
    Never pause at the end. The script only pauses when it opened its own
    window (a batch file or "Run with PowerShell"), not when run from an
    open PowerShell prompt.
 
.EXAMPLE
    .\Remove-OrphanedAppxFolders.ps1 -WhatIf
    Shows what would be deleted. Nothing is changed.
 
.EXAMPLE
    .\Remove-OrphanedAppxFolders.ps1
    Moves the marked folders to the Recycle Bin, asking before each one.
 
.EXAMPLE
    .\Remove-OrphanedAppxFolders.ps1 -CsvPath C:\Temp\report.csv -Force -NoPause
    Unattended run: no prompts.
 
.NOTES
    Version: 1.8.0
#>


[CmdletBinding(SupportsShouldProcess = $true, ConfirmImpact = 'High')]
param(
    [Parameter(Position = 0)]
    [string]$CsvPath,

    [string[]]$SearchFolder,

    [ValidateSet('Auto', 'On', 'Off')]
    [string]$AdminChecks = 'Auto',

    [switch]$Permanent,

    [ValidateRange(0, 36500)]
    [int]$MinAgeDays = 30,

    [switch]$IncludeOtherFolders,

    [switch]$IncludeWindowsComponents,

    [string[]]$Exclude,

    [string]$ExcludeFile,

    [string]$LogPath,

    [switch]$Force,

    [switch]$NoHistory,

    [switch]$NoPause
)

$ScriptVersion = "1.8.0"
# Pause at the end only when this script opened its own console window
# (a batch file or "Run with PowerShell"), so you can read the result before
# it closes. Never from an open PowerShell prompt, the module, the app, or
# with redirected input.
$PauseAtEnd = $false
if (-not $NoPause.IsPresent) {
    try {
        $PauseAtEnd = (-not [Console]::IsInputRedirected) -and
            ([Environment]::CommandLine.IndexOf([System.IO.Path]::GetFileName($PSCommandPath), [StringComparison]::OrdinalIgnoreCase) -ge 0)
    }
    catch {
        $PauseAtEnd = $false
    }
}

# -Force skips the per-folder prompt, unless -Confirm was given explicitly.
if ($Force.IsPresent -and -not $PSBoundParameters.ContainsKey('Confirm')) {
    $ConfirmPreference = 'None'
}

function Exit-Script {
    param([int]$Code = 0)
    if ($PauseAtEnd) {
        Write-Host ""
        Read-Host "Press Enter to exit" | Out-Null
    }
    exit $Code
}

function Get-FolderInfo {
    # Total size, newest write time and whether any link or junction is
    # inside, in one pass.
    param([System.IO.DirectoryInfo]$Folder)
    $size     = [long]0
    $newest   = $Folder.LastWriteTime
    $hasLinks = $false
    foreach ($entry in (Get-ChildItem -LiteralPath $Folder.FullName -Recurse -Force -ErrorAction SilentlyContinue)) {
        if ($entry.Attributes -band [System.IO.FileAttributes]::ReparsePoint) { $hasLinks = $true }
        if (-not $entry.PSIsContainer) { $size += $entry.Length }
        if ($entry.LastWriteTime -gt $newest) { $newest = $entry.LastWriteTime }
    }
    [PSCustomObject]@{
        SizeMB   = [math]::Round(($size / 1MB), 2)
        Newest   = $newest
        HasLinks = $hasLinks
    }
}

function Test-MarkedForDelete {
    param([string]$Value)
    if ($null -eq $Value) { return $false }
    return $Value.Trim() -match '^(yes|y|true|1|x)$'
}

function Get-ExcludePattern {
    # Patterns from -Exclude plus the exclude files (one per line, # = comment).
    param([string[]]$FromParameter, [string[]]$Files, [bool]$FileWasGiven)
    $patterns = New-Object System.Collections.Generic.List[string]
    foreach ($p in @($FromParameter)) {
        if (-not [string]::IsNullOrWhiteSpace($p)) { $patterns.Add($p.Trim()) }
    }
    foreach ($file in @($Files)) {
        if ($file -and (Test-Path -LiteralPath $file -PathType Leaf)) {
            foreach ($line in (Get-Content -LiteralPath $file)) {
                $t = $line.Trim()
                if ($t -and -not $t.StartsWith('#')) { $patterns.Add($t) }
            }
        }
        elseif ($FileWasGiven) {
            Write-Host "WARNING: Exclude file not found: $file" -ForegroundColor Yellow
        }
    }
    return , $patterns.ToArray()
}

function Get-MatchingPattern {
    param([string]$Name, [string[]]$Patterns)
    foreach ($p in $Patterns) {
        if ($Name -like $p) { return $p }
    }
    return $null
}

function Test-WindowsComponent {
    param([string]$Name)
    $n = $Name.ToLowerInvariant()
    return $n.EndsWith('_cw5n1h2txyewy') -or $n -like 'windows_ie_ac_*'
}

function Test-IsAdministrator {
    try {
        $identity = [Security.Principal.WindowsIdentity]::GetCurrent()
        return ([Security.Principal.WindowsPrincipal]$identity).IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator)
    }
    catch {
        return $false
    }
}

function Get-CurrentUserSid {
    $sid = $null
    try { $sid = [Security.Principal.WindowsIdentity]::GetCurrent().User.Value } catch { $sid = $null }
    # Only used by the test suite on non-Windows systems, where there is no SID.
    if (-not $sid) { $sid = $env:WINPKGSCAN_TEST_SID }
    return $sid
}

function Get-RegisteredPackage {
    # All package types (main, framework, optional, resource, bundle), so no
    # registered package family is missed. Falls back if the filter is unsupported.
    try {
        return @(Get-AppxPackage -PackageTypeFilter All -ErrorAction Stop)
    }
    catch {
        return @(Get-AppxPackage -ErrorAction Stop)
    }
}

function Get-AdminPackageInfo {
    # Needs administrator rights. Returns:
    # ForMe: families Windows knows for this user in any state
    # (installed, staged, pending), even if not listed above
    # OtherUsers: family -> number of other user accounts that have it
    # Provisioned: families provisioned for new users
    param([string]$Sid)

    $forMe = New-Object 'System.Collections.Generic.HashSet[string]' ([StringComparer]::OrdinalIgnoreCase)
    $other = New-Object 'System.Collections.Generic.Dictionary[string,int]' ([StringComparer]::OrdinalIgnoreCase)
    $prov  = New-Object 'System.Collections.Generic.HashSet[string]' ([StringComparer]::OrdinalIgnoreCase)

    try {
        $all = @(Get-AppxPackage -AllUsers -PackageTypeFilter All -ErrorAction Stop)
    }
    catch {
        $all = @(Get-AppxPackage -AllUsers -ErrorAction Stop)
    }

    foreach ($p in $all) {
        $family = "$($p.PackageFamilyName)"
        if (-not $family) { continue }
        $seen = New-Object 'System.Collections.Generic.HashSet[string]'
        foreach ($u in @($p.PackageUserInformation)) {
            if ($null -eq $u) { continue }
            # Works with live objects and with text from PowerShell 7's
            # Windows PowerShell compatibility layer.
            $text = if ($u.UserSecurityId) {
                if ($u.UserSecurityId.Sid) { "$($u.UserSecurityId.Sid)" } else { "$($u.UserSecurityId)" }
            }
            else { "$u" }
            $m = [regex]::Match($text, 'S-1-[0-9-]+')
            if (-not $m.Success) { continue }
            $userSid = $m.Value
            if ($Sid -and $userSid -eq $Sid) {
                [void]$forMe.Add($family)
            }
            elseif ($userSid.StartsWith('S-1-5-21-') -and $seen.Add($userSid)) {
                if ($other.ContainsKey($family)) { $other[$family] = $other[$family] + 1 } else { $other[$family] = 1 }
            }
        }
    }

    try {
        foreach ($p in @(Get-AppxProvisionedPackage -Online -ErrorAction Stop)) {
            if ($p.DisplayName -and $p.PublisherId) {
                [void]$prov.Add("$($p.DisplayName)_$($p.PublisherId)")
            }
        }
    }
    catch {
        Write-Verbose "Could not read provisioned packages: $($_.Exception.Message)"
    }

    [PSCustomObject]@{
        ForMe       = $forMe
        OtherUsers  = $other
        Provisioned = $prov
    }
}

function Get-DesktopFolder {
    $path = [Environment]::GetFolderPath("Desktop")
    if ([string]::IsNullOrWhiteSpace($path)) { $path = Join-Path $env:USERPROFILE "Desktop" }
    return $path
}

function Get-DownloadsFolder {
    # The Downloads folder can be moved, so ask Windows where it is.
    $key  = 'HKCU:\Software\Microsoft\Windows\CurrentVersion\Explorer\Shell Folders'
    $name = '{374DE290-123F-4565-9164-39C4925E467B}'
    try {
        $value = (Get-ItemProperty -Path $key -Name $name -ErrorAction Stop).$name
        if ($value) { return $value }
    }
    catch {
        Write-Verbose "Downloads folder not found in the registry: $($_.Exception.Message)"
    }
    $base = if ($env:USERPROFILE) { $env:USERPROFILE } else { $HOME }
    return Join-Path $base "Downloads"
}

function Find-NewestFile {
    param([string[]]$Folders, [string[]]$Patterns)
    $found = foreach ($folder in $Folders) {
        if ($folder -and (Test-Path -LiteralPath $folder -PathType Container)) {
            foreach ($pattern in $Patterns) {
                Get-ChildItem -LiteralPath $folder -Filter $pattern -File -ErrorAction SilentlyContinue
            }
        }
    }
    return $found | Sort-Object LastWriteTime -Descending | Select-Object -First 1
}

function Read-CsvFile {
    # Reads a CSV saved by WinPkgScan, a browser or Excel. Detects the text
    # encoding (UTF-8 with or without BOM, UTF-16, or Excel's ANSI code page)
    # and the separator ("," or ";").
    param([string]$Path)
    $bytes = [System.IO.File]::ReadAllBytes($Path)
    if ($bytes.Length -ge 3 -and $bytes[0] -eq 0xEF -and $bytes[1] -eq 0xBB -and $bytes[2] -eq 0xBF) {
        $text = [System.Text.Encoding]::UTF8.GetString($bytes, 3, $bytes.Length - 3)
    }
    elseif ($bytes.Length -ge 2 -and $bytes[0] -eq 0xFF -and $bytes[1] -eq 0xFE) {
        $text = [System.Text.Encoding]::Unicode.GetString($bytes, 2, $bytes.Length - 2)
    }
    else {
        try {
            $text = (New-Object System.Text.UTF8Encoding($false, $true)).GetString($bytes)
        }
        catch {
            $codePage = [System.Globalization.CultureInfo]::CurrentCulture.TextInfo.ANSICodePage
            $text = [System.Text.Encoding]::GetEncoding($codePage).GetString($bytes)
        }
    }
    $rows = @($text | ConvertFrom-Csv)
    if ($rows.Count -gt 0 -and -not ($rows[0].PSObject.Properties.Name -contains 'FullPath')) {
        $rows = @($text | ConvertFrom-Csv -Delimiter ';')
    }
    return , $rows
}

function Get-HistoryPath {
    return Join-Path (Join-Path $env:LOCALAPPDATA 'WinPkgScan') 'history.json'
}

function Read-History {
    # Scan history: past scans (with folder names), removals and restores.
    param([string]$Path)
    $history = [PSCustomObject]@{ Scans = @(); Removals = @(); Restores = @() }
    if (-not (Test-Path -LiteralPath $Path -PathType Leaf)) { return $history }
    try {
        $text = [System.IO.File]::ReadAllText($Path)
        if ($text.Trim()) {
            $data = $text | ConvertFrom-Json -ErrorAction Stop
            if ($data.Scans)    { $history.Scans    = @($data.Scans) }
            if ($data.Removals) { $history.Removals = @($data.Removals) }
            if ($data.Restores) { $history.Restores = @($data.Restores) }
        }
    }
    catch {
        Write-Host "WARNING: The scan history could not be read, so it starts again: $($_.Exception.Message)" -ForegroundColor Yellow
        try {
            Copy-Item -LiteralPath $Path -Destination "$Path.bad" -Force -ErrorAction Stop -WhatIf:$false -Confirm:$false
        }
        catch {
            Write-Verbose "Could not keep a copy of the unreadable history: $($_.Exception.Message)"
        }
    }
    return $history
}

function Save-History {
    param($History, [string]$Path)
    $dir = Split-Path -Path $Path -Parent
    if (-not (Test-Path -LiteralPath $dir)) {
        New-Item -ItemType Directory -Path $dir -Force -WhatIf:$false -Confirm:$false | Out-Null
    }
    $data = [ordered]@{
        Version  = 1
        Scans    = @(@($History.Scans)    | Select-Object -Last 50)
        Removals = @(@($History.Removals) | Select-Object -Last 1000)
        Restores = @(@($History.Restores) | Select-Object -Last 1000)
    }
    $json = ConvertTo-Json -InputObject $data -Depth 6
    [System.IO.File]::WriteAllText($Path, $json, (New-Object System.Text.UTF8Encoding($true)))
}

function Get-FreedMB {
    # Space freed with WinPkgScan: removals minus restores.
    param($History)
    $freed = 0.0
    foreach ($r in @($History.Removals)) { if ($r) { $freed += [double]$r.FreedMB } }
    foreach ($r in @($History.Restores)) { if ($r) { $freed -= [double]$r.SizeMB } }
    if ($freed -lt 0) { $freed = 0 }
    return [math]::Round($freed, 2)
}

$PackagesPath = [System.IO.Path]::GetFullPath((Join-Path $env:LOCALAPPDATA "Packages"))

# Exclude lists: the one next to the scripts, and your own one (the app's
# Settings edit it). -ExcludeFile uses only the file given.
if ([string]::IsNullOrWhiteSpace($ExcludeFile)) {
    $ExcludeFiles = @(
        (Join-Path $PSScriptRoot "WinPkgScan.exclude.txt")
        (Join-Path (Join-Path $env:LOCALAPPDATA 'WinPkgScan') 'exclude.txt')
    )
    $ExcludeFileGiven = $false
}
else {
    $ExcludeFiles = @($ExcludeFile)
    $ExcludeFileGiven = $true
}
$ExcludePatterns = Get-ExcludePattern -FromParameter $Exclude -Files $ExcludeFiles -FileWasGiven $ExcludeFileGiven

# Package folders end in "_" + a 13-character publisher ID.
$PackageNamePattern = '^.+_[0-9a-hjkmnp-tv-z]{13}$'

$CsvPicked = $false
if ([string]::IsNullOrWhiteSpace($CsvPath)) {
    if (-not $SearchFolder) {
        $SearchFolder = @((Get-DesktopFolder), (Get-DownloadsFolder)) | Select-Object -Unique
    }
    $newest = Find-NewestFile -Folders $SearchFolder -Patterns 'Orphaned-Appx-Packages.csv', 'WinPkgScan-marked*.csv'
    if ($newest) {
        $CsvPath   = $newest.FullName
        $CsvPicked = $true
    }
    else {
        $CsvPath = Join-Path $SearchFolder[0] "Orphaned-Appx-Packages.csv"
    }
}

$Mode = if ($Permanent) { "PERMANENT DELETE" } else { "Move to Recycle Bin" }

Write-Host ""
Write-Host "=============================================" -ForegroundColor Cyan
Write-Host " AppData\Local\Packages Orphan Remover v$ScriptVersion" -ForegroundColor Cyan
Write-Host "=============================================" -ForegroundColor Cyan
Write-Host ""
Write-Host "CSV: $CsvPath"
if ($CsvPicked) {
    Write-Host " (newest marked file found; use -CsvPath to choose another)" -ForegroundColor Gray
}
Write-Host "Mode: $Mode" -ForegroundColor $(if ($Permanent) { 'Red' } else { 'White' })
Write-Host "Skip if newer: $(if ($MinAgeDays -gt 0) { "$MinAgeDays days" } else { 'off' })"
Write-Host "Other folders: $(if ($IncludeOtherFolders) { 'allowed' } else { 'skipped' })"
Write-Host "Windows parts: $(if ($IncludeWindowsComponents) { 'ALLOWED' } else { 'skipped' })" -ForegroundColor $(if ($IncludeWindowsComponents) { 'Red' } else { 'White' })
Write-Host "Exclusions: $(if ($ExcludePatterns.Count -gt 0) { $ExcludePatterns -join ', ' } else { 'none' })"
if ($WhatIfPreference) {
    Write-Host "WhatIf: on (nothing will be deleted)" -ForegroundColor Yellow
}
Write-Host ""

# ------------------------------------------------------------
# Read the CSV
# ------------------------------------------------------------

if (-not (Test-Path -LiteralPath $CsvPath -PathType Leaf)) {
    Write-Host "ERROR: CSV file was not found." -ForegroundColor Red
    Write-Host "Scan first (Run-Scan.bat), then mark folders in the HTML report and"
    Write-Host "click Save marked CSV, or type Yes in the Delete column in Excel."
    Exit-Script 1
}

try {
    $Rows = Read-CsvFile -Path $CsvPath
}
catch {
    Write-Host "ERROR: Could not read the CSV file." -ForegroundColor Red
    Write-Host $_.Exception.Message
    Write-Host "If it is open in Excel, close it and try again."
    Exit-Script 1
}

# A scan that found nothing gives a CSV with no rows (or an empty file).
if ($Rows.Count -eq 0) {
    Write-Host "The CSV has no folders in it. Nothing to do." -ForegroundColor Green
    Exit-Script 0
}

$Columns = @($Rows[0].PSObject.Properties.Name)
foreach ($required in 'FolderName', 'FullPath', 'Delete') {
    if ($Columns -notcontains $required) {
        Write-Host "ERROR: The CSV has no '$required' column." -ForegroundColor Red
        if ($required -eq 'Delete') {
            Write-Host "Add a column named Delete and type Yes on each row to remove."
        }
        else {
            Write-Host "Use the CSV made by Find-OrphanedAppxFolders.ps1 -Csv."
        }
        Exit-Script 1
    }
}

$Marked = @($Rows | Where-Object { Test-MarkedForDelete $_.Delete })
Write-Host "Rows in CSV: $($Rows.Count)"
Write-Host "Rows marked Delete: $($Marked.Count)"
Write-Host ""

if ($Marked.Count -eq 0) {
    Write-Host "Nothing to do. Type Yes in the Delete column for each folder to remove." -ForegroundColor Green
    Exit-Script 0
}

# ------------------------------------------------------------
# Get currently registered AppX packages
# ------------------------------------------------------------

Write-Host "Reading installed AppX packages..." -ForegroundColor Yellow
try {
    if ($PSVersionTable.PSVersion.Major -ge 7) {
        Import-Module Appx -UseWindowsPowerShell -ErrorAction Stop -WarningAction SilentlyContinue
    }
    $InstalledPackages = Get-RegisteredPackage
}
catch {
    Write-Host "ERROR: Could not read installed AppX packages." -ForegroundColor Red
    Write-Host $_.Exception.Message
    Write-Host "Nothing was deleted." -ForegroundColor Green
    Exit-Script 1
}

$InstalledFamilyNames = New-Object 'System.Collections.Generic.HashSet[string]' ([StringComparer]::OrdinalIgnoreCase)
foreach ($Package in $InstalledPackages) {
    if (-not [string]::IsNullOrWhiteSpace($Package.PackageFamilyName)) {
        [void]$InstalledFamilyNames.Add($Package.PackageFamilyName)
    }
}

if ($InstalledFamilyNames.Count -eq 0) {
    Write-Host "ERROR: No registered AppX packages were returned." -ForegroundColor Red
    Write-Host "Nothing was deleted, to avoid removing folders of installed apps." -ForegroundColor Red
    Exit-Script 1
}
Write-Host "Registered packages found: $($InstalledFamilyNames.Count)"

$AdminInfo = $null
$UseAdmin  = ($AdminChecks -eq 'On') -or ($AdminChecks -eq 'Auto' -and (Test-IsAdministrator))
if ($UseAdmin) {
    try {
        $AdminInfo = Get-AdminPackageInfo -Sid (Get-CurrentUserSid)
        Write-Host "Administrator checks: on"
    }
    catch {
        Write-Host "WARNING: Administrator checks failed: $($_.Exception.Message)" -ForegroundColor Yellow
    }
}
Write-Host ""

if (-not $Permanent) {
    Add-Type -AssemblyName Microsoft.VisualBasic
}

# ------------------------------------------------------------
# Check and delete each marked folder
# ------------------------------------------------------------

$Cutoff = (Get-Date).AddDays(-$MinAgeDays)
$Log    = New-Object System.Collections.Generic.List[object]

foreach ($Row in $Marked) {

    $name   = "$($Row.FolderName)".Trim()
    $raw    = "$($Row.FullPath)".Trim()
    $action = $null
    $reason = ""
    $sizeMB = ""
    $full   = $raw

    # 1. Path must be directly inside this user's Packages folder.
    try {
        $full = [System.IO.Path]::GetFullPath($raw).TrimEnd('\', '/')
    }
    catch {
        $action = "Skipped"; $reason = "Invalid path"
    }

    if (-not $action) {
        $parent = [System.IO.Path]::GetDirectoryName($full)
        $leaf   = [System.IO.Path]::GetFileName($full)
        if ([string]::IsNullOrWhiteSpace($raw) -or
            -not [string]::Equals($parent, $PackagesPath, [StringComparison]::OrdinalIgnoreCase)) {
            $action = "Skipped"; $reason = "Not directly inside $PackagesPath"
        }
        elseif (-not [string]::Equals($leaf, $name, [StringComparison]::OrdinalIgnoreCase)) {
            $action = "Skipped"; $reason = "FolderName and FullPath do not match"
        }
    }

    # 2. Must still exist, and must not be a link or junction.
    $item = $null
    if (-not $action) {
        $item = Get-Item -LiteralPath $full -Force -ErrorAction SilentlyContinue
        if ($null -eq $item) {
            $action = "Skipped"; $reason = "Folder not found (already removed?)"
        }
        elseif (-not $item.PSIsContainer) {
            $action = "Skipped"; $reason = "Not a folder"
        }
        elseif ($item.Attributes -band [System.IO.FileAttributes]::ReparsePoint) {
            $action = "Skipped"; $reason = "Link or junction (not followed)"
        }
    }

    # 3. Must not belong to an installed app, be excluded, be a Windows
    # component, or (by default) be a non-package folder.
    if (-not $action) {
        $excludedBy = Get-MatchingPattern -Name $item.Name -Patterns $ExcludePatterns
        if ($InstalledFamilyNames.Contains($item.Name)) {
            $action = "Skipped"; $reason = "Registered to an installed app"
        }
        elseif ($AdminInfo -and $AdminInfo.ForMe.Contains($item.Name)) {
            $action = "Skipped"; $reason = "App still known to Windows for you (staged or pending)"
        }
        elseif ($excludedBy) {
            $action = "Skipped"; $reason = "Excluded by pattern '$excludedBy'"
        }
        elseif (-not $IncludeWindowsComponents -and (Test-WindowsComponent $item.Name)) {
            $action = "Skipped"; $reason = "Windows component (keep)"
        }
        elseif (-not $IncludeOtherFolders -and $item.Name.ToLowerInvariant() -notmatch $PackageNamePattern) {
            $action = "Skipped"; $reason = "Not a package-named folder (use -IncludeOtherFolders)"
        }
    }

    # 4. Must not have been modified recently.
    if (-not $action) {
        $info   = Get-FolderInfo -Folder $item
        $sizeMB = $info.SizeMB
        # Windows PowerShell 5.1 can follow a junction inside a folder and
        # delete the target's contents, so never delete such folders.
        if ($info.HasLinks) {
            $action = "Skipped"; $reason = "Contains links or junctions (delete manually)"
        }
        elseif ($MinAgeDays -gt 0 -and $info.Newest -gt $Cutoff) {
            $action = "Skipped"
            $reason = "Modified $($info.Newest.ToString('yyyy-MM-dd')), within $MinAgeDays days"
        }
    }

    # 5. Confirm and delete.
    if (-not $action) {
        $verb = if ($Permanent) { "Permanently delete ($sizeMB MB)" } else { "Move to Recycle Bin ($sizeMB MB)" }
        # ShouldProcess asks the user. It throws if nobody can answer
        # (e.g. a scheduled task), so treat that as "not confirmed".
        $confirmed = $false
        $askFailed = $false
        try {
            $confirmed = $PSCmdlet.ShouldProcess($full, $verb)
        }
        catch {
            $askFailed = $true
        }

        if ($askFailed) {
            $action = "Skipped"; $reason = "Could not ask for confirmation (use -Force for unattended runs)"
        }
        elseif ($confirmed) {
            try {
                if ($Permanent) {
                    Remove-Item -LiteralPath $full -Recurse -Force -ErrorAction Stop -Confirm:$false -WhatIf:$false
                    $action = "Deleted"
                }
                else {
                    [Microsoft.VisualBasic.FileIO.FileSystem]::DeleteDirectory(
                        $full,
                        [Microsoft.VisualBasic.FileIO.UIOption]::OnlyErrorDialogs,
                        [Microsoft.VisualBasic.FileIO.RecycleOption]::SendToRecycleBin,
                        [Microsoft.VisualBasic.FileIO.UICancelOption]::ThrowException)
                    $action = "Recycled"
                }
                if (Test-Path -LiteralPath $full) {
                    $action = "Failed"; $reason = "Folder still exists after delete"
                }
            }
            catch {
                $action = "Failed"; $reason = $_.Exception.Message
            }
        }
        elseif ($WhatIfPreference) {
            $action = "WhatIf"
        }
        else {
            $action = "Skipped"; $reason = "Not confirmed"
        }
    }

    $colour = switch ($action) {
        'Recycled' { 'Green' }
        'Deleted'  { 'Green' }
        'WhatIf'   { 'Yellow' }
        'Failed'   { 'Red' }
        default    { 'Gray' }
    }
    $line = "{0,-9} {1}" -f $action, $name
    if ($reason) { $line += " ($reason)" }
    Write-Host $line -ForegroundColor $colour

    $Log.Add([PSCustomObject]@{
        Time       = (Get-Date).ToString('yyyy-MM-dd HH:mm:ss')
        Action     = $action
        FolderName = $name
        SizeMB     = $sizeMB
        FullPath   = $full
        Reason     = $reason
    })
}

# ------------------------------------------------------------
# Summary and log
# ------------------------------------------------------------

function Get-ActionCount {
    param([string]$Name)
    return @($Log | Where-Object Action -eq $Name).Count
}

$Removed = @($Log | Where-Object { $_.Action -in 'Recycled', 'Deleted' })
$FreedMB = 0
if ($Removed.Count -gt 0) {
    $FreedMB = [math]::Round((($Removed | Measure-Object -Property SizeMB -Sum).Sum), 2)
}
$FailedCount = Get-ActionCount 'Failed'

Write-Host ""
Write-Host "=============================================" -ForegroundColor Cyan
Write-Host " Summary" -ForegroundColor Cyan
Write-Host "=============================================" -ForegroundColor Cyan
Write-Host "Moved to Recycle Bin: $(Get-ActionCount 'Recycled')"
Write-Host "Permanently deleted: $(Get-ActionCount 'Deleted')"
if ($WhatIfPreference) {
    Write-Host "Would be removed: $(Get-ActionCount 'WhatIf')"
}
Write-Host "Skipped: $(Get-ActionCount 'Skipped')"
Write-Host "Failed: $FailedCount"
Write-Host "Space freed: $FreedMB MB"

# Add this run to the scan history (total space freed with WinPkgScan).
if (-not $NoHistory -and -not $WhatIfPreference -and $Removed.Count -gt 0) {
    try {
        $HistoryPath = Get-HistoryPath
        $History = Read-History -Path $HistoryPath
        $History.Removals = @(@($History.Removals) + [PSCustomObject]@{
            Time    = (Get-Date).ToString('yyyy-MM-dd HH:mm')
            Folders = $Removed.Count
            FreedMB = $FreedMB
            Mode    = if ($Permanent) { 'Deleted' } else { 'Recycled' }
        })
        Save-History -History $History -Path $HistoryPath
        Write-Host "Freed with WinPkgScan so far: $(Get-FreedMB $History) MB"
    }
    catch {
        Write-Host "WARNING: Could not update the scan history: $($_.Exception.Message)" -ForegroundColor Yellow
    }
}

if ([string]::IsNullOrWhiteSpace($LogPath)) {
    $csvDir  = Split-Path -Path ([System.IO.Path]::GetFullPath($CsvPath)) -Parent
    $LogPath = Join-Path $csvDir ("WinPkgScan-DeleteLog-{0}.csv" -f (Get-Date -Format 'yyyyMMdd-HHmmss'))
}

try {
    $logDir = Split-Path -Path $LogPath -Parent
    if ($logDir -and -not (Test-Path -LiteralPath $logDir)) {
        New-Item -ItemType Directory -Path $logDir -Force -WhatIf:$false -Confirm:$false | Out-Null
    }
    $Log | Export-Csv -LiteralPath $LogPath -NoTypeInformation -Encoding UTF8 -Force -WhatIf:$false -Confirm:$false -ErrorAction Stop
    Write-Host ""
    Write-Host "Log saved:" -ForegroundColor Green
    Write-Host $LogPath
}
catch {
    Write-Host ""
    Write-Host "WARNING: Could not save the log: $($_.Exception.Message)" -ForegroundColor Yellow
}

if (Get-ActionCount 'Recycled') {
    Write-Host ""
    Write-Host "Removed folders can be restored from the Recycle Bin." -ForegroundColor Green
}

if ($FailedCount -gt 0) {
    Exit-Script 1
}
Exit-Script 0