Private/Invoke-SecureUninstall.ps1

#Requires -Version 5.1

<#
.SYNOPSIS
Performs the removal (uninstaller phase 5).
 
.DESCRIPTION
Every step reports what actually happened rather than what was attempted.
The previous version wrapped native winget calls in try/catch, which never
fires because native commands do not throw, so it logged "[OK] Python
uninstalled" regardless of the outcome.
#>


function Invoke-SecureUninstall {
    [OutputType([hashtable])]
    param(
        [string]$BotPath = (Join-Path $env:LOCALAPPDATA "BATCRelayBot"),
        [hashtable]$DependencyChoices = @{},

        # Overridable so the test suite does not deposit a timestamped log in
        # the developer's real roaming profile on every run - which is how
        # ninety-odd stray logs accumulated there.
        [string]$LogDirectory = (Join-Path $env:APPDATA "BATCRelayBot-Uninstall")
    )

    # The log lives outside the installation so it survives the deletion.
    $logDir = $LogDirectory
    New-Item -ItemType Directory -Path $logDir -Force -ErrorAction SilentlyContinue | Out-Null
    $logPath = Join-Path $logDir "removal-$(Get-Date -Format 'yyyyMMdd-HHmmss').log"

    $deletedFiles = @()
    $errors = @()
    $removedDependencies = @()

    @(
        "=== BATCRelayBot removal log ===",
        "Started: $(Get-Date -Format 'yyyy-MM-dd HH:mm:ss')",
        "Target: $BotPath",
        ""
    ) | Out-File $logPath -Force -Encoding UTF8

    Write-Host ""

    # ---- 1: stop the bot ------------------------------------------------
    Write-Host " [1/5] Stopping the bot" -ForegroundColor Gray
    try {
        $stopResult = Stop-BotProcess -BotPath $BotPath -TimeoutSeconds 15

        switch ($stopResult.Method) {
            'not running' {
                Write-Host " Not running." -ForegroundColor Gray
                "[OK] No running bot process" | Add-Content $logPath -Encoding UTF8
            }
            'graceful' {
                Write-Host " Stopped cleanly (left the voice channel)." -ForegroundColor Green
                "[OK] Bot stopped gracefully (PIDs: $($stopResult.ProcessIds -join ', '))" | Add-Content $logPath -Encoding UTF8
            }
            'forced' {
                if ($stopResult.Stopped) {
                    Write-Host " Did not respond in time - terminated." -ForegroundColor Yellow
                    "[WARN] Bot force-terminated (PIDs: $($stopResult.ProcessIds -join ', '))" | Add-Content $logPath -Encoding UTF8
                } else {
                    $message = "Bot process could not be stopped (PIDs: $($stopResult.ProcessIds -join ', '))"
                    $errors += $message
                    "[ERROR] $message" | Add-Content $logPath -Encoding UTF8
                    Write-Host " FAILED - files may stay locked." -ForegroundColor Red
                }
            }
        }
    } catch {
        $message = "Stopping the bot failed: $($_.Exception.Message)"
        $errors += $message
        "[ERROR] $message" | Add-Content $logPath -Encoding UTF8
        Write-Host " WARNING: $($_.Exception.Message)" -ForegroundColor Yellow
    }

    # ---- 2: overwrite and delete config.json ----------------------------
    Write-Host " [2/5] Removing config.json" -ForegroundColor Gray
    $configPath = Join-Path $BotPath "config.json"
    if (Test-Path $configPath) {
        try {
            # Overwrite before deleting. On an SSD this does not guarantee the
            # old bytes are unreachable - wear levelling may keep them - so it
            # is described as overwriting, not as secure erasure, and the user
            # is told to reset the token instead.
            $length = (Get-Item $configPath).Length
            if ($length -gt 0) {
                $buffer = New-Object byte[] $length
                $random = New-Object System.Random
                for ($pass = 0; $pass -lt 3; $pass++) {
                    $random.NextBytes($buffer)
                    [System.IO.File]::WriteAllBytes($configPath, $buffer)
                }
            }
            Remove-Item $configPath -Force -ErrorAction Stop
            $deletedFiles += "config.json"
            "[OK] config.json overwritten (3 passes) and deleted" | Add-Content $logPath -Encoding UTF8
            Write-Host " Overwritten and deleted." -ForegroundColor Green
        } catch {
            $message = "config.json could not be removed: $($_.Exception.Message)"
            $errors += $message
            "[ERROR] $message" | Add-Content $logPath -Encoding UTF8
            Write-Host " FAILED: $($_.Exception.Message)" -ForegroundColor Red
        }
    } else {
        Write-Host " Not present." -ForegroundColor Gray
    }

    # ---- 3: remove the installation directory ---------------------------
    Write-Host " [3/5] Removing the installation directory" -ForegroundColor Gray
    if (Test-Path $BotPath) {
        try {
            # Enumerate before deleting so the log and the summary can name
            # what went, not just how many.
            $doomed = @(Get-ChildItem -Path $BotPath -Recurse -File -ErrorAction SilentlyContinue |
                Select-Object -ExpandProperty Name)

            Remove-Item $BotPath -Recurse -Force -ErrorAction Stop

            foreach ($name in $doomed) {
                if ($deletedFiles -notcontains $name) { $deletedFiles += $name }
            }
            "[OK] Removed $BotPath ($($doomed.Count) files)" | Add-Content $logPath -Encoding UTF8
            Write-Host " Removed ($($doomed.Count) files)." -ForegroundColor Green
        } catch {
            $message = "Could not remove $($BotPath): $($_.Exception.Message)"
            $errors += $message
            "[ERROR] $message" | Add-Content $logPath -Encoding UTF8
            Write-Host " FAILED: $($_.Exception.Message)" -ForegroundColor Red
        }
    } else {
        Write-Host " Already gone." -ForegroundColor Gray
    }

    # ---- 4: roaming AppData leftovers -----------------------------------
    Write-Host " [4/5] Cleaning up leftovers" -ForegroundColor Gray
    $appDataPath = Join-Path $env:APPDATA "BATCRelayBot"
    if (Test-Path $appDataPath) {
        try {
            Remove-Item $appDataPath -Recurse -Force -ErrorAction Stop
            "[OK] Removed $appDataPath" | Add-Content $logPath -Encoding UTF8
            Write-Host " Removed $appDataPath" -ForegroundColor Green
        } catch {
            "[WARN] Could not remove $($appDataPath): $($_.Exception.Message)" | Add-Content $logPath -Encoding UTF8
            Write-Host " WARNING: could not remove $appDataPath" -ForegroundColor Yellow
        }
    } else {
        Write-Host " Nothing to clean up." -ForegroundColor Gray
    }

    # ---- 5: optional components -----------------------------------------
    Write-Host " [5/5] Optional components" -ForegroundColor Gray

    if ($DependencyChoices.RemovePython) {
        foreach ($package in @($DependencyChoices.PythonPackages)) {
            $removedDependencies += (Remove-OptionalPackage -Package $package -Label "Python" `
                -LogPath $logPath -ErrorList ([ref]$errors))
        }
    }

    if ($DependencyChoices.RemoveFFmpeg) {
        foreach ($package in @($DependencyChoices.FFmpegPackages)) {
            $removedDependencies += (Remove-OptionalPackage -Package $package -Label "FFmpeg" `
                -LogPath $logPath -ErrorList ([ref]$errors))
        }
    }

    if ($DependencyChoices.RemoveModule) {
        Write-Host " Removing the PowerShell module..." -ForegroundColor Gray
        try {
            Uninstall-Module BATCRelayBot -AllVersions -Force -ErrorAction Stop
            $removedDependencies += "BATCRelayBot module"
            "[OK] PowerShell module removed" | Add-Content $logPath -Encoding UTF8
            Write-Host " Removed the PowerShell module." -ForegroundColor Green
        } catch {
            $message = "PowerShell module could not be removed: $($_.Exception.Message)"
            $errors += $message
            "[ERROR] $message" | Add-Content $logPath -Encoding UTF8
            Write-Host " FAILED: $($_.Exception.Message)" -ForegroundColor Red
        }
    }

    $removedDependencies = @($removedDependencies | Where-Object { $_ })
    if ($removedDependencies.Count -eq 0) {
        Write-Host " Nothing selected." -ForegroundColor Gray
    }

    $success = (-not (Test-Path $BotPath)) -and ($errors.Count -eq 0)

    @(
        "",
        "Finished: $(Get-Date -Format 'yyyy-MM-dd HH:mm:ss')",
        "Success: $success",
        "Errors: $($errors.Count)"
    ) | Add-Content $logPath -Encoding UTF8

    return @{
        Success             = $success
        DeletedFiles        = $deletedFiles
        RemovedDependencies = $removedDependencies
        Errors              = $errors
        LogPath             = $logPath
    }
}

