public/Invoke-OctaDiskCleanup.ps1

function Measure-OctaPathBytes {
    <#
        Sums the real on-disk size of everything under the given paths. File-scoped (not nested
        inside Get-OctaCleanupTargets as it once was) so Invoke-OctaDiskCleanup can re-run the
        exact same measurement after deleting, to report what was actually freed instead of
        assuming the pre-scan number.
    #>

    param([string[]]$Paths)
    $total = 0L
    foreach ($p in $Paths) {
        $items = Get-ChildItem -Path $p -Recurse -Force -ErrorAction SilentlyContinue
        $total += ($items | Measure-Object -Property Length -Sum -ErrorAction SilentlyContinue).Sum
    }
    return $total
}

function Get-OctaRecycleBinPaths {
    <#
        Every fixed drive's hidden $Recycle.Bin folder (one per volume, each with a per-user SID
        subfolder inside it) - the real on-disk locations backing the single logical "Recycle
        Bin" view Explorer/Shell.Application show as one merged list across all drives.
 
        ponytail: measuring via Shell.Application's Namespace(10).Items() `.Size` property
        (the original approach) is a real, reproduced bug, not a hypothetical one: that COM
        property returns a signed 32-bit integer, and any single item over 2 GB - a Windows ISO,
        a large game install archive, anything - overflows it negative. Measured on a real
        machine: a Recycle Bin containing a 4 GB ISO and several 1+ GB files summed to
        -1,052,385,010 bytes, which then propagated into the overall "Total reclaimable"
        figure shown to the user. Direct filesystem measurement uses .NET's real Int64 file
        Length, the same mechanism already used for every other cleanup target, so it has no
        equivalent overflow ceiling for any file size realistic to end up in a Recycle Bin.
    #>

    $paths = @()
    foreach ($drive in @(Get-CimInstance -ClassName Win32_LogicalDisk -Filter 'DriveType=3' -ErrorAction SilentlyContinue)) {
        $candidate = Join-Path $drive.DeviceID '$Recycle.Bin'
        if (Test-Path -LiteralPath $candidate) { $paths += "$candidate\*" }
    }
    return $paths
}

function Measure-OctaRecycleBinBytes {
    <# Real on-disk Recycle Bin size across every fixed drive - see Get-OctaRecycleBinPaths for
       why this replaces the overflow-prone Shell.Application COM property. #>

    $paths = @(Get-OctaRecycleBinPaths)
    if ($paths.Count -eq 0) { return 0L }
    return Measure-OctaPathBytes -Paths $paths
}

