public/Remove-BucketObject.ps1

function Remove-BucketObject {
    <#
    .SYNOPSIS
    Removes objects from a bucket or deletes the bucket directory itself.
    .DESCRIPTION
    Removes objects from a bucket by key, match, filter, or all. Use -Drop to
    delete the bucket directory itself (safety-checked for .dat/.json only).
    Supports -WhatIf for preview and -PassThru for returning removed metadata.

    When removing by -Key, the bucket name defaults to "default" if omitted.
    When removing all objects (-Bucket alone), objects are removed but the
    bucket directory stays. Add -Drop to delete the container.
    .PARAMETER InputObject
    The object to remove. Accepts pipeline input. If it has _BucketName and _BucketKey
    metadata, bucket and key are auto-resolved.
    .PARAMETER Key
    Object key(s) to remove (Position 0, ByKey set). Accepts multiple values.
    Looks for both .json and .dat files. Case-insensitive.
    .PARAMETER Bucket
    Bucket name. In ByKey set, defaults to "default". Required in all other sets.
    .PARAMETER Path
    Root directory for bucket storage. Default: $HOME/.buckets.
    .PARAMETER Match
    Hashtable of property-value pairs for bulk deletion. Supports $null values.
    .PARAMETER Filter
    ScriptBlock for custom bulk deletion. Use $_ to reference object properties.
    .PARAMETER Drop
    Delete the bucket directory itself (not just its objects). Safety-checked:
    only removes directories containing .dat/.json files (or empty).
    .PARAMETER Force
    Skip confirmation prompt when using -Drop.
    .PARAMETER Recurse
    Recurse into nested sub-buckets. Applies to -Drop, ByAll, and ByFilter.
    .PARAMETER Depth
    Maximum nesting depth when recursing. Default: unlimited.
    .PARAMETER PassThru
    Return metadata (Bucket, Key) for removed objects.
    .PARAMETER Quiet
    Suppress progress output.
    .EXAMPLE
    Remove-BucketObject -Bucket logs -Key "log-003"
    .EXAMPLE
    Remove-BucketObject -Bucket temp
    .EXAMPLE
    Remove-BucketObject -Bucket temp -Drop -Force
    .EXAMPLE
    Remove-BucketObject -Bucket users -Match @{ Active = $false }
    .EXAMPLE
    Remove-BucketObject -Bucket users -Filter { $_.Status -eq "cancelled" }
    .EXAMPLE
    Get-BucketObject -Bucket users -Match @{Role="guest"} | Remove-BucketObject
    #>

    [CmdletBinding(SupportsShouldProcess = $true, ConfirmImpact = 'Medium', DefaultParameterSetName = 'ByAll')]
    param(
        [Parameter(ValueFromPipeline = $true, ValueFromPipelineByPropertyName = $true)]
        [PSObject]$InputObject,

        [Parameter(Position = 0, ParameterSetName = 'ByKey')]
        [Parameter(ValueFromPipelineByPropertyName = $true)]
        [Alias('_BucketKey')]
        [string[]]$Key,

        [Parameter(Mandatory = $true, Position = 0, ParameterSetName = 'ByAll')]
        [Parameter(Mandatory = $true, ParameterSetName = 'ByFilter')]
        [Parameter(Mandatory = $true, ParameterSetName = 'DropBucket')]
        [Parameter(ParameterSetName = 'ByKey')]
        [Parameter(ValueFromPipelineByPropertyName = $true)]
        [Alias('_BucketName')]
        [string[]]$Bucket = @('default'),

        [string]$Path,

        [Parameter(ParameterSetName = 'ByFilter')]
        [hashtable]$Match,

        [Parameter(ParameterSetName = 'ByFilter')]
        [scriptblock]$Filter,

        [Parameter(Mandatory = $true, ParameterSetName = 'DropBucket')]
        [switch]$Drop,

        [Parameter(ParameterSetName = 'DropBucket')]
        [switch]$Force,

        [Parameter(ParameterSetName = 'ByAll')]
        [Parameter(ParameterSetName = 'ByFilter')]
        [Parameter(ParameterSetName = 'DropBucket')]
        [Parameter(ParameterSetName = 'ByKey')]
        [switch]$Recurse,

        [Parameter(ParameterSetName = 'ByAll')]
        [Parameter(ParameterSetName = 'ByFilter')]
        [Parameter(ParameterSetName = 'DropBucket')]
        [Parameter(ParameterSetName = 'ByKey')]
        [int]$Depth = [int]::MaxValue,

        [switch]$PassThru,

        [switch]$Quiet
    )

    begin {
        $removedCount = 0; $lastBucket = ''; $removedKeys = [System.Collections.ArrayList]::new()
        $allProcessed = $false; $filterProcessed = $false; $dropProcessed = $false

        if ([string]::IsNullOrWhiteSpace($Path)) { $Path = Get-DefaultPath }
        $Path = Resolve-SafePath -Path $Path

        if ($Drop) { $Force = $Force -or $PSBoundParameters.ContainsKey('Force') }

        function _GatherFiles {
            param([string]$Dir, [int]$CurrentDepth, [int]$MaxDepth, [string[]]$Key, [System.Collections.Generic.HashSet[string]]$Visited)
            $files = [System.Collections.ArrayList]::new()
            $di = [System.IO.DirectoryInfo]::new($Dir)
            $allFiles = @($di.GetFiles("*.json")) + @($di.GetFiles("*.dat"))
            $keys = @($Key | Where-Object { $_ })
            if ($keys.Count -gt 0) {
                $targets = @($keys | ForEach-Object { $_.ToLowerInvariant() })
                $allFiles = @($allFiles | Where-Object {
                    $base = [System.IO.Path]::GetFileNameWithoutExtension($_.Name).ToLowerInvariant()
                    foreach ($t in $targets) {
                        $matched = if ($t -match '[\*\?]') { $base -like $t } else { $base -eq $t }
                        if ($matched) { return $true }
                    }
                    return $false
                })
            }
            foreach ($f in $allFiles) { $null = $files.Add($f) }
            if ($CurrentDepth -lt $MaxDepth) {
                foreach ($sub in $di.GetDirectories()) {
                    if ($sub.Name -eq '.buckets') { continue }
                    $subResolved = [System.IO.Path]::GetFullPath($(if ($null -ne $sub.LinkTarget) { $sub.LinkTarget } else { $sub.FullName }))
                    if ($Visited.Contains($subResolved)) { continue }
                    $null = $Visited.Add($subResolved)
                    foreach ($f in (_GatherFiles -Dir $sub.FullName -CurrentDepth ($CurrentDepth + 1) -MaxDepth $MaxDepth -Key $Key -Visited $Visited)) { $null = $files.Add($f) }
                }
            }
            $files.ToArray()
        }
    }

    process {
        $fromPipeline = $false
        $bucketName = $Bucket[0]
        if ($null -ne $InputObject) {
            $hasMeta = $InputObject.PSObject.Properties['_BucketName'] -and $InputObject.PSObject.Properties['_BucketKey']
            if ($hasMeta) {
                if ([string]::IsNullOrWhiteSpace($bucketName) -or $bucketName -eq 'default') { $bucketName = $InputObject._BucketName; $Bucket = @($bucketName) }
                if ($null -eq $Key -or $Key.Count -eq 0 -or ($Key.Count -eq 1 -and [string]::IsNullOrWhiteSpace($Key[0]))) { $Key = @($InputObject._BucketKey) }
                $fromPipeline = $true
            }
        }

        if ($Drop) {
            if ($dropProcessed) { return }
            $dropProcessed = $true

            $resolvedRoot = Resolve-SafePath -Path $Path
            $bucketPaths = @()
            foreach ($b in $Bucket) {
                if ($b -match '[\*\?]') {
                    $matched = Find-MatchingBuckets -Root $Path -Patterns @($b)
                    foreach ($m in $matched) { $bucketPaths += $m.Path }
                } else {
                    $bucketPaths += Get-BucketPath -Name $b -Path $Path
                }
            }

            $removable = @()
            $skippedBuckets = @()
            foreach ($bPath in $bucketPaths) {
                if (-not [System.IO.Directory]::Exists($bPath)) { continue }
                $resolvedBucket = Resolve-SafePath -Path $bPath
                if (-not $resolvedBucket.StartsWith($resolvedRoot, [System.StringComparison]::OrdinalIgnoreCase)) {
                    $relName = $bPath.Substring($Path.Length).TrimStart([System.IO.Path]::DirectorySeparatorChar).Replace([System.IO.Path]::DirectorySeparatorChar, '/')
                    $skippedBuckets += [PSCustomObject]@{ Name = $relName; Reason = "path resolves outside root" }
                    continue
                }

                $di = [System.IO.DirectoryInfo]::new($bPath)
                $allFiles = @($di.GetFiles())
                $otherFiles = @($allFiles | Where-Object { $_.Extension -notin ".dat", ".json" })
                if ($otherFiles.Count -gt 0) {
                    $relName = $bPath.Substring($Path.Length).TrimStart([System.IO.Path]::DirectorySeparatorChar).Replace([System.IO.Path]::DirectorySeparatorChar, '/')
                    $skippedBuckets += [PSCustomObject]@{ Name = $relName; Reason = "contains $($otherFiles.Count) non-bucket file(s): $($otherFiles.Name -join ', ')" }
                    continue
                }

                $nestedBuckets = @()
                foreach ($subDir in $di.GetDirectories()) {
                    if ($subDir.Name -eq ".buckets") { continue }
                    if ($subDir.GetFiles("*.dat").Length -gt 0 -or $subDir.GetFiles("*.json").Length -gt 0) {
                        $nestedBuckets += $subDir.Name
                    }
                }

                $stats = Get-BucketStats -Bucket $bPath -Path $Path -ErrorAction SilentlyContinue
                $relName = $bPath.Substring($Path.Length).TrimStart([System.IO.Path]::DirectorySeparatorChar).Replace([System.IO.Path]::DirectorySeparatorChar, '/')

                $removable += [PSCustomObject]@{
                    Name = $relName
                    Objects = if ($stats) { $stats.ObjectCount } else { 0 }
                    Size = if ($stats) { $stats.TotalSize } else { "0 KB" }
                    Path = $bPath
                    HasNestedBuckets = $nestedBuckets.Count -gt 0
                    NestedBucketNames = $nestedBuckets
                }
            }

            if ($removable.Count -eq 0 -and $skippedBuckets.Count -eq 0) {
                Write-Warning "No buckets match '$Bucket'"
                return
            }

            if ($Recurse -and $removable.Count -gt 1) {
                $sorted = @($removable | Sort-Object { $_.Path.Length })
                $topLevel = @()
                foreach ($r in $sorted) {
                    $isChild = $false
                    foreach ($existing in $topLevel) {
                        if ($r.Path.StartsWith($existing.Path + [System.IO.Path]::DirectorySeparatorChar, [System.StringComparison]::OrdinalIgnoreCase)) {
                            $isChild = $true; break
                        }
                    }
                    if (-not $isChild) { $topLevel += $r }
                }
                $removable = $topLevel
            }

            if ($WhatIfPreference) {
                if ($removable.Count -gt 0) {
                    Write-RemovalSummary -Title "What if: Remove the following bucket(s)" `
                        -Names $removable.Name -Counts $removable.Objects -Sizes $removable.Size -Nested $removable.NestedBucketNames
                }
                if ($skippedBuckets.Count -gt 0) {
                    Write-Host " Skipped:" -ForegroundColor $script:CSkip
                    foreach ($s in $skippedBuckets) {
                        Write-Host " " -NoNewline
                        Write-Host "$($s.Name)" -NoNewline -ForegroundColor $script:CPath
                        Write-Host " — " -NoNewline -ForegroundColor $script:CMuted
                        Write-Host "$($s.Reason)" -ForegroundColor $script:CError
                    }
                }
                return
            }

            if (-not $Force -and -not $Quiet -and $removable.Count -gt 0) {
                Write-RemovalSummary -Title "Remove $($removable.Count) bucket(s)?" `
                    -Names $removable.Name -Counts $removable.Objects -Sizes $removable.Size -Nested $removable.NestedBucketNames
            }

            if ($skippedBuckets.Count -gt 0 -and -not $Quiet) {
                foreach ($s in $skippedBuckets) {
                    Write-Host " " -NoNewline -ForegroundColor $script:CMuted
                    Write-Host "$($s.Name)" -NoNewline -ForegroundColor $script:CPath
                    Write-Host " · " -NoNewline -ForegroundColor $script:CMuted
                    Write-Host "$($s.Reason)" -ForegroundColor $script:CError
                }
            }

            $removedDirs = 0
            $removable = @($removable | Sort-Object { $_.Path.Length } -Descending)
            foreach ($r in $removable) {
                if (-not [System.IO.Directory]::Exists($r.Path)) { continue }
                $finalDi = [System.IO.DirectoryInfo]::new($r.Path)
                $finalFiles = @($finalDi.GetFiles())
                $finalOther = @($finalFiles | Where-Object { $_.Extension -notin ".dat", ".json" })
                if ($finalOther.Count -gt 0) {
                    Write-Warning "Bucket '$($r.Name)' now contains non-bucket files, aborting: $($finalOther.Name -join ', ')"
                    continue
                }

                $target = "bucket '$($r.Name)' ($($r.Objects) object(s), $($r.Size))"
                $shouldRemove = $Force
                if (-not $Force) {
                    $shouldRemove = $PSCmdlet.ShouldProcess($target, "Remove-BucketObject")
                }

                if ($shouldRemove) {
                    if ($r.HasNestedBuckets -and -not $Recurse) {
                        foreach ($f in $finalFiles) { $f.Delete() }
                        foreach ($d in $finalDi.GetDirectories()) {
                            if ($d.Name -eq '.buckets') { continue }
                            $hasBucketFiles = $d.GetFiles("*.dat").Length -gt 0 -or $d.GetFiles("*.json").Length -gt 0
                            if (-not $hasBucketFiles -and $d.GetDirectories().Length -eq 0) { $d.Delete() }
                        }
                        $remainingDirs = @($finalDi.GetDirectories())
                        if ($remainingDirs.Count -eq 0 -and $finalDi.GetFiles().Length -eq 0) { $finalDi.Delete() }
                        foreach ($ck in @($script:BucketPathCache.Keys | Where-Object { $_ -like "*|$($r.Name)" })) { $script:BucketPathCache.Remove($ck) }
                    } elseif ($Recurse) {
                        if ($Depth -eq [int]::MaxValue) {
                            [System.IO.Directory]::Delete($r.Path, $true)
                        } else {
                            function Remove-WithDepthLimit {
                                param([string]$Dir, [int]$CurrentDepth, [int]$MaxDepth)
                                $di = [System.IO.DirectoryInfo]::new($Dir)
                                foreach ($f in $di.GetFiles("*.dat")) { try { $f.Delete() } catch {} }
                                foreach ($f in $di.GetFiles("*.json")) { try { $f.Delete() } catch {} }
                                if ($CurrentDepth -lt $MaxDepth) {
                                    foreach ($sub in $di.GetDirectories()) {
                                        if ($sub.Name -eq '.buckets') { continue }
                                        Remove-WithDepthLimit -Dir $sub.FullName -CurrentDepth ($CurrentDepth + 1) -MaxDepth $MaxDepth
                                    }
                                }
                                $di.Refresh()
                                $remainingFiles = @($di.GetFiles())
                                $remainingDirs = @($di.GetDirectories() | Where-Object { $_.Name -ne '.buckets' })
                                if ($remainingFiles.Count -eq 0 -and $remainingDirs.Count -eq 0) { try { $di.Delete() } catch {} }
                            }
                            Remove-WithDepthLimit -Dir $r.Path -CurrentDepth 0 -MaxDepth $Depth
                        }
                        foreach ($ck in @($script:BucketPathCache.Keys | Where-Object { $_ -like "*|$($r.Name)*" })) { $script:BucketPathCache.Remove($ck) }
                    } else {
                        $finalDirs = @($finalDi.GetDirectories())
                        if ($finalDirs.Count -gt 0) {
                            Write-Warning "Bucket '$($r.Name)' contains non-bucket subdirectories, aborting"
                            continue
                        }
                        [System.IO.Directory]::Delete($r.Path, $true)
                        foreach ($ck in @($script:BucketPathCache.Keys | Where-Object { $_ -like "*|$($r.Name)" })) { $script:BucketPathCache.Remove($ck) }
                    }

                    $removedDirs++
                    if (-not $Quiet) {
                        Write-Host "$($r.Name)" -NoNewline -ForegroundColor $script:CPath
                        Write-Host " · " -NoNewline -ForegroundColor $script:CMuted
                        $objLabel = if ($r.Objects -eq 1) { "1 object" } else { "$($r.Objects) objects" }
                        Write-Host $objLabel -NoNewline -ForegroundColor $script:CNum
                        Write-Host " removed" -ForegroundColor $script:CMuted
                    }
                }
            }

            if ($removedDirs -gt 1 -and -not $Quiet) {
                Write-Host $removedDirs -NoNewline -ForegroundColor $script:CNum
                Write-Host " buckets removed" -ForegroundColor $script:CMuted
            }
            return
        }

        if ($Key -and $Key.Count -gt 0 -and -not $fromPipeline) {
            $keys = @($Key | Where-Object { $_ })
            if ($keys.Count -gt 0) {
                $bucketPath = Get-BucketPath -Name $bucketName -Path $Path -ErrorAction SilentlyContinue
                if (-not $bucketPath -or -not [System.IO.Directory]::Exists($bucketPath)) {
                    Write-Warning "Bucket '$Bucket' not found"
                    return
                }

                $matchedFiles = @()
                if ($Recurse) {
                    $gv = [System.Collections.Generic.HashSet[string]]::new(); $matchedFiles = _GatherFiles -Dir $bucketPath -CurrentDepth 1 -MaxDepth $Depth -Key $keys -Visited $gv
                } else {
                    foreach ($singleKey in $keys) {
                        $file = Find-ObjectFile -BucketPath $bucketPath -Key $singleKey
                        if ($file) { $matchedFiles += $file }
                    }
                }
                if ($matchedFiles.Count -eq 0) {
                    Write-Warning "Object with key '$($Key -join ', ')' not found in bucket '$Bucket'"
                } else {
                    foreach ($file in $matchedFiles) {
                        $fileKey = [System.IO.Path]::GetFileNameWithoutExtension($file.Name)
                        if ($PSCmdlet.ShouldProcess("object '$fileKey' from bucket '$Bucket'", "Remove-BucketObject")) {
                            if ($PassThru) {
                                [PSCustomObject]@{ Bucket = $Bucket; Key = $fileKey }
                            }
                            [System.IO.File]::Delete($file.FullName)
                            $parentDir = [System.IO.Path]::GetDirectoryName($file.FullName)
                            if ($parentDir.StartsWith($bucketPath)) {
                                $parentDi = [System.IO.DirectoryInfo]::new($parentDir)
                                $remaining = @($parentDi.GetFiles()) + @($parentDi.GetDirectories())
                                if ($remaining.Count -eq 0) { try { [System.IO.Directory]::Delete($parentDir) } catch {} }
                            }
                            $removedCount++
                            $lastBucket = $Bucket
                            $null = $removedKeys.Add($fileKey)
                        }
                    }
                }
                return
            }
        }

        if ($fromPipeline -and $Key -and $Key.Count -gt 0) {
            $bucketPath = Get-BucketPath -Name $bucketName -Path $Path -ErrorAction SilentlyContinue
            if (-not $bucketPath -or -not [System.IO.Directory]::Exists($bucketPath)) { return }
            foreach ($singleKey in $Key) {
                $file = Find-ObjectFile -BucketPath $bucketPath -Key $singleKey
                if (-not $file) { continue }
                $fileKey = [System.IO.Path]::GetFileNameWithoutExtension($file.Name)
                if ($PSCmdlet.ShouldProcess("object '$fileKey' from bucket '$Bucket'", "Remove-BucketObject")) {
                    if ($PassThru) { [PSCustomObject]@{ Bucket = $Bucket; Key = $fileKey } }
                    [System.IO.File]::Delete($file.FullName)
                    $parentDir = [System.IO.Path]::GetDirectoryName($file.FullName)
                    if ($parentDir.StartsWith($bucketPath)) {
                        $parentDi = [System.IO.DirectoryInfo]::new($parentDir)
                        $remaining = @($parentDi.GetFiles()) + @($parentDi.GetDirectories())
                        if ($remaining.Count -eq 0) { try { [System.IO.Directory]::Delete($parentDir) } catch {} }
                    }
                    $removedCount++; $lastBucket = $Bucket; $null = $removedKeys.Add($fileKey)
                }
            }
            return
        }

        $isFilter = $Match -or $Filter
        if ($isFilter) {
            if ($filterProcessed) { return }
            $filterProcessed = $true

            $bucketPath = Get-BucketPath -Name $bucketName -Path $Path
            if (-not [System.IO.Directory]::Exists($bucketPath)) { Write-Verbose "Bucket '$Bucket' not found"; return }

            $di = [System.IO.DirectoryInfo]::new($bucketPath)
            $allFiles = if ($Recurse) { $gv = [System.Collections.Generic.HashSet[string]]::new(); _GatherFiles -Dir $bucketPath -CurrentDepth 1 -MaxDepth $Depth -Visited $gv } else { @($di.GetFiles("*.json")) + @($di.GetFiles("*.dat")) }

            if ($allFiles.Count -eq 0) { Write-Verbose "Bucket '$Bucket' is already empty"; return }

            if ($WhatIfPreference) {
                $matchedKeys = [System.Collections.ArrayList]::new()
                foreach ($file in $allFiles) {
                    $obj = Read-BucketFile -File $file
                    if ($null -eq $obj) { continue }
                    if ($Match -and -not (Test-MatchFilter -Object $obj -Match $Match)) { continue }
                    if ($Filter -and ($null -eq ($obj | Where-Object $Filter))) { continue }
                    $null = $matchedKeys.Add([System.IO.Path]::GetFileNameWithoutExtension($file.Name))
                }
                if ($matchedKeys.Count -eq 0) { Write-Verbose "No objects matched the filter criteria in bucket '$Bucket'"; return }
                Write-Host ""
                Write-Host " What if: Remove " -NoNewline -ForegroundColor $script:CMuted
                Write-Host $matchedKeys.Count -NoNewline -ForegroundColor $script:CNum
                Write-Host " matching object(s) from " -NoNewline -ForegroundColor $script:CMuted
                Write-Host $Bucket -NoNewline -ForegroundColor $script:CPath
                $recurseNote = if ($Recurse) { " (recursive, depth ${Depth})" } else { "" }
                Write-Host "$recurseNote" -ForegroundColor $script:CMuted
                $showKeys = $matchedKeys | Select-Object -First 5
                foreach ($k in $showKeys) { Write-Host " $k" -ForegroundColor $script:CMuted }
                if ($matchedKeys.Count -gt 5) { Write-Host " ... and $($matchedKeys.Count - 5) more" -ForegroundColor $script:CMuted }
                Write-Host ""
                return
            }

            $target = "matching objects from bucket '$Bucket'"
            if (-not $PSCmdlet.ShouldProcess($target, "Remove-BucketObject")) { return }

            $matchedFiles = [System.Collections.ArrayList]::new()
            $matchedKeys = [System.Collections.ArrayList]::new()
            foreach ($file in $allFiles) {
                $obj = Read-BucketFile -File $file
                if ($null -eq $obj) { continue }
                if ($Match -and -not (Test-MatchFilter -Object $obj -Match $Match)) { continue }
                if ($Filter -and ($null -eq ($obj | Where-Object $Filter))) { continue }
                $null = $matchedFiles.Add($file)
                $null = $matchedKeys.Add([System.IO.Path]::GetFileNameWithoutExtension($file.Name))
            }

            if ($matchedFiles.Count -eq 0) { Write-Verbose "No objects matched the filter criteria in bucket '$Bucket'"; return }

            $matchSize = ($matchedFiles | Measure-Object -Property Length -Sum).Sum
            $sizeStr = if ($matchSize) { "$([math]::Round($matchSize / 1KB, 2)) KB" } else { "0 KB" }

            if (-not $Quiet) {
                Write-Host ""
                Write-Host " Remove " -NoNewline -ForegroundColor $script:CMuted
                Write-Host $matchedFiles.Count -NoNewline -ForegroundColor $script:CNum
                Write-Host " matching object(s) from " -NoNewline -ForegroundColor $script:CMuted
                Write-Host $Bucket -NoNewline -ForegroundColor $script:CPath
                $recurseNote = if ($Recurse) { " (recursive, depth ${Depth})" } else { "" }
                Write-Host "$recurseNote ($sizeStr)" -ForegroundColor $script:CMuted
                $showKeys = $matchedKeys | Select-Object -First 5
                foreach ($k in $showKeys) { Write-Host " " -NoNewline; Write-Host $k -ForegroundColor $script:CNum }
                if ($matchedKeys.Count -gt 5) { Write-Host " ... and $($matchedKeys.Count - 5) more" -ForegroundColor $script:CMuted }
                Write-Host ""
            }

            foreach ($f in $matchedFiles) {
                if ($PassThru) {
                    $relPath = $f.FullName.Substring($bucketPath.Length).TrimStart([System.IO.Path]::DirectorySeparatorChar)
                    $keyOnly = [System.IO.Path]::ChangeExtension($relPath, $null).TrimEnd('.')
                    [PSCustomObject]@{ Bucket = $Bucket; Key = $keyOnly }
                }
                [System.IO.File]::Delete($f.FullName)
                $removedCount++; $lastBucket = $Bucket; $null = $removedKeys.Add([System.IO.Path]::GetFileNameWithoutExtension($f.Name))
            }
            return
        }

        if ($fromPipeline) { return }

        $bucketPath = Get-BucketPath -Name $bucketName -Path $Path
        if (-not [System.IO.Directory]::Exists($bucketPath)) {
            Write-Verbose "Bucket '$Bucket' not found at '$bucketPath'"
            return
        }

        if ($allProcessed) { return }
        $allProcessed = $true

        $di = [System.IO.DirectoryInfo]::new($bucketPath)
        $allFiles = if ($Recurse) { $gv = [System.Collections.Generic.HashSet[string]]::new(); _GatherFiles -Dir $bucketPath -CurrentDepth 1 -MaxDepth $Depth -Visited $gv } else { @($di.GetFiles("*.json")) + @($di.GetFiles("*.dat")) }

        if ($allFiles.Count -eq 0) { Write-Verbose "Bucket '$Bucket' is already empty"; return }

        $bucketSize = ($allFiles | Measure-Object -Property Length -Sum).Sum
        $sizeStr = if ($bucketSize) { "$([math]::Round($bucketSize / 1KB, 2)) KB" } else { "0 KB" }

        if ($WhatIfPreference) {
            Write-Host ""
            Write-Host " What if: Remove all " -NoNewline -ForegroundColor $script:CMuted
            Write-Host $allFiles.Count -NoNewline -ForegroundColor $script:CNum
            Write-Host " object(s) from " -NoNewline -ForegroundColor $script:CMuted
            Write-Host $Bucket -NoNewline -ForegroundColor $script:CPath
            $recurseNote = if ($Recurse) { " (recursive, depth ${Depth})" } else { "" }
            Write-Host "$recurseNote ($sizeStr)" -ForegroundColor $script:CMuted
            Write-Host ""
            return
        }

        $target = "$($allFiles.Count) object(s) from bucket '$Bucket'"
        if ($PSCmdlet.ShouldProcess($target, "Remove-BucketObject")) {
            $allFiles | ForEach-Object { [System.IO.File]::Delete($_.FullName) }
            if ($Recurse) {
                $emptyDirKeys = [System.Collections.ArrayList]::new()
                $edVisited = [System.Collections.Generic.HashSet[string]]::new()
                function _EnumDirs { param([string]$D, [int]$CD, [System.Collections.Generic.HashSet[string]]$EDVisited)
                    $null = $emptyDirKeys.Add($D)
                    if ($CD -lt $Depth) {
                        foreach ($s in [System.IO.DirectoryInfo]::new($D).GetDirectories()) {
                            if ($s.Name -eq '.buckets') { continue }
                            $sResolved = [System.IO.Path]::GetFullPath($(if ($null -ne $s.LinkTarget) { $s.LinkTarget } else { $s.FullName }))
                            if ($EDVisited.Contains($sResolved)) { continue }
                            $null = $EDVisited.Add($sResolved)
                            _EnumDirs -D $s.FullName -CD ($CD + 1) -EDVisited $EDVisited
                        }
                    }
                }
                _EnumDirs -D $bucketPath -CD 1 -EDVisited $edVisited
                $emptyDirKeys | Sort-Object Length -Descending | ForEach-Object {
                    $d = [System.IO.DirectoryInfo]::new($_)
                    $d.Refresh()
                    $remaining = @($d.GetFiles()) + @($d.GetDirectories() | Where-Object { $_.Name -ne '.buckets' })
                    if ($remaining.Count -eq 0) { try { $d.Delete() } catch {} }
                }
            } else {
                foreach ($d in $di.GetDirectories()) {
                    if ($d.Name -eq ".buckets") { continue }
                    $remaining = @($d.GetFiles()) + @($d.GetDirectories())
                    if ($remaining.Count -eq 0) { [System.IO.Directory]::Delete($d.FullName) }
                }
            }
        }

        if ($PassThru) {
            foreach ($f in $allFiles) {
                $relPath = $f.FullName.Substring($bucketPath.Length).TrimStart([System.IO.Path]::DirectorySeparatorChar)
                $keyOnly = [System.IO.Path]::ChangeExtension($relPath, $null).TrimEnd('.')
                [PSCustomObject]@{ Bucket = $Bucket; Key = $keyOnly }
            }
        }
        elseif (-not $WhatIfPreference -and -not $Quiet) {
            Write-Host "$Bucket" -NoNewline -ForegroundColor $script:CPath
            Write-Host " · " -NoNewline -ForegroundColor $script:CMuted
            $objLabel = if ($allFiles.Count -eq 1) { "1 object" } else { "$($allFiles.Count) objects" }
            Write-Host $objLabel -NoNewline -ForegroundColor $script:CNum
            Write-Host " removed" -ForegroundColor $script:CMuted
        }
    }

    end {
        if ($removedCount -gt 0 -and -not $Quiet -and -not $WhatIfPreference) {
            Write-Host "$lastBucket" -NoNewline -ForegroundColor $script:CPath
            Write-Host " · " -NoNewline -ForegroundColor $script:CMuted
            $objLabel = if ($removedCount -eq 1) { "1 object" } else { "$removedCount objects" }
            Write-Host $objLabel -NoNewline -ForegroundColor $script:CNum
            Write-Host " removed" -ForegroundColor $script:CMuted
        }
    }
}