bin/purge.ps1

# WinMole - Project Artifact Purge
# Scans configured directories for build artifacts (node_modules, target, .venv, etc.)
# and interactively selects which ones to permanently remove.

param(
    [switch]$DryRun,
    [switch]$Paths
)

$ErrorActionPreference = 'SilentlyContinue'
. "$PSScriptRoot\..\lib\core.ps1"
. "$PSScriptRoot\..\lib\ui.ps1"

# ── Purge paths config ────────────────────────────────────────────────────────
$purgeCfgFile = Join-Path $script:WINMOLE_CONFIG 'purge_paths.txt'

if ($Paths) {
    if (-not (Test-Path $script:WINMOLE_CONFIG)) {
        [void][System.IO.Directory]::CreateDirectory($script:WINMOLE_CONFIG)
    }
    if (-not (Test-Path $purgeCfgFile)) {
        @(
            "# WinMole Purge Paths - one directory per line"
            "# Default paths are used when this file is empty."
            "$env:USERPROFILE\Projects"
            "$env:USERPROFILE\dev"
            "$env:USERPROFILE\source"
        ) | Set-Content $purgeCfgFile
    }
    Write-SpectreHost " [deepskyblue1]Opening purge paths config:[/] $(Esc $purgeCfgFile)"
    Start-Process notepad $purgeCfgFile
    return
}

# ── Resolve scan roots ────────────────────────────────────────────────────────
$customPaths = @()
if (Test-Path $purgeCfgFile) {
    $customPaths = Get-Content $purgeCfgFile -ErrorAction SilentlyContinue |
        Where-Object { $_ -and -not $_.StartsWith('#') } |
        ForEach-Object { $_.Trim() } |
        Where-Object { Test-Path $_ }
}

$scanRoots = if ($customPaths) {
    $customPaths
} else {
    @(
        "$env:USERPROFILE\Projects",
        "$env:USERPROFILE\dev",
        "$env:USERPROFILE\source",
        "$env:USERPROFILE\repos",
        "$env:USERPROFILE\github",
        "$env:USERPROFILE\work",
        "$env:USERPROFILE\Documents\Projects",
        "$env:USERPROFILE\Desktop\Projects"
    ) | Where-Object { Test-Path $_ }
}

if (-not $scanRoots) {
    Write-Warn "No project directories found. Use 'mo purge --paths' to configure scan directories."
    return
}

# ── Artifact patterns ─────────────────────────────────────────────────────────
$artifactPatterns = @(
    [pscustomobject]@{ Name = 'node_modules'; MaxDepth = 3 }
    [pscustomobject]@{ Name = 'target';       MaxDepth = 3 }
    [pscustomobject]@{ Name = '.gradle';      MaxDepth = 3 }
    [pscustomobject]@{ Name = 'build';        MaxDepth = 3 }
    [pscustomobject]@{ Name = 'dist';         MaxDepth = 3 }
    [pscustomobject]@{ Name = '.build';       MaxDepth = 3 }
    [pscustomobject]@{ Name = '__pycache__';  MaxDepth = 4 }
    [pscustomobject]@{ Name = '.venv';        MaxDepth = 3 }
    [pscustomobject]@{ Name = 'venv';         MaxDepth = 3 }
    [pscustomobject]@{ Name = '.env';         MaxDepth = 3 }
    [pscustomobject]@{ Name = '.next';        MaxDepth = 3 }
    [pscustomobject]@{ Name = '.nuxt';        MaxDepth = 3 }
    [pscustomobject]@{ Name = 'out';          MaxDepth = 3 }
    [pscustomobject]@{ Name = 'vendor';       MaxDepth = 3 }
    [pscustomobject]@{ Name = 'obj';          MaxDepth = 4 }
    [pscustomobject]@{ Name = 'bin';          MaxDepth = 4 }
    [pscustomobject]@{ Name = '.terraform';   MaxDepth = 3 }
    [pscustomobject]@{ Name = '.tox';         MaxDepth = 3 }
    [pscustomobject]@{ Name = 'coverage';     MaxDepth = 3 }
    [pscustomobject]@{ Name = '.pytest_cache'; MaxDepth = 4 }
    [pscustomobject]@{ Name = '.mypy_cache';  MaxDepth = 4 }
    [pscustomobject]@{ Name = 'Pods';         MaxDepth = 3 }
    [pscustomobject]@{ Name = '.svelte-kit';  MaxDepth = 3 }
)

$patternSet = [System.Collections.Generic.HashSet[string]]::new(
    [System.StringComparer]::OrdinalIgnoreCase)
foreach ($p in $artifactPatterns) { [void]$patternSet.Add($p.Name) }

Write-Header -Title 'WinMole Purge' -Sub (if ($DryRun) { '(dry run)' } else { '' })
Write-SpectreHost " [grey]Scanning for project artifacts... (this may take a moment)[/]"

# ── Fast scan using .NET enumeration with runspaces ───────────────────────────
$found = [System.Collections.Concurrent.ConcurrentBag[object]]::new()