function Remove-OptionalPackage {
    <#
    .SYNOPSIS
    Removes one winget package and reports the verified outcome.
 
    .OUTPUTS
    A description of what was removed, or $null when it was not.
    #>

    [OutputType([string])]
    param(
        [hashtable]$Package,
        [string]$Label,
        [string]$LogPath,
        [ref]$ErrorList
    )

    if (-not $Package -or -not $Package.Id) { return $null }

    Write-Host " Removing $Label ($($Package.Id))..." -ForegroundColor Gray
    $result = Uninstall-WingetPackage -Id $Package.Id -LogPath $LogPath

    if ($result.Removed) {
        "[OK] $Label removed: $($Package.Id)" | Add-Content $LogPath -Encoding UTF8
        Write-Host " Removed $Label." -ForegroundColor Green
        return "$Label ($($Package.Id))"
    }

    $message = "$Label ($($Package.Id)) was not removed: $($result.Detail)"
    $ErrorList.Value += $message
    "[ERROR] $message" | Add-Content $LogPath -Encoding UTF8
    Write-Host " FAILED: $($result.Detail)" -ForegroundColor Red
    Write-Host " Remove it manually with: winget uninstall --id $($Package.Id) --exact" -ForegroundColor Yellow
    return $null
}

Export-ModuleMember -Function @('Invoke-SecureUninstall', 'Remove-OptionalPackage')