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 writes its logs into its own sandbox.
        [string]$LogDirectory = $BotPath
    )

    # The log stays in the installation directory, which is emptied rather than
    # deleted. Writing it to a second folder under Roaming meant an uninstall
    # created a directory somewhere else in the profile while removing one here,
    # and left a timestamped file behind on every run.
    $logDir = $LogDirectory
    New-Item -ItemType Directory -Path $logDir -Force -ErrorAction SilentlyContinue | Out-Null
    $logPath = Join-Path $logDir "uninstall.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 installed files -----------------------------------
    Write-Host " [3/5] Removing the installed files" -ForegroundColor Gray
    $removal = Remove-BotContent -BotPath $BotPath -Keep (Split-Path $logPath -Leaf) -LogPath $logPath

    foreach ($name in $removal.Deleted) {
        if ($deletedFiles -notcontains $name) { $deletedFiles += $name }
    }

    if ($removal.Blocked.Count -eq 0) {
        $count = $removal.Deleted.Count
        Write-Host " Removed $count file$(if ($count -ne 1) { 's' })." -ForegroundColor Green
    } else {
        foreach ($item in $removal.Blocked) {
            $message = "Could not remove $($item.Path): $($item.Reason)"
            $errors += $message
            "[ERROR] $message" | Add-Content $logPath -Encoding UTF8
            Write-Host " FAILED: $($item.Path)" -ForegroundColor Red
            Write-Host " $($item.Reason)" -ForegroundColor Red
        }
    }

    # ---- 4: what is left --------------------------------------------------
    Write-Host " [4/5] Checking what is left" -ForegroundColor Gray

    # Identified by name, exactly as Remove-BotContent keeps it. Comparing full
    # paths looked safer and was not: $env:TEMP hands back the 8.3 short form
    # on some machines while Get-ChildItem reports the long one, so the
    # uninstaller failed to recognise its own log and reported it as a
    # leftover - a clean removal calling itself a failure.
    $keepName = Split-Path $logPath -Leaf

    $leftovers = @()
    if (Test-Path $BotPath) {
        $leftovers = @(Get-ChildItem -Path $BotPath -Recurse -File -ErrorAction SilentlyContinue |
            Where-Object { $_.Name -ne $keepName } |
            Select-Object -ExpandProperty FullName)
    }

    # Older versions kept state in the first of these and wrote their removal
    # log into the second, so an uninstall used to create a folder elsewhere in
    # the profile while removing one here.
    foreach ($stale in @((Join-Path $env:APPDATA "BATCRelayBot"),
                         (Join-Path $env:APPDATA "BATCRelayBot-Uninstall"))) {
        if (-not (Test-Path $stale)) { continue }
        try {
            Remove-Item $stale -Recurse -Force -ErrorAction Stop
            "[OK] Removed $stale" | Add-Content $logPath -Encoding UTF8
            Write-Host " Removed $stale" -ForegroundColor Green
        } catch {
            $leftovers += $stale
            "[WARN] Could not remove $($stale): $($_.Exception.Message)" | Add-Content $logPath -Encoding UTF8
        }
    }

    # This step used to look only at Roaming, so it printed "Nothing to clean
    # up" directly underneath a step 3 that had just failed on a locked file.
    # A failure that announces itself as tidy is worse than the failure.
    if ($leftovers.Count -eq 0) {
        Write-Host " Nothing left behind." -ForegroundColor Green
    } else {
        Write-Host " Still present:" -ForegroundColor Yellow
        foreach ($item in $leftovers) {
            Write-Host " $item" -ForegroundColor Yellow
        }
    }

    # ---- 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))
        }
    }

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

    # The directory itself survives, holding this log and nothing else, so
    # "gone" is decided by what is left inside it rather than by its absence.
    $success = ($errors.Count -eq 0) -and ($leftovers.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
        Leftovers           = $leftovers
        Errors              = $errors
        LogPath             = $logPath
    }
}

function Remove-BotContent {
    <#
    .SYNOPSIS
    Empties the installation directory, keeping one file, and reports what
    would not go.
 
    .DESCRIPTION
    A file handle outlives the process that held it by a moment. Stopping the
    bot and deleting immediately afterwards failed on bot_error.log with "used
    by another process" - the bot had exited, Windows had not yet let go. One
    attempt was therefore enough to fail while being nowhere near enough to
    succeed, so this retries a few times before giving up.
 
    It deletes the contents rather than the directory: the removal log lives
    there and has to survive the removal it is describing.
 
    .OUTPUTS
    Hashtable with Deleted (file names) and Blocked (Path and Reason each).
    #>

    [OutputType([hashtable])]
    param(
        [Parameter(Mandatory = $true)][string]$BotPath,
        [Parameter(Mandatory = $true)][string]$Keep,
        [string]$LogPath,
        [int]$Attempts = 5,
        [int]$WaitMilliseconds = 1000
    )

    if (-not (Test-Path $BotPath)) {
        return @{ Deleted = @(); Blocked = @() }
    }

    $doomed = @(Get-ChildItem -Path $BotPath -Recurse -File -ErrorAction SilentlyContinue |
        Where-Object { $_.Name -ne $Keep } |
        Select-Object -ExpandProperty Name)

    $blocked = @()
    for ($attempt = 1; $attempt -le $Attempts; $attempt++) {
        $blocked = @()

        foreach ($item in Get-ChildItem -Path $BotPath -Force -ErrorAction SilentlyContinue) {
            if ($item.Name -eq $Keep) { continue }
            try {
                Remove-Item $item.FullName -Recurse -Force -ErrorAction Stop
            } catch {
                $blocked += @{ Path = $item.FullName; Reason = $_.Exception.Message }
            }
        }

        if ($blocked.Count -eq 0) { break }

        if ($attempt -lt $Attempts) {
            if ($LogPath) {
                "[WARN] $($blocked.Count) item(s) still locked, attempt $attempt of $Attempts" |
                    Add-Content $LogPath -Encoding UTF8
            }
            Start-Sleep -Milliseconds $WaitMilliseconds
        }
    }

    $stillThere = @(Get-ChildItem -Path $BotPath -Recurse -File -ErrorAction SilentlyContinue |
        Where-Object { $_.Name -ne $Keep } |
        Select-Object -ExpandProperty Name)

    $deleted = @($doomed | Where-Object { $stillThere -notcontains $_ })

    if ($LogPath) {
        "[OK] Removed $($deleted.Count) file(s) from $BotPath" | Add-Content $LogPath -Encoding UTF8
    }

    return @{ Deleted = $deleted; Blocked = $blocked }
}

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', 'Remove-BotContent')