$scanBlock = {
    param($root, $patNames, $maxD)
    $results = [System.Collections.Generic.List[object]]::new()
    function Scan-Dir {
        param([string]$dir, [int]$depth)
        if ($depth -gt $maxD) { return }
        try {
            $di = [System.IO.DirectoryInfo]::new($dir)
            foreach ($child in $di.EnumerateDirectories()) {
                if ($patNames.Contains($child.Name)) {
                    $results.Add([pscustomobject]@{
                        Path        = $child.FullName
                        ArtifactName= $child.Name
                        ProjectDir  = $dir
                    })
                } else {
                    Scan-Dir -dir $child.FullName -depth ($depth + 1)
                }
            }
        } catch {}
    }
    Scan-Dir -dir $root -depth 0
    return $results
}

$pool = [runspacefactory]::CreateRunspacePool(
    1, [Math]::Min($scanRoots.Count + 1, [Environment]::ProcessorCount))
$pool.Open()

$handles = foreach ($root in $scanRoots) {
    $ps = [System.Management.Automation.PowerShell]::Create()
    $ps.RunspacePool = $pool
    [void]$ps.AddScript($scanBlock).AddArgument($root).AddArgument($patternSet).AddArgument(4)
    [pscustomobject]@{ PS = $ps; Handle = $ps.BeginInvoke() }
}

$allFound = [System.Collections.Generic.List[object]]::new()
foreach ($h in $handles) {
    try {
        $res = $h.PS.EndInvoke($h.Handle)
        if ($res) { foreach ($r in $res) { $allFound.Add($r) } }
    } catch {}
    $h.PS.Dispose()
}
$pool.Close(); $pool.Dispose()

# ── Measure sizes in parallel ─────────────────────────────────────────────────
$paths = @($allFound | ForEach-Object { $_.Path })
$sizes = if ($paths.Count -gt 0) {
    Get-DirectorySizes -Paths $paths
} else { @{} }

Write-Host ""

if ($allFound.Count -eq 0) {
    Write-Info "No project artifacts found in: $($scanRoots -join ', ')"
    return
}

# ── Build menu items ──────────────────────────────────────────────────────────
$cutoff = (Get-Date).AddDays(-7)

$menuItems = foreach ($item in $allFound | Sort-Object { $sizes[$_.Path] } -Descending) {
    $size    = if ($sizes.ContainsKey($item.Path)) { $sizes[$item.Path] } else { [long]0 }
    $projName= Split-Path -Leaf $item.ProjectDir
    if ($projName.Length -gt 22) { $projName = $projName.Substring(0, 19) + '...' }

    # Recent project check
    $isRecent = $false
    try {
        $lwt = [System.IO.Directory]::GetLastWriteTime($item.ProjectDir)
        $isRecent = ($lwt -gt $cutoff)
    } catch {}

    $tag = "$($item.ArtifactName)$(if ($isRecent) { ' | Recent' } else { '' })"

    [pscustomobject]@{
        Label    = $projName
        Size     = $size
        Tag      = $tag
        Selected = -not $isRecent
        Disabled = $false
        Path     = $item.Path
    }
}

$menuItems = @($menuItems)
$totalStr  = Format-Bytes -Bytes ($menuItems | Measure-Object -Property Size -Sum).Sum

$chosen = Show-ChecklistMenu -Title "Select Categories to Clean - $totalStr" -Items $menuItems
if (-not $chosen) {
    Write-SpectreHost " [grey]Cancelled.[/]"
    Write-Host ""
    return
}

$toDelete = @($chosen | Where-Object { $_.Selected })
if ($toDelete.Count -eq 0) {
    Write-Info "Nothing selected."
    return
}

$deleteSize = ($toDelete | Measure-Object -Property Size -Sum).Sum
$deleteMsg = "This will permanently delete $($toDelete.Count) artifact director$(if ($toDelete.Count -eq 1){'y'}else{'ies'}) ($(Format-Bytes $deleteSize))."
Write-Host ""
Write-SpectreHost " [bold red1]$(Esc $deleteMsg)[/]"

if ($DryRun) {
    Write-Host ""
    Write-Info "Dry run — no files deleted."
    foreach ($d in $toDelete) {
        Write-SpectreHost " [grey]$(Esc $d.Path)[/]"
    }
    return
}

if (-not (Confirm-Action -Message 'Proceed with deletion?')) {
    Write-SpectreHost " [grey]Cancelled.[/]"
    Write-Host ""
    return
}

Write-Host ""
$freed = [long]0
foreach ($d in $toDelete) {
    try {
        Remove-Item -LiteralPath $d.Path -Recurse -Force -ErrorAction Stop
        Write-Success -Message $d.Label -Right (Format-Bytes $d.Size)
        Write-OpLog "Purge: $($d.Path) ($(Format-Bytes $d.Size))"
        $freed += $d.Size
    } catch {
        Write-Warn "Could not delete: $($d.Path) — $_"
    }
}

Write-Host ""
Write-Summary -Label 'Space freed:' -Value (Format-Bytes -Bytes $freed)