Buckets.psm1

<#
.SYNOPSIS
    A PowerShell module for file-based PSObject storage using directory-backed buckets.
.DESCRIPTION
    Buckets provides a simple way to store, retrieve, and manage PowerShell objects
    in directory-based collections called "buckets". Objects are automatically serialized
    to binary (default) or JSON format, with auto-fallback to binary when JSON depth
    limits are exceeded.
#>


# Bucket path caching for session
$script:BucketPathCache = @{}
$script:LastPWD = $PWD.Path

function Clear-BucketPathCache {
    $script:BucketPathCache.Clear()
    $script:LastPWD = $PWD.Path
}

# Dynamic argument completer for -Bucket parameter
# Registered via Register-ArgumentCompleter at module load (see bottom of file)
function Get-BucketNameCompletions {
    param($commandName, $parameterName, $wordToComplete, $commandAst, $fakeBoundParameters)

    $path = if ($fakeBoundParameters.ContainsKey('Path')) { $fakeBoundParameters['Path'] } else { Get-DefaultPath }
    if (-not [System.IO.Directory]::Exists($path)) { return }

    $dirs = [System.IO.DirectoryInfo]::new($path).GetDirectories("$wordToComplete*")
    $dirs | ForEach-Object {
        [System.Management.Automation.CompletionResult]::new(
            $_.Name,
            $_.Name,
            'ParameterValue',
            $_.Name
        )
    }
}

function Get-DefaultPath {
    return Join-Path $PWD.Path ".buckets"
}

function Resolve-SafePath {
    [CmdletBinding()]
    param(
        [Parameter(Mandatory = $true)]
        [string]$Path
    )
    try {
        $resolved = [System.IO.Path]::GetFullPath($Path)
        return $resolved
    }
    catch {
        throw "Invalid path '$Path': $_"
    }
}

function Get-BucketPath {
    [CmdletBinding()]
    param(
        [Parameter(Mandatory = $true)]
        [string]$Name,
        [string]$Path
    )

    if ($script:LastPWD -ne $PWD.Path) {
        Clear-BucketPathCache
    }

    if ([string]::IsNullOrWhiteSpace($Path)) { $Path = Get-DefaultPath }
    $cacheKey = "${Path}|${Name}"
    if ($script:BucketPathCache.ContainsKey($cacheKey)) {
        return $script:BucketPathCache[$cacheKey]
    }
    $bucketPath = Resolve-SafePath -Path (Join-Path $Path $Name)
    $script:BucketPathCache[$cacheKey] = $bucketPath
    return $bucketPath
}

function Ensure-BucketExists {
    [CmdletBinding()]
    param(
        [Parameter(Mandatory = $true)]
        [string]$Name,
        [string]$Path
    )

    if ([string]::IsNullOrWhiteSpace($Path)) { $Path = Get-DefaultPath }
    $rootPath = Resolve-SafePath -Path $Path
    $bucketPath = Get-BucketPath -Name $Name -Path $rootPath
    if (-not $bucketPath.StartsWith($rootPath, [System.StringComparison]::OrdinalIgnoreCase)) {
        throw "Bucket path '$bucketPath' resolves outside of root '$rootPath'. Path traversal not allowed."
    }
    if (-not [System.IO.Directory]::Exists($bucketPath)) {
        $null = [System.IO.Directory]::CreateDirectory($bucketPath)
    }
    return $bucketPath
}