function Get-OctaCleanupTargets {
    <#
        .SYNOPSIS
        Measures real, current reclaimable bytes per target (FR-045). Never caches/estimates.
    #>

    [CmdletBinding()]
    param()

    $targets = @()

    $targets += [pscustomobject]@{
        Name = 'User temp files'; Paths = @("$env:TEMP\*")
        MeasuredBytes = (Measure-OctaPathBytes -Paths @("$env:TEMP\*"))
        RiskLevel = 'Safe'; Reversible = $false
        IrreversibleReason = 'Deleted temp files are not recoverable.'
    }
    $targets += [pscustomobject]@{
        Name = 'System temp files'; Paths = @("$env:WINDIR\Temp\*")
        MeasuredBytes = (Measure-OctaPathBytes -Paths @("$env:WINDIR\Temp\*"))
        RiskLevel = 'Safe'; Reversible = $false
        IrreversibleReason = 'Deleted temp files are not recoverable.'
    }
    $targets += [pscustomobject]@{
        Name = 'Prefetch'; Paths = @("$env:WINDIR\Prefetch\*.pf")
        MeasuredBytes = (Measure-OctaPathBytes -Paths @("$env:WINDIR\Prefetch\*.pf"))
        RiskLevel = 'Safe'; Reversible = $false
        IrreversibleReason = 'Windows regenerates prefetch data as needed; deleted files are not restored.'
    }
    $targets += [pscustomobject]@{
        Name = 'Thumbnail cache'; Paths = @("$env:LOCALAPPDATA\Microsoft\Windows\Explorer\thumbcache_*.db")
        MeasuredBytes = (Measure-OctaPathBytes -Paths @("$env:LOCALAPPDATA\Microsoft\Windows\Explorer\thumbcache_*.db"))
        RiskLevel = 'Safe'; Reversible = $false
        IrreversibleReason = 'Thumbnails are regenerated on demand; deleted cache files are not restored.'
    }
    $targets += [pscustomobject]@{
        Name = 'Windows Update leftovers'; Paths = @("$env:WINDIR\SoftwareDistribution\Download\*")
        MeasuredBytes = (Measure-OctaPathBytes -Paths @("$env:WINDIR\SoftwareDistribution\Download\*"))
        RiskLevel = 'Safe'; Reversible = $false
        IrreversibleReason = 'Windows re-downloads update files if needed again.'
    }
    $targets += [pscustomobject]@{
        Name = 'Crash dumps'; Paths = @("$env:WINDIR\Minidump\*", "$env:WINDIR\memory.dmp")
        MeasuredBytes = (Measure-OctaPathBytes -Paths @("$env:WINDIR\Minidump\*", "$env:WINDIR\memory.dmp"))
        RiskLevel = 'Safe'; Reversible = $false
        IrreversibleReason = 'Crash dump files are diagnostic-only; deleting them loses that diagnostic history.'
    }

    $targets += [pscustomobject]@{
        Name = 'Recycle Bin'; Paths = @('(Recycle Bin)')
        MeasuredBytes = (Measure-OctaRecycleBinBytes)
        RiskLevel = 'Safe'; Reversible = $false
        IrreversibleReason = 'Emptying the Recycle Bin permanently deletes its contents.'
    }

    # 007: browser caches - each measured only if that browser is actually installed, never
    # shown as zero for an absent one.
    $browserCaches = @(
        @{ Name = 'Edge cache'; Path = "$env:LOCALAPPDATA\Microsoft\Edge\User Data\Default\Cache" },
        @{ Name = 'Chrome cache'; Path = "$env:LOCALAPPDATA\Google\Chrome\User Data\Default\Cache" },
        @{ Name = 'Firefox cache'; Path = "$env:APPDATA\Mozilla\Firefox\Profiles" }
    )
    foreach ($bc in $browserCaches) {
        if ($bc.Name -eq 'Firefox cache') {
            $profileDirs = @(Get-ChildItem -Path $bc.Path -Directory -Filter '*.default*' -ErrorAction SilentlyContinue)
            if ($profileDirs.Count -eq 0) { continue }
            $cachePaths = $profileDirs | ForEach-Object { Join-Path $_.FullName 'cache2\*' }
        }
        else {
            if (-not (Test-Path $bc.Path)) { continue }
            $cachePaths = @("$($bc.Path)\*")
        }
        $targets += [pscustomobject]@{
            Name = $bc.Name; Paths = $cachePaths
            MeasuredBytes = (Measure-OctaPathBytes -Paths $cachePaths)
            RiskLevel = 'Safe'; Reversible = $false
            IrreversibleReason = 'Browser caches are regenerated as needed; deleted cache files are not restored.'
        }
    }

    # 009/Kudu parity: gaming platform caches - each independently existence-gated, same pattern
    # as browser caches above (never shown for a platform that isn't installed). Paths verified
    # against public vendor documentation (research.md); Steam/Epic not live-tested on the dev
    # machine (neither installed there) - closed out via VM validation instead.
    $steamInstallPath = (Get-ItemProperty -Path 'HKLM:\SOFTWARE\WOW6432Node\Valve\Steam' -ErrorAction SilentlyContinue).InstallPath
    $gamingCaches = @(
        @{ Name = 'Steam shader cache'; Path = if ($steamInstallPath) { Join-Path $steamInstallPath 'steamapps\shadercache' } else { $null } },
        @{ Name = 'Epic Games Launcher cache'; Path = "$env:LOCALAPPDATA\EpicGamesLauncher\Saved\webcache" },
        @{ Name = 'NVIDIA DirectX shader cache'; Path = "$env:LOCALAPPDATA\NVIDIA\DXCache" },
        @{ Name = 'NVIDIA OpenGL shader cache'; Path = "$env:LOCALAPPDATA\NVIDIA\GLCache" }
    )
    foreach ($gc in $gamingCaches) {
        if (-not $gc.Path -or -not (Test-Path -LiteralPath $gc.Path)) { continue }
        $targets += [pscustomobject]@{
            Name = $gc.Name; Paths = @("$($gc.Path)\*")
            MeasuredBytes = (Measure-OctaPathBytes -Paths @("$($gc.Path)\*"))
            RiskLevel = 'Safe'; Reversible = $false
            IrreversibleReason = 'Shader/launcher caches are regenerated as needed; deleted cache files are not restored.'
        }
    }

    $windowsOldPath = "$env:SystemDrive\Windows.old"
    if (Test-Path $windowsOldPath) {
        $targets += [pscustomobject]@{
            Name = 'Old Windows installation (Windows.old)'; Paths = @($windowsOldPath)
            MeasuredBytes = (Measure-OctaPathBytes -Paths @("$windowsOldPath\*"))
            RiskLevel = 'Risky'; Reversible = $false
            IrreversibleReason = 'Removing this forfeits the ability to roll back a Windows feature upgrade.'
        }
    }

    return $targets
}