function New-BucketObject {
    <#
    .SYNOPSIS
    Saves a PSObject to a bucket. Creates the bucket if it doesn't exist.
    .DESCRIPTION
    Serializes one or more PowerShell objects and stores them in a bucket directory.
    Arrays are stored as individual files. By default objects are serialized to binary
    (.dat) using PSSerializer for full .NET type preservation. Use -AsJson for
    human-readable JSON format. If JSON serialization fails on complex types,
    the object automatically falls back to binary format.
    .PARAMETER InputObject
    The object(s) to store. Accepts pipeline input. Arrays are stored as individual files.
    .PARAMETER Bucket
    Name of the bucket to save to. Creates the bucket if it doesn't exist. Default: "default".
    .PARAMETER Path
    Root directory for bucket storage. Default: $PWD/.buckets.
    .PARAMETER Key
    Property name whose value becomes the filename. Special characters (/, :, *, ?, ", <, >, |, ., []) are sanitized to underscores. If omitted, a GUID is used.
    .PARAMETER Depth
    Maximum depth for JSON serialization. Default: 20.
    .PARAMETER BinaryDepth
    Maximum depth for binary (PSSerializer) serialization. Default: 2.
    .PARAMETER AsTimestamp
    Use a timestamp-based filename (yyyyMMddHHmmssfff_index) instead of a GUID. Ignored if -Key is also specified.
    .PARAMETER AsJson
    Store objects as JSON (.json) instead of binary (.dat).
    .PARAMETER Compress
    Enable GZip compression for binary (.dat) files to reduce disk usage.
    .PARAMETER Quiet
    Suppress all output. No progress indicator, no summary.
    .PARAMETER Overwrite
    Overwrite existing objects with the same key. Default: $false.
    .PARAMETER ArrayTracking
    Enable array tracking for piped or -InputObject arrays. Items are tagged with
    _ArrayId (shared GUID) and _ArrayIndex (original position) so they can be
    reassembled later via Get-BucketObject -GroupArrays. Without this switch,
    objects are saved as individual files with no grouping metadata.
    .OUTPUTS
    By default, a progress indicator and summary are shown.
    Use -Verbose for per-object details. Use -Quiet for silent operation.
    .EXAMPLE
    # Save users with Name as the key
    New-BucketObject -Bucket users -InputObject $users -Key Name

    .EXAMPLE
    # Save config as JSON
    New-BucketObject -Bucket config -InputObject $config -Key _Id -AsJson

    .EXAMPLE
    # Save metrics keyed by Hour
    New-BucketObject -Bucket metrics -InputObject $metrics -Key Hour

    .EXAMPLE
    # Save logs with unique IDs, silent mode
    New-BucketObject -Bucket logs -InputObject $logEntries -Key Id -Quiet

    .EXAMPLE
    # Overwrite existing object
    New-BucketObject -Bucket users -InputObject @{ Name = "Alice"; Email = "alice@new.com"; Role = "manager"; Active = $true } -Key Name -Overwrite

    .EXAMPLE
    # Save array with tracking for later reconstruction
    $orders | New-BucketObject -Bucket orders -Key OrderId -ArrayTracking
    $orders = (Get-BucketObject -Bucket orders -GroupArrays)._ArrayItems
    #>

    [CmdletBinding()]
    param(
        [Parameter(Mandatory = $true, ValueFromPipeline = $true)]
        [PSObject]$InputObject,

        [string]$Bucket = "default",

        [string]$Path,

        [string]$Key,

        [ValidateRange(1, 100)]
        [int]$Depth = 20,

        [ValidateRange(1, 10)]
        [int]$BinaryDepth = 2,

        [switch]$AsTimestamp,

        [switch]$AsJson,

        [switch]$Compress,

        [switch]$Overwrite,

        [switch]$ArrayTracking,

        [switch]$Quiet
    )

    begin {
        $bucketPath = Ensure-BucketExists -Name $Bucket -Path $Path
        $extension = if ($AsJson) { ".json" } else { ".dat" }
        $savedCount = 0
        $skippedCount = 0
        $fallbackCount = 0
        $failedCount = 0
        $totalCount = 0
        $useVerbose = $VerbosePreference -eq 'Continue'
        $useQuiet = $Quiet.IsPresent
        $showProgress = -not $useVerbose -and -not $useQuiet

        if ($AsTimestamp -and -not [string]::IsNullOrWhiteSpace($Key)) {
            Write-Verbose "Both -Key and -AsTimestamp specified. -Key takes precedence, -AsTimestamp ignored."
        }

        $itemsToProcess = [System.Collections.ArrayList]::new()
        $useBuffering = $ArrayTracking.IsPresent
    }

    process {
        if ($null -eq $InputObject) { return }

        if ($useBuffering) {
            $isCollection = $InputObject -is [System.Collections.IEnumerable] -and $InputObject -isnot [string] -and $InputObject -isnot [hashtable] -and $InputObject -isnot [System.Collections.IDictionary]
            if ($isCollection) {
                foreach ($item in $InputObject) {
                    $null = $itemsToProcess.Add($item)
                }
            }
            else {
                $null = $itemsToProcess.Add($InputObject)
            }
            return
        }

        $isCollection = $InputObject -is [System.Collections.IEnumerable] -and $InputObject -isnot [string] -and $InputObject -isnot [hashtable] -and $InputObject -isnot [System.Collections.IDictionary]

        if ($isCollection) {
            $items = $InputObject

            $totalForItems = $items.Count
            $index = 0
            foreach ($item in $items) {
                $itemFilename = if (-not [string]::IsNullOrWhiteSpace($Key)) {
                    $keyValue = $item.$Key
                    if ($null -eq $keyValue) {
                        Write-Verbose "Property '$Key' not found on object, skipping"
                        $skippedCount++
                        $index++
                        continue
                    }
                    $safeKey = $keyValue -replace '[\\/:\*\?"<>\|\.\[\]]', '_'
                    if ([string]::IsNullOrWhiteSpace($safeKey) -or $safeKey -match '^_+$') {
                        Write-Verbose "Key for object is empty after sanitization ('$keyValue' -> '$safeKey'), skipping"
                        $skippedCount++
                        $index++
                        continue
                    }
                    "${safeKey}${extension}"
                }
                elseif ($AsTimestamp) {
                    "$(Get-Date -Format 'yyyyMMddHHmmssfff')_${index}${extension}"
                }
                else {
                    "$([Guid]::NewGuid())${extension}"
                }

                $itemFilePath = Join-Path $bucketPath $itemFilename

                if ([System.IO.File]::Exists($itemFilePath) -and -not $Overwrite) {
                    Write-Verbose "Object with key '$([System.IO.Path]::GetFileNameWithoutExtension($itemFilename))' already exists in bucket '$Bucket'. Use -Overwrite to replace."
                    $skippedCount++
                    $index++
                    continue
                }

                $writeSuccess = $false
                if ($AsJson) {
                    try {
                        $json = ConvertTo-Json -InputObject $item -Depth $Depth -Compress -WarningAction SilentlyContinue
                        [System.IO.File]::WriteAllText($itemFilePath, $json, [System.Text.Encoding]::UTF8)
                        $writeSuccess = $true
                    }
                    catch {
                        try {
                            $xml = [System.Management.Automation.PSSerializer]::Serialize($item, $BinaryDepth)
                            $rawBytes = [System.Text.Encoding]::UTF8.GetBytes($xml)
                            $itemFilePath = [System.IO.Path]::ChangeExtension($itemFilePath, ".dat")
                            if ($Compress) {
                                $ms = [System.IO.MemoryStream]::new()
                                $cs = [System.IO.Compression.GZipStream]::new($ms, [System.IO.Compression.CompressionLevel]::Optimal)
                                $cs.Write($rawBytes, 0, $rawBytes.Length)
                                $cs.Close()
                                [System.IO.File]::WriteAllBytes($itemFilePath, $ms.ToArray())
                            }
                            else {
                                [System.IO.File]::WriteAllBytes($itemFilePath, $rawBytes)
                            }
                            Write-Verbose "Object '$([System.IO.Path]::GetFileNameWithoutExtension($itemFilename))' incompatible with JSON, saved as binary (.dat)"
                            $fallbackCount++
                            $writeSuccess = $true
                        }
                        catch {
                            Write-Verbose "Failed to serialize object '$([System.IO.Path]::GetFileNameWithoutExtension($itemFilename))' as binary: $_"
                            $failedCount++
                        }
                    }
                }
                else {
                    $currentDepth = $BinaryDepth
                    $serialized = $false
                    while (-not $serialized -and $currentDepth -le 10) {
                        try {
                            $xml = [System.Management.Automation.PSSerializer]::Serialize($item, $currentDepth)
                            $rawBytes = [System.Text.Encoding]::UTF8.GetBytes($xml)
                            if ($Compress) {
                                $ms = [System.IO.MemoryStream]::new()
                                $cs = [System.IO.Compression.GZipStream]::new($ms, [System.IO.Compression.CompressionLevel]::Optimal)
                                $cs.Write($rawBytes, 0, $rawBytes.Length)
                                $cs.Close()
                                [System.IO.File]::WriteAllBytes($itemFilePath, $ms.ToArray())
                            }
                            else {
                                [System.IO.File]::WriteAllBytes($itemFilePath, $rawBytes)
                            }
                            $serialized = $true
                            if ($currentDepth -gt $BinaryDepth) {
                                Write-Verbose "Binary serialization required depth $currentDepth (default: $BinaryDepth)"
                                $fallbackCount++
                            }
                        }
                        catch {
                            $currentDepth++
                        }
                    }
                    if (-not $serialized) {
                        Write-Verbose "Failed to serialize object with key '$([System.IO.Path]::GetFileNameWithoutExtension($itemFilename))' at any depth"
                        $failedCount++
                    }
                    else {
                        $writeSuccess = $true
                    }
                }

                if ($writeSuccess) {
                    $currentKey = [System.IO.Path]::GetFileNameWithoutExtension($itemFilename)
                    $savedCount++

                    if ($showProgress) {
                        $percent = if ($totalForItems -gt 0) { [math]::Round(($savedCount / $totalForItems) * 100) } else { 0 }
                        $activity = "Saving to '$Bucket'"
                        $status = "$savedCount object(s) saved"
                        Write-Progress -Activity $activity -Status $status -PercentComplete $percent -CurrentOperation $currentKey
                    }
                    elseif ($useVerbose) {
                        Write-Verbose "Saved [$Bucket/$currentKey] -> $itemFilePath"
                    }
                }

                $index++
            }
        }
        else {
            $item = $InputObject

            $itemFilename = if (-not [string]::IsNullOrWhiteSpace($Key)) {
                $keyValue = $item.$Key
                if ($null -eq $keyValue) {
                    Write-Verbose "Property '$Key' not found on object, skipping"
                    $skippedCount++
                    return
                }
                $safeKey = $keyValue -replace '[\\/:\*\?"<>\|\.\[\]]', '_'
                if ([string]::IsNullOrWhiteSpace($safeKey) -or $safeKey -match '^_+$') {
                    Write-Verbose "Key for object is empty after sanitization ('$keyValue' -> '$safeKey'), skipping"
                    $skippedCount++
                    return
                }
                "${safeKey}${extension}"
            }
            elseif ($AsTimestamp) {
                "$(Get-Date -Format 'yyyyMMddHHmmssfff')_0${extension}"
            }
            else {
                "$([Guid]::NewGuid())${extension}"
            }

            $itemFilePath = Join-Path $bucketPath $itemFilename

            if ([System.IO.File]::Exists($itemFilePath) -and -not $Overwrite) {
                Write-Verbose "Object with key '$([System.IO.Path]::GetFileNameWithoutExtension($itemFilename))' already exists in bucket '$Bucket'. Use -Overwrite to replace."
                $skippedCount++
                return
            }

            $writeSuccess = $false
            if ($AsJson) {
                try {
                    $json = ConvertTo-Json -InputObject $item -Depth $Depth -Compress -WarningAction SilentlyContinue
                    [System.IO.File]::WriteAllText($itemFilePath, $json, [System.Text.Encoding]::UTF8)
                    $writeSuccess = $true
                }
                catch {
                    try {
                        $xml = [System.Management.Automation.PSSerializer]::Serialize($item, $BinaryDepth)
                        $rawBytes = [System.Text.Encoding]::UTF8.GetBytes($xml)
                        $itemFilePath = [System.IO.Path]::ChangeExtension($itemFilePath, ".dat")
                        if ($Compress) {
                            $ms = [System.IO.MemoryStream]::new()
                            $cs = [System.IO.Compression.GZipStream]::new($ms, [System.IO.Compression.CompressionLevel]::Optimal)
                            $cs.Write($rawBytes, 0, $rawBytes.Length)
                            $cs.Close()
                            [System.IO.File]::WriteAllBytes($itemFilePath, $ms.ToArray())
                        }
                        else {
                            [System.IO.File]::WriteAllBytes($itemFilePath, $rawBytes)
                        }
                        Write-Verbose "Object '$([System.IO.Path]::GetFileNameWithoutExtension($itemFilename))' incompatible with JSON, saved as binary (.dat)"
                        $fallbackCount++
                        $writeSuccess = $true
                    }
                    catch {
                        Write-Verbose "Failed to serialize object '$([System.IO.Path]::GetFileNameWithoutExtension($itemFilename))' as binary: $_"
                        $failedCount++
                    }
                }
            }
            else {
                $currentDepth = $BinaryDepth
                $serialized = $false
                while (-not $serialized -and $currentDepth -le 10) {
                    try {
                        $xml = [System.Management.Automation.PSSerializer]::Serialize($item, $currentDepth)
                        $rawBytes = [System.Text.Encoding]::UTF8.GetBytes($xml)
                        if ($Compress) {
                            $ms = [System.IO.MemoryStream]::new()
                            $cs = [System.IO.Compression.GZipStream]::new($ms, [System.IO.Compression.CompressionLevel]::Optimal)
                            $cs.Write($rawBytes, 0, $rawBytes.Length)
                            $cs.Close()
                            [System.IO.File]::WriteAllBytes($itemFilePath, $ms.ToArray())
                        }
                        else {
                            [System.IO.File]::WriteAllBytes($itemFilePath, $rawBytes)
                        }
                        $serialized = $true
                        if ($currentDepth -gt $BinaryDepth) {
                            Write-Verbose "Binary serialization required depth $currentDepth (default: $BinaryDepth)"
                            $fallbackCount++
                        }
                    }
                    catch {
                        $currentDepth++
                    }
                }
                if (-not $serialized) {
                    Write-Verbose "Failed to serialize object with key '$([System.IO.Path]::GetFileNameWithoutExtension($itemFilename))' at any depth"
                    $failedCount++
                }
                else {
                    $writeSuccess = $true
                }
            }

            if ($writeSuccess) {
                $currentKey = [System.IO.Path]::GetFileNameWithoutExtension($itemFilename)
                $savedCount++

                if ($showProgress) {
                    $percent = if ($totalCount -gt 0) { [math]::Round(($savedCount / $totalCount) * 100) } else { 0 }
                    $activity = "Saving to '$Bucket'"
                    $status = "$savedCount object(s) saved"
                    Write-Progress -Activity $activity -Status $status -PercentComplete $percent -CurrentOperation $currentKey
                }
                elseif ($useVerbose) {
                    Write-Verbose "Saved [$Bucket/$currentKey] -> $itemFilePath"
                }
            }
        }
    }

    end {
        if ($useBuffering -and $itemsToProcess.Count -gt 0) {
            $arrayId = $null
            if ($itemsToProcess.Count -gt 1 -and -not [string]::IsNullOrWhiteSpace($Key)) {
                $arrayId = [Guid]::NewGuid().ToString()
            }

            $totalForItems = $itemsToProcess.Count
            $index = 0
            foreach ($item in $itemsToProcess) {
                if ($null -ne $arrayId) {
                    if ($item -is [hashtable]) {
                        $item._ArrayId = $arrayId
                        $item._ArrayIndex = $index
                    }
                    else {
                        $item | Add-Member -NotePropertyName "_ArrayId" -NotePropertyValue $arrayId -Force
                        $item | Add-Member -NotePropertyName "_ArrayIndex" -NotePropertyValue $index -Force
                    }
                }

                $itemFilename = if (-not [string]::IsNullOrWhiteSpace($Key)) {
                    $keyValue = $item.$Key
                    if ($null -eq $keyValue) {
                        Write-Verbose "Property '$Key' not found on object, skipping"
                        $skippedCount++
                        $index++
                        continue
                    }
                    $safeKey = $keyValue -replace '[\\/:\*\?"<>\|\.\[\]]', '_'
                    if ([string]::IsNullOrWhiteSpace($safeKey) -or $safeKey -match '^_+$') {
                        Write-Verbose "Key for object is empty after sanitization ('$keyValue' -> '$safeKey'), skipping"
                        $skippedCount++
                        $index++
                        continue
                    }
                    "${safeKey}${extension}"
                }
                elseif ($AsTimestamp) {
                    "$(Get-Date -Format 'yyyyMMddHHmmssfff')_${index}${extension}"
                }
                else {
                    "$([Guid]::NewGuid())${extension}"
                }

                $itemFilePath = Join-Path $bucketPath $itemFilename

                if ([System.IO.File]::Exists($itemFilePath) -and -not $Overwrite) {
                    Write-Verbose "Object with key '$([System.IO.Path]::GetFileNameWithoutExtension($itemFilename))' already exists in bucket '$Bucket'. Use -Overwrite to replace."
                    $skippedCount++
                    $index++
                    continue
                }

                $writeSuccess = $false
                if ($AsJson) {
                    try {
                        $json = ConvertTo-Json -InputObject $item -Depth $Depth -Compress -WarningAction SilentlyContinue
                        [System.IO.File]::WriteAllText($itemFilePath, $json, [System.Text.Encoding]::UTF8)
                        $writeSuccess = $true
                    }
                    catch {
                        try {
                            $xml = [System.Management.Automation.PSSerializer]::Serialize($item, $BinaryDepth)
                            $rawBytes = [System.Text.Encoding]::UTF8.GetBytes($xml)
                            $itemFilePath = [System.IO.Path]::ChangeExtension($itemFilePath, ".dat")
                            if ($Compress) {
                                $ms = [System.IO.MemoryStream]::new()
                                $cs = [System.IO.Compression.GZipStream]::new($ms, [System.IO.Compression.CompressionLevel]::Optimal)
                                $cs.Write($rawBytes, 0, $rawBytes.Length)
                                $cs.Close()
                                [System.IO.File]::WriteAllBytes($itemFilePath, $ms.ToArray())
                            }
                            else {
                                [System.IO.File]::WriteAllBytes($itemFilePath, $rawBytes)
                            }
                            Write-Verbose "Object '$([System.IO.Path]::GetFileNameWithoutExtension($itemFilename))' incompatible with JSON, saved as binary (.dat)"
                            $fallbackCount++
                            $writeSuccess = $true
                        }
                        catch {
                            Write-Verbose "Failed to serialize object '$([System.IO.Path]::GetFileNameWithoutExtension($itemFilename))' as binary: $_"
                            $failedCount++
                        }
                    }
                }
                else {
                    $currentDepth = $BinaryDepth
                    $serialized = $false
                    while (-not $serialized -and $currentDepth -le 10) {
                        try {
                            $xml = [System.Management.Automation.PSSerializer]::Serialize($item, $currentDepth)
                            $rawBytes = [System.Text.Encoding]::UTF8.GetBytes($xml)
                            if ($Compress) {
                                $ms = [System.IO.MemoryStream]::new()
                                $cs = [System.IO.Compression.GZipStream]::new($ms, [System.IO.Compression.CompressionLevel]::Optimal)
                                $cs.Write($rawBytes, 0, $rawBytes.Length)
                                $cs.Close()
                                [System.IO.File]::WriteAllBytes($itemFilePath, $ms.ToArray())
                            }
                            else {
                                [System.IO.File]::WriteAllBytes($itemFilePath, $rawBytes)
                            }
                            $serialized = $true
                            if ($currentDepth -gt $BinaryDepth) {
                                Write-Verbose "Binary serialization required depth $currentDepth (default: $BinaryDepth)"
                                $fallbackCount++
                            }
                        }
                        catch {
                            $currentDepth++
                        }
                    }
                    if (-not $serialized) {
                        Write-Verbose "Failed to serialize object with key '$([System.IO.Path]::GetFileNameWithoutExtension($itemFilename))' at any depth"
                        $failedCount++
                    }
                    else {
                        $writeSuccess = $true
                    }
                }

                if ($writeSuccess) {
                    $currentKey = [System.IO.Path]::GetFileNameWithoutExtension($itemFilename)
                    $savedCount++
                }

                $index++
            }
        }

        if ($showProgress) {
            Write-Progress -Activity "Saving to '$Bucket'" -Completed
            $summary = "Saved $savedCount object(s) to '$Bucket'"
            if ($Compress) { $summary += " (compressed)" }
            Write-Host $summary -ForegroundColor Green
            if ($skippedCount -gt 0) {
                Write-Host " $skippedCount skipped (existing or missing key)" -ForegroundColor Yellow
            }
            if ($fallbackCount -gt 0) {
                Write-Host " $fallbackCount required auto-incremented depth or binary fallback" -ForegroundColor DarkYellow
            }
            if ($failedCount -gt 0) {
                Write-Host " $failedCount failed to serialize" -ForegroundColor Red
            }
        }
    }
}

function Convert-HashtableToPSCustomObject {
    param($obj)

    if ($obj -is [hashtable]) {
        $ordered = [ordered]@{}
        foreach ($kvp in $obj.GetEnumerator()) {
            $ordered[$kvp.Key] = $kvp.Value
        }
        return [PSCustomObject]$ordered
    }
    return $obj
}

function Read-BucketFile {
    param(
        [System.IO.FileInfo]$File
    )

    if ($null -eq $File -or -not [System.IO.File]::Exists($File.FullName)) { return $null }

    $extension = $File.Extension
    $rawBytes = [System.IO.File]::ReadAllBytes($File.FullName)

    if ($extension -eq ".dat") {
        try {
            $decoded = $null
            $isCompressed = $rawBytes.Length -ge 2 -and $rawBytes[0] -eq 0x1F -and $rawBytes[1] -eq 0x8B
            if ($isCompressed) {
                try {
                    $ms = [System.IO.MemoryStream]::new($rawBytes)
                    $decompressed = [System.IO.Compression.GZipStream]::new($ms, [System.IO.Compression.CompressionMode]::Decompress)
                    $reader = [System.IO.StreamReader]::new($decompressed)
                    $decoded = $reader.ReadToEnd()
                    $reader.Close()
                    $decompressed.Close()
                }
                catch {
                    Write-Warning "Failed to decompress '$($File.Name)': $_"
                    return $null
                }
            }
            else {
                $decoded = [System.Text.Encoding]::UTF8.GetString($rawBytes)
                if (-not $decoded.StartsWith('<Objs') -and -not $decoded.StartsWith('<?xml')) {
                    $decoded = [System.Text.Encoding]::UTF8.GetString([System.Convert]::FromBase64String($decoded))
                }
            }
            return Convert-HashtableToPSCustomObject ([System.Management.Automation.PSSerializer]::Deserialize($decoded))
        }
        catch {
            Write-Warning "Failed to deserialize '$($File.Name)': $_"
            return $null
        }
    }
    else {
        try {
            $content = [System.Text.Encoding]::UTF8.GetString($rawBytes)
            if ($content.StartsWith([char]0xFEFF)) {
                $content = $content.Substring(1)
            }
            return Convert-HashtableToPSCustomObject ($content | ConvertFrom-Json)
        }
        catch {
            Write-Warning "Failed to parse JSON '$($File.Name)': $_"
            return $null
        }
    }
}

function Get-ObjectFiles {
    param(
        [string]$BucketPath,
        [string]$Key
    )

    if (-not [string]::IsNullOrWhiteSpace($Key)) {
        $di = [System.IO.DirectoryInfo]::new($BucketPath)
        $target = $Key.ToLowerInvariant()
        foreach ($f in @($di.GetFiles("*.json")) + @($di.GetFiles("*.dat"))) {
            $base = [System.IO.Path]::GetFileNameWithoutExtension($f.Name)
            if ($base.ToLowerInvariant() -eq $target) { return @($f) }
        }
        return @()
    }
    else {
        $di = [System.IO.DirectoryInfo]::new($BucketPath)
        return @($di.GetFiles("*.json")) + @($di.GetFiles("*.dat"))
    }
}

function Find-ObjectFile {
    param(
        [string]$BucketPath,
        [string]$Key
    )

    if ([string]::IsNullOrWhiteSpace($Key) -or -not [System.IO.Directory]::Exists($BucketPath)) { return $null }

    $di = [System.IO.DirectoryInfo]::new($BucketPath)
    $target = $Key.ToLowerInvariant()
    foreach ($f in @($di.GetFiles("*.json")) + @($di.GetFiles("*.dat"))) {
        $base = [System.IO.Path]::GetFileNameWithoutExtension($f.Name)
        if ($base.ToLowerInvariant() -eq $target) { return $f }
    }
    return $null
}

function Get-ObjectProperty {
    param(
        [PSObject]$Object,
        [string]$PropertyName
    )

    $hasValue = $false
    $value = $null

    if ($Object -is [hashtable]) {
        if ($Object.ContainsKey($PropertyName)) {
            $hasValue = $true
            $value = $Object[$PropertyName]
        }
    }
    elseif ($null -ne $Object.PSObject.Properties[$PropertyName]) {
        $hasValue = $true
        $value = $Object.$PropertyName
    }

    return @{ HasValue = $hasValue; Value = $value }
}

function Get-BucketObject {
    <#
    .SYNOPSIS
    Retrieves objects from one or more buckets.
    .DESCRIPTION
    Reads serialized objects from bucket directories. When no bucket is specified,
    searches all buckets under the storage path. Supports exact-match hashtable
    filtering (-Match) and arbitrary scriptblock filtering (-Filter).
    Retrieved objects include metadata properties: _BucketName, _BucketKey, _BucketFile.
    .PARAMETER Bucket
    Bucket name(s) to search. If omitted, searches all buckets under -Path. Supports wildcards.
    .PARAMETER Path
    Root directory for bucket storage. Default: $PWD/.buckets.
    .PARAMETER Key
    Specific object key to retrieve. Looks for both .json and .dat files. Case-insensitive.
    .PARAMETER Match
    Hashtable of property-value pairs for exact-match filtering. All pairs must match. Supports $null values.
    .PARAMETER Filter
    ScriptBlock for custom filtering. Use $_ to reference object properties (e.g., { $_.Age -gt 30 }).
    .PARAMETER First
    Return only the first N objects (or arrays when -GroupArrays is used).
    .PARAMETER Skip
    Skip the first N objects (or arrays when -GroupArrays is used) before returning results.
    .PARAMETER GroupArrays
    Reassemble stored arrays from individual files. Objects saved as part of a collection
    carry _ArrayId and _ArrayIndex metadata; this parameter groups objects by _ArrayId,
    sorts by _ArrayIndex, and returns each group as a wrapper object with properties:
    _ArrayGroup ($true) and _ArrayItems (the array of objects). Strips _ArrayId and _ArrayIndex
    from the grouped objects. Objects without array metadata are returned as-is.
    .OUTPUTS
    Deserialized PSObjects with _BucketName, _BucketKey, and _BucketFile metadata.
    When -GroupArrays is used, array groups are returned as objects with _ArrayGroup and _ArrayItems.
    .EXAMPLE
    Get-BucketObject -Bucket users -Match @{ Role = "admin" }
    .EXAMPLE
    Get-BucketObject -Bucket users -Match @{ Deleted = $null }
    .EXAMPLE
    Get-BucketObject -Filter { $_.Status -eq "shipped" -and $_.Shipping.Method -eq "Express" }
    .EXAMPLE
    Get-BucketObject -Bucket users, orders
    .EXAMPLE
    Get-BucketObject -First 10 -Skip 20
    .EXAMPLE
    Get-BucketObject -Bucket orders -Key "ORD-001" -GroupArrays
    #>

    [CmdletBinding()]
    param(
        [Parameter(Position = 1)]
        [string[]]$Bucket,

        [string]$Path,

        [Parameter(Position = 0)]
        [string]$Key,

        [hashtable]$Match,

        [scriptblock]$Filter,

        [int]$First,

        [int]$Skip,

        [switch]$GroupArrays
    )

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

    $bucketPaths = @()
    if ($Bucket -and $Bucket.Count -gt 0) {
        $cachedBuckets = $null
        foreach ($b in $Bucket) {
            if ($b -match '[\*\?]') {
                if ($null -eq $cachedBuckets) {
                    $cachedBuckets = Get-Bucket -Path $Path
                }
                $matched = $cachedBuckets | Where-Object { $_.Name -like $b }
                $bucketPaths += $matched | ForEach-Object { $_.Path }
            }
            else {
                $bucketPaths += Get-BucketPath -Name $b -Path $Path
            }
        }
    }
    else {
        if ([System.IO.Directory]::Exists($Path)) {
            $bucketPaths += [System.IO.DirectoryInfo]::new($Path).GetDirectories() | ForEach-Object { $_.FullName }
        }
    }

    $allObjects = [System.Collections.ArrayList]::new()

    foreach ($bucketPath in $bucketPaths) {
        if (-not [System.IO.Directory]::Exists($bucketPath)) { continue }

        $bucketName = Split-Path $bucketPath -Leaf

        $files = Get-ObjectFiles -BucketPath $bucketPath -Key $Key

        foreach ($file in $files) {
            if ($null -eq $file -or -not [System.IO.File]::Exists($file.FullName)) { continue }

            $obj = Read-BucketFile -File $file
            if ($null -eq $obj) { continue }

            if ($Match) {
                $hit = $true
                foreach ($kvp in $Match.GetEnumerator()) {
                    $propName = $kvp.Name
                    $expectedValue = $kvp.Value
                    $prop = Get-ObjectProperty -Object $obj -PropertyName $propName

                    $matchesValue = if ($null -eq $expectedValue) {
                        -not $prop.HasValue
                    }
                    elseif (-not $prop.HasValue) {
                        $false
                    }
                    else {
                        $prop.Value -eq $expectedValue
                    }

                    if (-not $matchesValue) {
                        $hit = $false
                        break
                    }
                }
                if (-not $hit) { continue }
            }

            if ($Filter) {
                if ($null -eq ($obj | Where-Object $Filter)) { continue }
            }

            $obj | Add-Member -NotePropertyName "_BucketName" -NotePropertyValue $bucketName -Force
            $obj | Add-Member -NotePropertyName "_BucketKey" -NotePropertyValue ([System.IO.Path]::GetFileNameWithoutExtension($file.Name)) -Force
            $obj | Add-Member -NotePropertyName "_BucketFile" -NotePropertyValue $file.FullName -Force
            $null = $allObjects.Add($obj)
        }
    }

    if ($GroupArrays -and $allObjects.Count -gt 0) {
        $groups = [System.Collections.Generic.Dictionary[string, System.Collections.ArrayList]]::new()
        $singles = [System.Collections.ArrayList]::new()

        foreach ($obj in $allObjects) {
            $hasArrayId = if ($obj -is [hashtable]) { $obj.ContainsKey('_ArrayId') } else { $null -ne $obj.PSObject.Properties['_ArrayId'] }
            if ($hasArrayId) {
                $arrayId = if ($obj -is [hashtable]) { $obj._ArrayId } else { $obj._ArrayId }
                if (-not $groups.ContainsKey($arrayId)) {
                    $groups[$arrayId] = [System.Collections.ArrayList]::new()
                }
                $null = $groups[$arrayId].Add($obj)
            }
            else {
                $null = $singles.Add($obj)
            }
        }

        $output = [System.Collections.ArrayList]::new()

        foreach ($arrayId in $groups.Keys) {
            $group = [System.Collections.ArrayList]::new()
            $sorted = if ($groups[$arrayId][0] -is [hashtable]) {
                $groups[$arrayId] | Sort-Object -Property { $_['_ArrayIndex'] }
            } else {
                $groups[$arrayId] | Sort-Object -Property _ArrayIndex
            }
            foreach ($item in $sorted) {
                if ($item -is [hashtable]) {
                    $item.Remove('_ArrayId')
                    $item.Remove('_ArrayIndex')
                }
                else {
                    $item.PSObject.Properties.Remove('_ArrayId')
                    $item.PSObject.Properties.Remove('_ArrayIndex')
                }
                $null = $group.Add($item)
            }
            $arrayGroup = [PSCustomObject]@{
                _ArrayGroup = $true
                _ArrayItems = $group.ToArray()
            }
            $null = $output.Add($arrayGroup)
        }

        foreach ($obj in $singles) {
            $null = $output.Add($obj)
        }

        if ($Skip -gt 0) {
            $output = $output | Select-Object -Skip $Skip
        }
        if ($First -gt 0) {
            $output = $output | Select-Object -First $First
        }

        foreach ($item in $output) {
            Write-Output $item
        }
    }
    else {
        $emitted = 0
        $skipped = 0
        foreach ($obj in $allObjects) {
            if ($Skip -gt 0 -and $skipped -lt $Skip) {
                $skipped++
                continue
            }
            if ($First -gt 0 -and $emitted -ge $First) { break }
            Write-Output $obj
            $emitted++
        }
    }
}

function Set-BucketObject {
    <#
    .SYNOPSIS
    Updates an existing object in a bucket.
    .DESCRIPTION
    Automatically detects whether the pipeline input is a full object replacement or a partial update.

    If the piped object contains _BucketName and _BucketKey metadata (from Get-BucketObject),
    the entire object replaces the stored version. If the piped object lacks metadata, only
    its properties are merged into the existing object (partial update).

    Preserves the storage format (JSON or binary) of the existing file. If JSON serialization
    fails on complex types, falls back to binary format.
    .PARAMETER InputObject
    The object to store. Accepts pipeline input. If it has _BucketName and _BucketKey metadata,
    bucket and key are auto-resolved. Otherwise -Bucket and -Key are required.
    .PARAMETER Bucket
    Name of the bucket containing the object. Auto-resolved from pipeline metadata if omitted.
    Required when piping partial updates.
    .PARAMETER Key
    Object key to update. Auto-resolved from pipeline metadata if omitted.
    Required when piping partial updates.
    .PARAMETER Path
    Root directory for bucket storage. Default: $PWD/.buckets.
    .PARAMETER Depth
    Maximum depth for JSON serialization. Default: 20.
    .PARAMETER BinaryDepth
    Maximum depth for binary (PSSerializer) serialization. Default: 2.
    .PARAMETER AsJson
    Force JSON format for the updated file.
    .PARAMETER Compress
    Enable GZip compression for binary (.dat) files.
    .PARAMETER Quiet
    Suppress all output. No summary.
    .EXAMPLE
    # Full replacement: object has metadata from Get-BucketObject
    $user = Get-BucketObject -Bucket users -Key "Alice"
    $user.Role = "manager"
    $user | Set-BucketObject

    .EXAMPLE
    # Partial update: only specified properties are merged into the existing object
    Set-BucketObject -InputObject @{ Role = "admin" } -Bucket users -Key Name

    .EXAMPLE
    # Quiet mode with no output
    Get-BucketObject -Bucket logs -Key "log-001" | ForEach-Object { $_.Level = "INFO"; $_ } | Set-BucketObject -Quiet
    #>

    [CmdletBinding()]
    param(
        [Parameter(Mandatory = $true, ValueFromPipeline = $true, ValueFromPipelineByPropertyName = $true)]
        [PSObject]$InputObject,

        [Parameter(ValueFromPipelineByPropertyName = $true)]
        [Alias("_BucketName")]
        [string]$Bucket,

        [Parameter(ValueFromPipelineByPropertyName = $true)]
        [Alias("_BucketKey")]
        [string]$Key,

        [string]$Path,

        [ValidateRange(1, 100)]
        [int]$Depth = 20,

        [ValidateRange(1, 10)]
        [int]$BinaryDepth = 2,

        [switch]$AsJson,

        [switch]$Compress,

        [switch]$Quiet
    )

    begin {
        $bucketPath = $null
        $savedCount = 0
        $useVerbose = $VerbosePreference -eq 'Continue'
        $useQuiet = $Quiet.IsPresent
    }

    process {
        $isPatch = -not ($InputObject.PSObject.Properties['_BucketName'] -and $InputObject.PSObject.Properties['_BucketKey'])

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

        if ($isPatch) {
            if ([string]::IsNullOrWhiteSpace($Bucket) -or [string]::IsNullOrWhiteSpace($Key)) {
                throw "When piping partial updates, you must specify -Bucket and -Key explicitly."
            }
        }
        else {
            if ([string]::IsNullOrWhiteSpace($Bucket) -or [string]::IsNullOrWhiteSpace($Key)) {
                if ($InputObject.PSObject.Properties['_BucketName']) {
                    $Bucket = $InputObject._BucketName
                }
                if ($InputObject.PSObject.Properties['_BucketKey']) {
                    $Key = $InputObject._BucketKey
                }
                if ([string]::IsNullOrWhiteSpace($Bucket) -or [string]::IsNullOrWhiteSpace($Key)) {
                    throw "Cannot determine bucket and key. Use -Bucket and -Key parameters, or pipe an object from Get-BucketObject."
                }
            }
        }

        # Extract key value from property name (consistent with New-BucketObject)
        if ($InputObject.PSObject.Properties[$Key]) {
            $resolvedKey = $InputObject.$Key
            if ($null -ne $resolvedKey) {
                $Key = $resolvedKey -replace '[\\/:\*\?"<>\|\.\[\]]', '_'
            }
        }

        if ($null -eq $bucketPath) {
            $bucketPath = Get-BucketPath -Name $Bucket -Path $Path
            if (-not [System.IO.Directory]::Exists($bucketPath)) {
                throw "Bucket '$Bucket' not found at '$bucketPath'"
            }
        }

        $file = Find-ObjectFile -BucketPath $bucketPath -Key $Key
        if ($null -eq $file) {
            throw "Object with key '$Key' not found in bucket '$Bucket'"
        }

        $filePath = $file.FullName
        $useJson = $file.Extension -eq ".json" -or $AsJson

        if ($isPatch) {
            $existing = Read-BucketFile -File ([System.IO.FileInfo]::new($filePath))
            if ($null -eq $existing) {
                throw "Failed to read existing object '$Key' in bucket '$Bucket'"
            }
            if ($InputObject -is [hashtable]) {
                if ($existing -is [hashtable]) {
                    foreach ($kvp in $InputObject.GetEnumerator()) {
                        $existing[$kvp.Key] = $kvp.Value
                    }
                }
                else {
                    foreach ($kvp in $InputObject.GetEnumerator()) {
                        if ($existing.PSObject.Properties[$kvp.Key]) {
                            $existing.PSObject.Properties[$kvp.Key].Value = $kvp.Value
                        }
                        else {
                            $existing | Add-Member -NotePropertyName $kvp.Key -NotePropertyValue $kvp.Value
                        }
                    }
                }
            }
            else {
                foreach ($prop in $InputObject.PSObject.Properties) {
                    if ($prop.IsSettable) {
                        if ($existing -is [hashtable]) {
                            $existing[$prop.Name] = $prop.Value
                        }
                        elseif ($existing.PSObject.Properties[$prop.Name]) {
                            $existing.PSObject.Properties[$prop.Name].Value = $prop.Value
                        }
                        else {
                            $existing | Add-Member -NotePropertyName $prop.Name -NotePropertyValue $prop.Value
                        }
                    }
                }
            }
            $InputObject = $existing
        }

        $writeSuccess = $false
        if ($useJson) {
            try {
                $json = ConvertTo-Json -InputObject $InputObject -Depth $Depth -Compress -WarningAction SilentlyContinue
                [System.IO.File]::WriteAllText($filePath, $json, [System.Text.Encoding]::UTF8)
                $writeSuccess = $true
            }
            catch {
                try {
                    $xml = [System.Management.Automation.PSSerializer]::Serialize($InputObject, $BinaryDepth)
                    $rawBytes = [System.Text.Encoding]::UTF8.GetBytes($xml)
                    if (Test-Path $filePath) { Remove-Item $filePath -Force }
                    $filePath = [System.IO.Path]::ChangeExtension($filePath, ".dat")
                    if ($Compress) {
                        $ms = [System.IO.MemoryStream]::new()
                        $cs = [System.IO.Compression.GZipStream]::new($ms, [System.IO.Compression.CompressionLevel]::Optimal)
                        $cs.Write($rawBytes, 0, $rawBytes.Length)
                        $cs.Close()
                        [System.IO.File]::WriteAllBytes($filePath, $ms.ToArray())
                    }
                    else {
                        [System.IO.File]::WriteAllBytes($filePath, $rawBytes)
                    }
                    Write-Verbose "Object '$Key' incompatible with JSON, saved as binary (.dat)"
                    $writeSuccess = $true
                }
                catch {
                    throw "Failed to serialize object '$Key' as binary: $_"
                }
            }
        }
        else {
            $currentDepth = $BinaryDepth
            $serialized = $false
            while (-not $serialized -and $currentDepth -le 10) {
                try {
                    $xml = [System.Management.Automation.PSSerializer]::Serialize($InputObject, $currentDepth)
                    $rawBytes = [System.Text.Encoding]::UTF8.GetBytes($xml)
                    if ($Compress) {
                        $ms = [System.IO.MemoryStream]::new()
                        $cs = [System.IO.Compression.GZipStream]::new($ms, [System.IO.Compression.CompressionLevel]::Optimal)
                        $cs.Write($rawBytes, 0, $rawBytes.Length)
                        $cs.Close()
                        [System.IO.File]::WriteAllBytes($filePath, $ms.ToArray())
                    }
                    else {
                        [System.IO.File]::WriteAllBytes($filePath, $rawBytes)
                    }
                    $serialized = $true
                    if ($currentDepth -gt $BinaryDepth) {
                        Write-Verbose "Binary serialization required depth $currentDepth (default: $BinaryDepth)"
                    }
                }
                catch {
                    $currentDepth++
                }
            }
            if (-not $serialized) {
                throw "Failed to serialize object '$Key' at any binary depth"
            }
            $writeSuccess = $true
        }

        if ($writeSuccess) {
            $savedCount++
            if ($useVerbose) {
                Write-Verbose "Updated [$Bucket/$Key] -> $filePath"
            }
            elseif (-not $useQuiet) {
                $result = [PSCustomObject]@{
                    Bucket   = $Bucket
                    Key      = $Key
                    FilePath = $filePath
                }
                Write-Output $result
            }
        }
    }

    end {
        if ($savedCount -gt 0 -and -not $useVerbose -and -not $useQuiet) {
            Write-Host "Updated $savedCount object(s) in '$Bucket'" -ForegroundColor Green
        }
    }
}

function Remove-BucketObject {
    <#
    .SYNOPSIS
    Removes an object from a bucket.
    .DESCRIPTION
    Deletes a specific object file from a bucket directory. Use -Key to remove a single
    object, -All to clear the entire bucket, or -Match/-Filter for bulk deletion.
    .PARAMETER Bucket
    Name of the bucket containing the object(s) to remove.
    .PARAMETER Path
    Root directory for bucket storage. Default: $PWD/.buckets.
    .PARAMETER Key
    Object key to remove. Looks for both .json and .dat files. Case-insensitive.
    .PARAMETER All
    Remove all objects from the bucket.
    .PARAMETER Match
    Hashtable of property-value pairs for bulk deletion. All pairs must match. Supports $null values.
    .PARAMETER Filter
    ScriptBlock for custom bulk deletion. Use $_ to reference object properties.
    .PARAMETER PassThru
    Return metadata for removed objects.
    .EXAMPLE
    # Remove a single log entry by Id
    Remove-BucketObject -Bucket logs -Key "log-003"

    .EXAMPLE
    # Remove all objects from a bucket
    Remove-BucketObject -Bucket temp -All -PassThru

    .EXAMPLE
    # Remove all inactive users
    Remove-BucketObject -Bucket users -Match @{ Active = $false } -PassThru

    .EXAMPLE
    # Remove objects matching a scriptblock
    Remove-BucketObject -Bucket orders -Filter { $_.Status -eq "cancelled" }

    .EXAMPLE
    # Preview removal without executing
    Remove-BucketObject -Bucket users -Key "Charlie" -WhatIf
    #>

    [CmdletBinding(SupportsShouldProcess = $true, ConfirmImpact = 'Medium', DefaultParameterSetName = 'ByKey')]
    param(
        [Parameter(Mandatory = $true)]
        [string]$Bucket,

        [string]$Path,

        [Parameter(ParameterSetName = 'ByKey')]
        [string]$Key,

        [Parameter(ParameterSetName = 'All')]
        [switch]$All,

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

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

        [switch]$PassThru,

        [switch]$Quiet
    )

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

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

    if ($All) {
        $di = [System.IO.DirectoryInfo]::new($bucketPath)
        $allFiles = @($di.GetFiles("*.json")) + @($di.GetFiles("*.dat"))

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

        $target = "$($allFiles.Count) object(s) from bucket '$Bucket'"
        if ($PSCmdlet.ShouldProcess($target, "Remove-BucketObject")) {
            $allFiles | ForEach-Object { [System.IO.File]::Delete($_.FullName) }
        }

        if ($PassThru) {
            foreach ($f in $allFiles) {
                [PSCustomObject]@{
                    Bucket   = $Bucket
                    Key      = [System.IO.Path]::GetFileNameWithoutExtension($f.Name)
                    FilePath = $f.FullName
                }
            }
        }
        elseif (-not $WhatIfPreference) {
            Write-Verbose "Removed $($allFiles.Count) object(s) from bucket '$Bucket'"
        }
    }
    elseif (-not [string]::IsNullOrWhiteSpace($Key)) {
        $file = Find-ObjectFile -BucketPath $bucketPath -Key $Key

        if ($null -eq $file) {
            Write-Warning "Object with key '$Key' not found in bucket '$Bucket'"
        }
        elseif ($PSCmdlet.ShouldProcess("object '$Key' from bucket '$Bucket'", "Remove-BucketObject")) {
            if ($PassThru) {
                [PSCustomObject]@{
                    Bucket   = $Bucket
                    Key      = $Key
                    FilePath = $file.FullName
                }
            }
            [System.IO.File]::Delete($file.FullName)
        }
    }
    elseif ($PSCmdlet.ParameterSetName -eq 'ByFilter') {
        $di = [System.IO.DirectoryInfo]::new($bucketPath)
        $allFiles = @($di.GetFiles("*.json")) + @($di.GetFiles("*.dat"))

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

        $matchedFiles = @()
        foreach ($file in $allFiles) {
            $obj = Read-BucketFile -File $file
            if ($null -eq $obj) { continue }

            if ($Match) {
                $hit = $true
                foreach ($kvp in $Match.GetEnumerator()) {
                    $propName = $kvp.Name
                    $expectedValue = $kvp.Value
                    $prop = Get-ObjectProperty -Object $obj -PropertyName $propName

                    $matchesValue = if ($null -eq $expectedValue) {
                        -not $prop.HasValue
                    }
                    elseif (-not $prop.HasValue) {
                        $false
                    }
                    else {
                        $prop.Value -eq $expectedValue
                    }

                    if (-not $matchesValue) {
                        $hit = $false
                        break
                    }
                }
                if (-not $hit) { continue }
            }

            if ($Filter) {
                if ($null -eq ($obj | Where-Object $Filter)) { continue }
            }

            $matchedFiles += $file
        }

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

        $target = "$($matchedFiles.Count) matching object(s) from bucket '$Bucket'"
        if ($PSCmdlet.ShouldProcess($target, "Remove-BucketObject")) {
            foreach ($f in $matchedFiles) {
                if ($PassThru) {
                    [PSCustomObject]@{
                        Bucket   = $Bucket
                        Key      = [System.IO.Path]::GetFileNameWithoutExtension($f.Name)
                        FilePath = $f.FullName
                    }
                }
                [System.IO.File]::Delete($f.FullName)
            }
        }
        elseif (-not $WhatIfPreference) {
            Write-Verbose "Would remove $($matchedFiles.Count) object(s) from bucket '$Bucket'"
        }
    }
    else {
        throw "Specify either -Key, -All, or -Match/-Filter"
    }
}

function Get-BucketKeys {
    <#
    .SYNOPSIS
    Lists object keys in a bucket without deserializing objects.
    .DESCRIPTION
    Fast key enumeration that reads filenames only, avoiding the overhead of
    deserializing object data. Returns keys with their file format and size.
    .PARAMETER Bucket
    Bucket name to scan. If omitted, scans all buckets under -Path. Supports wildcards.
    .PARAMETER Path
    Root directory for bucket storage. Default: $PWD/.buckets.
    .PARAMETER Match
    Filter keys by pattern (wildcard). Case-insensitive.
    .OUTPUTS
    PSCustomObject with Bucket, Key, Format, and Size properties.
    .EXAMPLE
    Get-BucketKeys -Bucket users
    .EXAMPLE
    Get-BucketKeys -Match "*admin*"
    .EXAMPLE
    Get-BucketKeys | Where-Object { $_.Format -eq "json" }
    #>

    [CmdletBinding()]
    param(
        [Parameter(Position = 0)]
        [string]$Bucket,

        [string]$Path,

        [string]$Match
    )

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

    $bucketPaths = @()
    if (-not [string]::IsNullOrWhiteSpace($Bucket)) {
        if ($Bucket -match '[\*\?]') {
            $cachedBuckets = Get-Bucket -Path $Path
            $matched = $cachedBuckets | Where-Object { $_.Name -like $Bucket }
            $bucketPaths += $matched | ForEach-Object { $_.Path }
        }
        else {
            $bucketPaths += Get-BucketPath -Name $Bucket -Path $Path
        }
    }
    else {
        if ([System.IO.Directory]::Exists($Path)) {
            $bucketPaths += [System.IO.DirectoryInfo]::new($Path).GetDirectories() | ForEach-Object { $_.FullName }
        }
    }

    foreach ($bucketPath in $bucketPaths) {
        if (-not [System.IO.Directory]::Exists($bucketPath)) { continue }

        $bucketName = Split-Path $bucketPath -Leaf
        $di = [System.IO.DirectoryInfo]::new($bucketPath)
        $files = @($di.GetFiles("*.json")) + @($di.GetFiles("*.dat"))

        foreach ($f in $files) {
            $key = [System.IO.Path]::GetFileNameWithoutExtension($f.Name)

            if (-not [string]::IsNullOrWhiteSpace($Match) -and $key -notlike $Match) { continue }

            [PSCustomObject]@{
                Bucket = $bucketName
                Key    = $key
                Format = if ($f.Extension -eq ".json") { "json" } else { "dat" }
                Size   = $f.Length
            }
        }
    }
}

function Get-Bucket {
    <#
    .SYNOPSIS
    Lists available buckets with object counts.
    .DESCRIPTION
    Scans the storage path for bucket directories and returns information about each,
    including name, path, and total object count (JSON + binary files).
    .PARAMETER Path
    Root directory for bucket storage. Default: $PWD/.buckets.
    .PARAMETER Name
    Filter buckets by name pattern (substring match).
    .OUTPUTS
    PSCustomObject with Name, Path, and ObjectCount properties.
    .EXAMPLE
    Get-Bucket
    .EXAMPLE
    Get-Bucket "user"
    #>

    [CmdletBinding()]
    param(
        [Parameter(Position = 0)]
        [string]$Name,

        [string]$Path
    )

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

    if (-not [System.IO.Directory]::Exists($Path)) {
        return
    }

    $buckets = @([System.IO.DirectoryInfo]::new($Path).GetDirectories())

    if (-not [string]::IsNullOrWhiteSpace($Name)) {
        $buckets = $buckets | Where-Object { $_.Name -like "*$Name*" }
    }

    $buckets | ForEach-Object {
        $bucketDir = $_
        $datFiles = [System.IO.Directory]::GetFiles($bucketDir.FullName, "*.dat")
        $jsonFiles = [System.IO.Directory]::GetFiles($bucketDir.FullName, "*.json")
        [PSCustomObject]@{
            Name        = $bucketDir.Name
            Path        = $bucketDir.FullName
            ObjectCount = $datFiles.Length + $jsonFiles.Length
        }
    }
}

function Get-BucketStats {
    <#
    .SYNOPSIS
    Shows statistics for a bucket.
    .DESCRIPTION
    Returns object count, total storage size, and oldest/newest object timestamps
    for the specified bucket. Returns $null if the bucket does not exist.
    .PARAMETER Bucket
    Name of the bucket to analyze.
    .PARAMETER Path
    Root directory for bucket storage. Default: $PWD/.buckets.
    .OUTPUTS
    PSCustomObject with Name, Path, ObjectCount, TotalSize, OldestObject, and NewestObject properties.
    .EXAMPLE
    Get-BucketStats -Bucket users
    #>

    [CmdletBinding()]
    param(
        [Parameter(Mandatory = $true)]
        [string]$Bucket,

        [string]$Path
    )

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

    if (-not [System.IO.Directory]::Exists($bucketPath)) {
        Write-Warning "Bucket '$Bucket' not found at '$bucketPath'"
        return
    }

    $di = [System.IO.DirectoryInfo]::new($bucketPath)
    $datFiles = @($di.GetFiles("*.dat"))
    $jsonFiles = @($di.GetFiles("*.json"))
    $fileObjects = $datFiles + $jsonFiles

    $totalSize = ($fileObjects | Measure-Object -Property Length -Sum).Sum

    $oldest = $null
    $newest = $null
    foreach ($f in $fileObjects) {
        if ($null -eq $oldest -or $f.CreationTime -lt $oldest) { $oldest = $f.CreationTime }
        if ($null -eq $newest -or $f.CreationTime -gt $newest) { $newest = $f.CreationTime }
    }

    [PSCustomObject]@{
        Name         = $Bucket
        Path         = $bucketPath
        ObjectCount  = $fileObjects.Count
        TotalSize    = if ($totalSize) { "$([math]::Round($totalSize / 1KB, 2)) KB" } else { "0 KB" }
        OldestObject = $oldest
        NewestObject = $newest
    }
}

function Remove-Bucket {
    <#
    .SYNOPSIS
    Removes one or more buckets and all their objects.
    .DESCRIPTION
    Deletes bucket directories and their contents. Supports exact names, multiple
    buckets, and wildcard patterns. Only removes directories containing .dat/.json
    files (or empty directories). Skips buckets with other file types.
    Uses standard -Confirm/-WhatIf support (SupportsShouldProcess).
    -Confirm:$false skips the confirmation prompt.
    .PARAMETER Bucket
    Bucket name(s) or wildcard patterns to remove. Supports glob-style wildcards (*, ?).
    .PARAMETER Path
    Root directory for bucket storage. Default: $PWD/.buckets.
    .PARAMETER WhatIf
    Preview which buckets would be removed without actually deleting them.
    .PARAMETER Confirm
    Prompt for confirmation before removal. Default: prompts (ConfirmImpact = High).
    Use -Confirm:$false to skip.
    .EXAMPLE
    Remove-Bucket -Bucket users
    .EXAMPLE
    Remove-Bucket -Bucket "temp*" -Confirm:$false
    .EXAMPLE
    Remove-Bucket * -WhatIf
    #>

    [CmdletBinding(SupportsShouldProcess = $true, ConfirmImpact = 'High')]
    param(
        [Parameter(Mandatory = $true, Position = 0, ValueFromRemainingArguments = $true)]
        [string[]]$Bucket,

        [string]$Path,

        [switch]$Force
    )

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

    $allBuckets = @()
    if ([System.IO.Directory]::Exists($Path)) {
        $allBuckets = @([System.IO.DirectoryInfo]::new($Path).GetDirectories()) | ForEach-Object {
            [PSCustomObject]@{
                Name = $_.Name
                Path = $_.FullName
            }
        }
    }

    $matched = @()
    foreach ($pattern in $Bucket) {
        if ($pattern -match '[\*\?]') {
            $found = $allBuckets | Where-Object { $_.Name -like $pattern }
            if (-not $found) {
                Write-Warning "No buckets match pattern '$pattern'"
            }
            $matched += $found
        }
        elseif ($pattern -eq "*") {
            $matched += $allBuckets
        }
        else {
            $exact = $allBuckets | Where-Object { $_.Name -eq $pattern }
            if ($exact) {
                $matched += $exact
            }
            else {
                Write-Warning "Bucket '$pattern' not found at '$Path'"
            }
        }
    }

    if ($matched.Count -eq 0) { return }

    $removable = @()
    $skippedBuckets = @()
    foreach ($m in $matched) {
        $resolvedRoot = Resolve-SafePath -Path $Path
        $resolvedBucket = Resolve-SafePath -Path $m.Path
        if (-not $resolvedBucket.StartsWith($resolvedRoot, [System.StringComparison]::OrdinalIgnoreCase)) {
            $skippedBuckets += [PSCustomObject]@{ Name = $m.Name; Reason = "path resolves outside root" }
            continue
        }

        $di = [System.IO.DirectoryInfo]::new($m.Path)
        $subDirs = @($di.GetDirectories())
        if ($subDirs.Count -gt 0) {
            $skippedBuckets += [PSCustomObject]@{ Name = $m.Name; Reason = "contains subdirectories" }
            continue
        }

        $allFiles = @($di.GetFiles())
        $otherFiles = @($allFiles | Where-Object { $_.Extension -notin ".dat", ".json" })

        if ($otherFiles.Count -gt 0) {
            $skippedBuckets += [PSCustomObject]@{
                Name   = $m.Name
                Reason = "contains $($otherFiles.Count) non-bucket file(s): $($otherFiles.Name -join ', ')"
            }
            continue
        }

        $datFiles = @($di.GetFiles("*.dat"))
        $jsonFiles = @($di.GetFiles("*.json"))
        $stats = Get-BucketStats -Bucket $m.Name -Path $Path
        $removable += [PSCustomObject]@{
            Name       = $m.Name
            Objects    = if ($stats) { $stats.ObjectCount } else { 0 }
            Size       = if ($stats) { $stats.TotalSize } else { "0 KB" }
            Path       = $m.Path
        }
    }

    if ($removable.Count -eq 0 -and $skippedBuckets.Count -eq 0) { return }

    if ($WhatIfPreference) {
        if ($removable.Count -gt 0) {
            Write-Host " What if: Remove the following bucket(s):" -ForegroundColor Yellow
            foreach ($r in $removable) {
                Write-Host " $($r.Name) ($($r.Objects) objects, $($r.Size))" -ForegroundColor DarkGray
            }
        }
        if ($skippedBuckets.Count -gt 0) {
            Write-Host "`n Skipped:" -ForegroundColor Yellow
            foreach ($s in $skippedBuckets) {
                Write-Host " $($s.Name) — $($s.Reason)" -ForegroundColor Red
            }
        }
        return
    }

    if ($removable.Count -eq 0 -and $skippedBuckets.Count -eq 0) { return }

    $removedCount = 0
    foreach ($r in $removable) {
        $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
        }
        $finalDirs = @($finalDi.GetDirectories())
        if ($finalDirs.Count -gt 0) {
            Write-Warning "Bucket '$($r.Name)' now contains subdirectories, aborting"
            continue
        }

        $target = "bucket '$($r.Name)' ($($r.Objects) object(s), $($r.Size))"
        if ($PSCmdlet.ShouldProcess($target, "Remove-Bucket")) {
            Write-Verbose "Removing bucket '$($r.Name)' ($($r.Objects) object(s))"
            [System.IO.Directory]::Delete($r.Path, $true)
            $cacheKeys = @($script:BucketPathCache.Keys) | Where-Object { $_ -like "*|$($r.Name)" }
            foreach ($ck in $cacheKeys) { $script:BucketPathCache.Remove($ck) }
            $removedCount++
        }
    }

    if ($removedCount -gt 0) {
        Write-Host " Removed $removedCount bucket(s)" -ForegroundColor Green
    }
    if ($skippedBuckets.Count -gt 0) {
        Write-Host "`n Skipped:" -ForegroundColor Yellow
        foreach ($s in $skippedBuckets) {
            Write-Host " $($s.Name) — $($s.Reason)" -ForegroundColor Red
        }
    }
}

function Copy-BucketObject {
    <#
    .SYNOPSIS
    Copies an object within or between buckets.
    .DESCRIPTION
    Duplicates an object file from one bucket to another, optionally changing the key.
    Preserves the original serialization format (JSON or binary).
    .PARAMETER Bucket
    Source bucket name.
    .PARAMETER DestinationBucket
    Destination bucket name. Defaults to the same as -Bucket if omitted.
    .PARAMETER Path
    Root directory for bucket storage. Default: $PWD/.buckets.
    .PARAMETER Key
    Source object key to copy.
    .PARAMETER DestinationKey
    Destination object key. Defaults to the source key if omitted.
    .PARAMETER PassThru
    Return metadata for the copied object.
    .EXAMPLE
    Copy-BucketObject -Bucket users -Key "Alice" -DestinationBucket archive
    .EXAMPLE
    Copy-BucketObject -Bucket config -Key "app-config" -DestinationKey "app-config-backup"
    #>

    [CmdletBinding()]
    param(
        [Parameter(Mandatory = $true)]
        [string]$Bucket,

        [string]$DestinationBucket,

        [string]$Path,

        [Parameter(Mandatory = $true)]
        [string]$Key,

        [string]$DestinationKey,

        [switch]$PassThru,

        [switch]$Quiet
    )

    if ([string]::IsNullOrWhiteSpace($Path)) { $Path = Get-DefaultPath }
    $Path = Resolve-SafePath -Path $Path
    $sourceBucketPath = Get-BucketPath -Name $Bucket -Path $Path
    if (-not [System.IO.Directory]::Exists($sourceBucketPath)) {
        throw "Source bucket '$Bucket' not found at '$sourceBucketPath'"
    }

    if ([string]::IsNullOrWhiteSpace($DestinationBucket)) {
        $DestinationBucket = $Bucket
    }
    if ([string]::IsNullOrWhiteSpace($DestinationKey)) {
        $DestinationKey = $Key
    }

    $safeDestKey = $DestinationKey -replace '[\\/:\*\?"<>\|\.\[\]]', '_'
    if ([string]::IsNullOrWhiteSpace($safeDestKey) -or $safeDestKey -match '^_+$') {
        throw "Destination key '$DestinationKey' is invalid after sanitization"
    }

    $sourceFile = Find-ObjectFile -BucketPath $sourceBucketPath -Key $Key
    if ($null -eq $sourceFile) {
        throw "Object with key '$Key' not found in bucket '$Bucket'"
    }

    $destBucketPath = Ensure-BucketExists -Name $DestinationBucket -Path $Path
    $destJsonPath = Join-Path $destBucketPath "${safeDestKey}.json"
    $destDatPath = Join-Path $destBucketPath "${safeDestKey}.dat"

    if ([System.IO.File]::Exists($destJsonPath) -or [System.IO.File]::Exists($destDatPath)) {
        throw "Object with key '$safeDestKey' already exists in bucket '$DestinationBucket'. Use a different key."
    }

    $ext = $sourceFile.Extension
    $destFile = Join-Path $destBucketPath "${safeDestKey}${ext}"

    [System.IO.File]::Copy($sourceFile, $destFile)
    Write-Verbose "Copied [$Bucket/$Key] to [$DestinationBucket/$safeDestKey]"

    if ($PassThru) {
        [PSCustomObject]@{
            SourceBucket = $Bucket
            SourceKey = $Key
            DestinationBucket = $DestinationBucket
            DestinationKey = $safeDestKey
            FilePath = $destFile
        }
    }
    elseif (-not $Quiet) {
        Write-Host "Copied '$Key' from '$Bucket' to '$DestinationBucket/$safeDestKey'" -ForegroundColor Green
    }
}