function Clear-OctaFileSecurely {
    <#
        .SYNOPSIS
        Overwrites a file's content with random bytes, streamed in bounded-size chunks (never
        loads the whole file into memory), before the caller deletes it normally. 007 US3.
    #>

    [CmdletBinding()]
    param([Parameter(Mandatory)][string]$Path)

    try {
        $stream = [System.IO.File]::Open($Path, [System.IO.FileMode]::Open, [System.IO.FileAccess]::Write)
        try {
            $rng = [System.Security.Cryptography.RandomNumberGenerator]::Create()
            $buffer = New-Object byte[] 65536
            $remaining = $stream.Length
            $stream.Position = 0
            while ($remaining -gt 0) {
                $chunk = [Math]::Min($buffer.Length, $remaining)
                $rng.GetBytes($buffer, 0, $chunk)
                $stream.Write($buffer, 0, $chunk)
                $remaining -= $chunk
            }
            $stream.Flush()
        }
        finally {
            $stream.Dispose()
        }
    }
    catch {
        # Locked/inaccessible file - left for the normal Remove-Item pass to skip/report.
    }
}

function Invoke-OctaDiskCleanup {
    <#
        .SYNOPSIS
        Dry-run and (optionally) apply disk cleanup across known, standard Windows locations.
        FR-045/FR-046. Attempts a System Restore Point before deleting anything, even though
        the deletions themselves are not undo-able the way a registry action is.
 
        .PARAMETER SecureDelete
        007 US3: overwrites each file's content before deleting it, instead of a normal delete
        that leaves recoverable data on disk. Slower - discloses this before the user confirms.
    #>

    [CmdletBinding()]
    param(
        [switch]$Apply,
        [switch]$Yes,
        [switch]$SecureDelete,
        [switch]$NoRestorePoint
    )

    $targets = Get-OctaCleanupTargets
    $totalBytes = ($targets | Measure-Object -Property MeasuredBytes -Sum).Sum

    Write-Host 'Disk Cleanup targets (all irreversible except where noted):'
    foreach ($t in $targets) {
        $gb = [Math]::Round($t.MeasuredBytes / 1GB, 2)
        Write-Host (" {0,-40} {1,8} GB [{2}]" -f $t.Name, $gb, $t.RiskLevel)
    }
    Write-Host ("Total reclaimable: {0} GB" -f [Math]::Round($totalBytes / 1GB, 2))

    if (-not $Apply) {
        return [pscustomobject]@{ Status = 'DryRun'; Targets = $targets; TotalBytes = $totalBytes }
    }

    $safeTargets = @($targets | Where-Object { $_.RiskLevel -ne 'Risky' })
    $riskyTargets = @($targets | Where-Object { $_.RiskLevel -eq 'Risky' })

    if ($SecureDelete) {
        Write-Host 'Secure delete is on: files will be overwritten before removal (slower).'
    }
    if (-not $Yes) {
        $response = Read-Host 'Delete these files? (y/N)'
        if ($response -notin @('y', 'Y', 's', 'S')) {
            return [pscustomobject]@{ Status = 'Cancelled' }
        }
    }

    $applyTargets = $safeTargets
    if ($riskyTargets.Count -gt 0) {
        Write-Host 'Risky targets (confirm separately):'
        foreach ($t in $riskyTargets) { Write-Host " $($t.Name)" }
        if ($Yes) {
            $applyTargets += $riskyTargets
        }
        else {
            $riskyResponse = Read-Host 'Delete these too? (y/N)'
            if ($riskyResponse -in @('y', 'Y', 's', 'S')) { $applyTargets += $riskyTargets }
        }
    }

    New-OctaRestorePoint -NoRestorePoint:$NoRestorePoint -Description 'Octa disk cleanup' | Out-Null

    $freedBytes = 0L
    $notFullyFreed = @()
    foreach ($t in $applyTargets) {
        if ($t.Name -eq 'Recycle Bin') {
            Clear-RecycleBin -Force -ErrorAction SilentlyContinue
            # Re-measure the bin the same way Get-OctaCleanupTargets did (Measure-
            # OctaRecycleBinBytes, not the overflow-prone Shell.Application COM property - see
            # its own comment), rather than assuming Clear-RecycleBin emptied it (it silently
            # skips items held by another process).
            $remaining = Measure-OctaRecycleBinBytes
        }
        else {
            foreach ($p in $t.Paths) {
                if ($SecureDelete) {
                    Get-ChildItem -Path $p -Recurse -File -Force -ErrorAction SilentlyContinue | ForEach-Object {
                        Clear-OctaFileSecurely -Path $_.FullName
                    }
                }
                Remove-Item -Path $p -Recurse -Force -ErrorAction SilentlyContinue
            }
            $remaining = Measure-OctaPathBytes -Paths $t.Paths
        }

        # ponytail: report what was ACTUALLY freed (measured before minus measured after), never
        # the pre-scan measurement on its own. Remove-Item runs with -ErrorAction
        # SilentlyContinue because a locked file is normal here, not exceptional - but that means
        # skipped files left the old code reporting their bytes as reclaimed anyway. Verified
        # real: a 1 MB file held open by another process survived the delete, and Octa still
        # reported the full 1 MB as freed. Overstating a measured number is exactly what this
        # project's own README promises it does not do ("real y medido (no estimado)").
        $actuallyFreed = [Math]::Max(0L, [int64]$t.MeasuredBytes - [int64]$remaining)
        $freedBytes += $actuallyFreed
        if ($remaining -gt 0) {
            $notFullyFreed += [pscustomobject]@{ Name = $t.Name; RemainingBytes = [int64]$remaining }
        }
    }

    foreach ($n in $notFullyFreed) {
        Write-Host (" Not fully cleared (files in use): {0} - {1} GB still present" -f $n.Name, [Math]::Round($n.RemainingBytes / 1GB, 2))
    }

    return [pscustomobject]@{ Status = 'Success'; FreedBytes = $freedBytes; NotFullyFreed = $notFullyFreed }
}