function Rename-BucketObject {
    <#
    .SYNOPSIS
    Renames an object key within a bucket.
    .DESCRIPTION
    Moves an object file to a new key within the same bucket without re-serialization.
    Preserves the original format (JSON or binary).
    .PARAMETER Bucket
    Bucket name.
    .PARAMETER Path
    Root directory for bucket storage. Default: $PWD/.buckets.
    .PARAMETER Key
    Current object key.
    .PARAMETER NewKey
    New object key.
    .PARAMETER PassThru
    Return metadata for the renamed object.
    .EXAMPLE
    Rename-BucketObject -Bucket users -Key "Alice" -NewKey "alice-smith"
    #>

    [CmdletBinding()]
    param(
        [Parameter(Mandatory = $true)]
        [string]$Bucket,

        [string]$Path,

        [Parameter(Mandatory = $true)]
        [string]$Key,

        [Parameter(Mandatory = $true)]
        [string]$NewKey,

        [switch]$PassThru,

        [switch]$Quiet
    )

    if ([string]::IsNullOrWhiteSpace($Path)) { $Path = Get-DefaultPath }
    $Path = Resolve-SafePath -Path $Path
    $bucketPath = Get-BucketPath -Name $Bucket -Path $Path
    if (-not [System.IO.Directory]::Exists($bucketPath)) {
        throw "Bucket '$Bucket' not found at '$bucketPath'"
    }

    $safeNewKey = $NewKey -replace '[\\/:\*\?"<>\|\.\[\]]', '_'
    if ([string]::IsNullOrWhiteSpace($safeNewKey) -or $safeNewKey -match '^_+$') {
        throw "New key '$NewKey' is invalid after sanitization"
    }

    $sourceFile = Find-ObjectFile -BucketPath $bucketPath -Key $Key
    if ($null -eq $sourceFile) {
        throw "Object with key '$Key' not found in bucket '$Bucket'"
    }

    $ext = $sourceFile.Extension
    $destJsonPath = Join-Path $bucketPath "${safeNewKey}.json"
    $destDatPath = Join-Path $bucketPath "${safeNewKey}.dat"
    if ([System.IO.File]::Exists($destJsonPath) -or [System.IO.File]::Exists($destDatPath)) {
        throw "Object with key '$safeNewKey' already exists in bucket '$Bucket'"
    }

    $destFile = Join-Path $bucketPath "${safeNewKey}${ext}"

    [System.IO.File]::Move($sourceFile, $destFile)
    Write-Verbose "Renamed [$Bucket/$Key] to [$Bucket/$safeNewKey]"

    if ($PassThru) {
        [PSCustomObject]@{
            Bucket = $Bucket
            OldKey = $Key
            NewKey = $safeNewKey
            FilePath = $destFile
        }
    }
    elseif (-not $Quiet) {
        Write-Host "Renamed '$Key' to '$safeNewKey' in bucket '$Bucket'" -ForegroundColor Green
    }
}

function Move-BucketObject {
    <#
    .SYNOPSIS
    Moves an object within or between buckets.
    .DESCRIPTION
    Moves an object file from one bucket to another (or within the same bucket),
    optionally changing the key. Deletes the source file after successful copy.
    Preserves the original serialization format (JSON or binary).
    .PARAMETER Bucket
    Source bucket name.
    .PARAMETER DestinationBucket
    Destination bucket name. Defaults to the same as -Bucket if omitted.
    .PARAMETER Path
    Root directory for bucket storage. Default: $PWD/.buckets.
    .PARAMETER Key
    Source object key to move.
    .PARAMETER DestinationKey
    Destination object key. Defaults to the source key if omitted.
    .PARAMETER PassThru
    Return metadata for the moved object.
    .PARAMETER Quiet
    Suppress all output.
    .EXAMPLE
    # Archive a log entry to a backup bucket
    Move-BucketObject -Bucket logs -Key "log-004" -DestinationBucket archive

    .EXAMPLE
    # Rename an order within the same bucket
    Move-BucketObject -Bucket orders -Key "ORD-001" -DestinationKey "ORD-legacy-001"
    #>

    [CmdletBinding()]
    param(
        [Parameter(Mandatory = $true)]
        [string]$Bucket,

        [string]$DestinationBucket,

        [string]$Path,

        [Parameter(Mandatory = $true)]
        [string]$Key,

        [string]$DestinationKey,

        [switch]$PassThru,

        [switch]$Quiet
    )

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

    $sourceBucketPath = Get-BucketPath -Name $Bucket -Path $Path
    if (-not [System.IO.Directory]::Exists($sourceBucketPath)) {
        throw "Source bucket '$Bucket' not found at '$sourceBucketPath'"
    }

    if ([string]::IsNullOrWhiteSpace($DestinationBucket)) {
        $DestinationBucket = $Bucket
    }
    if ([string]::IsNullOrWhiteSpace($DestinationKey)) {
        $DestinationKey = $Key
    }

    $safeDestKey = $DestinationKey -replace '[\\/:\*\?"<>\|\.\[\]]', '_'
    if ([string]::IsNullOrWhiteSpace($safeDestKey) -or $safeDestKey -match '^_+$') {
        throw "Destination key '$DestinationKey' is invalid after sanitization"
    }

    $sourceFile = Find-ObjectFile -BucketPath $sourceBucketPath -Key $Key
    if ($null -eq $sourceFile) {
        throw "Object with key '$Key' not found in bucket '$Bucket'"
    }

    $destBucketPath = Ensure-BucketExists -Name $DestinationBucket -Path $Path
    $destJsonPath = Join-Path $destBucketPath "${safeDestKey}.json"
    $destDatPath = Join-Path $destBucketPath "${safeDestKey}.dat"

    if ([System.IO.File]::Exists($destJsonPath) -or [System.IO.File]::Exists($destDatPath)) {
        throw "Object with key '$safeDestKey' already exists in bucket '$DestinationBucket'. Use a different key."
    }

    $ext = $sourceFile.Extension
    $destFile = Join-Path $destBucketPath "${safeDestKey}${ext}"

    [System.IO.File]::Copy($sourceFile, $destFile)
    [System.IO.File]::Delete($sourceFile)

    Write-Verbose "Moved [$Bucket/$Key] to [$DestinationBucket/$safeDestKey]"

    if ($PassThru) {
        [PSCustomObject]@{
            SourceBucket = $Bucket
            SourceKey = $Key
            DestinationBucket = $DestinationBucket
            DestinationKey = $safeDestKey
            FilePath = $destFile
        }
    }
    elseif (-not $Quiet) {
        Write-Host "Moved '$Key' from '$Bucket' to '$DestinationBucket/$safeDestKey'" -ForegroundColor Green
    }
}

function Export-Bucket {
    <#
    .SYNOPSIS
    Exports a bucket to a single archive file.
    .DESCRIPTION
    Serializes all objects in a bucket to a single JSON or CLIXML archive file.
    Includes object metadata (_BucketName, _BucketKey) for easy restoration.
    .PARAMETER Bucket
    Bucket name to export. Supports wildcards.
    .PARAMETER Path
    Root directory for bucket storage. Default: $PWD/.buckets.
    .PARAMETER OutputFile
    Path to the output archive file.
    .PARAMETER AsJson
    Export as JSON archive (default is CLIXML/PSSerializer).
    .PARAMETER Compress
    Enable GZip compression for CLIXML archives.
    .PARAMETER Quiet
    Suppress all output.
    .EXAMPLE
    Export-Bucket -Bucket users -OutputFile "./users-backup.clixml"
    .EXAMPLE
    Export-Bucket -Bucket "config*" -OutputFile "./config-backup.json" -AsJson
    #>

    [CmdletBinding()]
    param(
        [Parameter(Mandatory = $true)]
        [string[]]$Bucket,

        [string]$Path,

        [Parameter(Mandatory = $true)]
        [string]$OutputFile,

        [switch]$AsJson,

        [switch]$Compress,

        [switch]$Quiet
    )

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

    $allObjects = @()
    $exportedBuckets = 0
    $exportedObjects = 0

    foreach ($b in $Bucket) {
        $objects = Get-BucketObject -Bucket $b -Path $Path
        if ($objects) {
            $allObjects += $objects
            $exportedBuckets++
            $exportedObjects += @($objects).Count
        }
    }

    if ($allObjects.Count -eq 0) {
        Write-Warning "No objects found to export for buckets: $($Bucket -join ', ')"
        return
    }

    $outputDir = [System.IO.Path]::GetDirectoryName((Resolve-SafePath -Path $OutputFile))
    if (-not [System.IO.Directory]::Exists($outputDir)) {
        $null = [System.IO.Directory]::CreateDirectory($outputDir)
    }

    if ($AsJson) {
        $json = ConvertTo-Json -InputObject $allObjects -Depth 20 -Compress
        [System.IO.File]::WriteAllText($OutputFile, $json, [System.Text.Encoding]::UTF8)
    }
    else {
        $xml = [System.Management.Automation.PSSerializer]::Serialize($allObjects, 10)
        $rawBytes = [System.Text.Encoding]::UTF8.GetBytes($xml)
        if ($Compress) {
            $ms = [System.IO.MemoryStream]::new()
            $cs = [System.IO.Compression.GZipStream]::new($ms, [System.IO.Compression.CompressionLevel]::Optimal)
            $cs.Write($rawBytes, 0, $rawBytes.Length)
            $cs.Close()
            [System.IO.File]::WriteAllBytes($OutputFile, $ms.ToArray())
        }
        else {
            [System.IO.File]::WriteAllBytes($OutputFile, $rawBytes)
        }
    }

    if (-not $Quiet) {
        Write-Host "Exported $exportedObjects object(s) from $exportedBuckets bucket(s) to '$OutputFile'" -ForegroundColor Green
    }
}

function Import-Bucket {
    <#
    .SYNOPSIS
    Imports objects from an archive file into a bucket.
    .DESCRIPTION
    Reads objects from a CLIXML or JSON archive file and stores them in a bucket.
    Preserves original keys if objects have _BucketKey metadata; otherwise generates new keys.
    .PARAMETER Bucket
    Destination bucket name. Creates the bucket if it doesn't exist.
    .PARAMETER Path
    Root directory for bucket storage. Default: $PWD/.buckets.
    .PARAMETER InputFile
    Path to the archive file to import.
    .PARAMETER AsJson
    Force import from JSON format (auto-detected by file extension if omitted).
    .PARAMETER Overwrite
    Overwrite existing objects with the same key.
    .PARAMETER Quiet
    Suppress all output.
    .EXAMPLE
    Import-Bucket -Bucket users -InputFile "./users-backup.clixml"
    .EXAMPLE
    Import-Bucket -Bucket config -InputFile "./config-backup.json" -Overwrite
    #>

    [CmdletBinding()]
    param(
        [Parameter(Mandatory = $true)]
        [string]$Bucket,

        [Parameter(Mandatory = $true)]
        [string]$InputFile,

        [switch]$AsJson,

        [switch]$Overwrite,

        [switch]$Quiet
    )

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

    if (-not [System.IO.File]::Exists($InputFile)) {
        throw "Input file '$InputFile' not found"
    }

    $rawBytes = [System.IO.File]::ReadAllBytes($InputFile)
    $useJson = $AsJson -or $InputFile -like "*.json"

    if ($useJson) {
        $content = [System.Text.Encoding]::UTF8.GetString($rawBytes)
        $objects = $content | ConvertFrom-Json
    }
    else {
        try {
            $isCompressed = $rawBytes.Length -ge 2 -and $rawBytes[0] -eq 0x1F -and $rawBytes[1] -eq 0x8B
            if ($isCompressed) {
                $ms = [System.IO.MemoryStream]::new($rawBytes)
                $decompressed = [System.IO.Compression.GZipStream]::new($ms, [System.IO.Compression.CompressionMode]::Decompress)
                $reader = [System.IO.StreamReader]::new($decompressed)
                $content = $reader.ReadToEnd()
                $reader.Close()
                $decompressed.Close()
                $objects = [System.Management.Automation.PSSerializer]::Deserialize($content)
            }
            else {
                $objects = [System.Management.Automation.PSSerializer]::Deserialize([System.Text.Encoding]::UTF8.GetString($rawBytes))
            }
        }
        catch {
            throw "Failed to deserialize archive file '$InputFile': $_"
        }
    }

    if ($null -eq $objects) {
        throw "Failed to deserialize archive file '$InputFile'"
    }

    $objectArray = @($objects)
    Write-Verbose "Loaded $($objectArray.Count) objects from '$InputFile'"

    $bucketPath = Ensure-BucketExists -Name $Bucket -Path $Path
    $importedCount = 0
    $skippedCount = 0

    foreach ($obj in $objectArray) {
        $key = if ($obj.PSObject.Properties['_BucketKey']) { $obj._BucketKey } else { [Guid]::NewGuid().ToString() }
        $safeKey = $key -replace '[\\/:\*\?"<>\|\.\[\]]', '_'
        if ([string]::IsNullOrWhiteSpace($safeKey) -or $safeKey -match '^_+$') {
            $safeKey = [Guid]::NewGuid().ToString()
        }

        $jsonPath = Join-Path $bucketPath "${safeKey}.json"
        $datPath = Join-Path $bucketPath "${safeKey}.dat"
        $filePath = $null
        if ([System.IO.File]::Exists($jsonPath)) { $filePath = $jsonPath }
        elseif ([System.IO.File]::Exists($datPath)) { $filePath = $datPath }

        if ($filePath -and -not $Overwrite) {
            Write-Verbose "Object with key '$safeKey' already exists in bucket '$Bucket'. Use -Overwrite to replace."
            $skippedCount++
            continue
        }

        $ext = if ($filePath) { [System.IO.Path]::GetExtension($filePath) } else { ".dat" }
        $finalPath = Join-Path $bucketPath "${safeKey}${ext}"

        if ($ext -eq ".json") {
            $json = ConvertTo-Json -InputObject $obj -Depth 20 -Compress
            [System.IO.File]::WriteAllText($finalPath, $json, [System.Text.Encoding]::UTF8)
        }
        else {
            $xml = [System.Management.Automation.PSSerializer]::Serialize($obj, 5)
            $rawBytes = [System.Text.Encoding]::UTF8.GetBytes($xml)
            [System.IO.File]::WriteAllBytes($finalPath, $rawBytes)
        }

        $importedCount++
    }

    if (-not $Quiet) {
        Write-Host "Imported $importedCount object(s) into '$Bucket'" -ForegroundColor Green
        if ($skippedCount -gt 0) {
            Write-Host " $skippedCount skipped (existing keys)" -ForegroundColor Yellow
        }
    }
}

# Only export public cmdlets — internal functions remain private
Export-ModuleMember -Function @(
    'New-BucketObject',
    'Get-BucketObject',
    'Set-BucketObject',
    'Remove-BucketObject',
    'Get-Bucket',
    'Get-BucketStats',
    'Get-BucketKeys',
    'Remove-Bucket',
    'Copy-BucketObject',
    'Rename-BucketObject',
    'Move-BucketObject',
    'Export-Bucket',
    'Import-Bucket'
)

# Tab completion for -Bucket and -DestinationBucket parameters
$script:CompleterBlock = {
    param($commandName, $parameterName, $wordToComplete, $commandAst, $fakeBoundParameters)

    $path = if ($fakeBoundParameters.ContainsKey('Path')) { $fakeBoundParameters['Path'] } else { Join-Path $PWD.Path ".buckets" }
    if (-not [System.IO.Directory]::Exists($path)) { return }

    [System.IO.DirectoryInfo]::new($path).GetDirectories("$wordToComplete*") | ForEach-Object {
        [System.Management.Automation.CompletionResult]::new($_.Name, $_.Name, 'ParameterValue', $_.Name)
    }
}

# Tab completion for -Key parameter (requires -Bucket to be specified)
$script:KeyCompleterBlock = {
    param($commandName, $parameterName, $wordToComplete, $commandAst, $fakeBoundParameters)

    $bucket = $fakeBoundParameters['Bucket']
    if (-not $bucket) { return }

    $path = if ($fakeBoundParameters.ContainsKey('Path')) { $fakeBoundParameters['Path'] } else { Join-Path $PWD.Path ".buckets" }
    $bucketPath = Join-Path $path $bucket
    if (-not [System.IO.Directory]::Exists($bucketPath)) { return }

    $di = [System.IO.DirectoryInfo]::new($bucketPath)
    $files = $di.GetFiles("$wordToComplete*.dat") + $di.GetFiles("$wordToComplete*.json")

    $files | ForEach-Object {
        $key = [System.IO.Path]::GetFileNameWithoutExtension($_.Name)
        [System.Management.Automation.CompletionResult]::new($key, $key, 'ParameterValue', "$($_.Extension.TrimStart('.')) key")
    }
}

@('New-BucketObject', 'Get-BucketObject', 'Set-BucketObject', 'Remove-BucketObject',
  'Get-BucketStats', 'Remove-Bucket', 'Copy-BucketObject', 'Rename-BucketObject',
  'Move-BucketObject', 'Export-Bucket', 'Import-Bucket') | ForEach-Object {
    Register-ArgumentCompleter -CommandName $_ -ParameterName Bucket -ScriptBlock $script:CompleterBlock
}

Register-ArgumentCompleter -CommandName Copy-BucketObject -ParameterName DestinationBucket -ScriptBlock $script:CompleterBlock
Register-ArgumentCompleter -CommandName Move-BucketObject -ParameterName DestinationBucket -ScriptBlock $script:CompleterBlock

# Key completion for cmdlets that take a -Key
@('Get-BucketObject', 'Set-BucketObject', 'Remove-BucketObject',
  'Copy-BucketObject', 'Rename-BucketObject', 'Move-BucketObject') | ForEach-Object {
    Register-ArgumentCompleter -CommandName $_ -ParameterName Key -ScriptBlock $script:KeyCompleterBlock
}