Support/Package/Schema/Eigenverft.Manifested.Sandbox.Package.DepotInventory.Management.ps1

<#
    Eigenverft.Manifested.Sandbox.Package - depot inventory management helpers.
#>


function Assert-PackageDepotId {
    [CmdletBinding()]
    param(
        [Parameter(Mandatory = $true)]
        [string]$DepotId
    )

    if ([string]::IsNullOrWhiteSpace($DepotId)) {
        throw 'DepotId must not be empty.'
    }
    if ($DepotId -notmatch '^[A-Za-z][A-Za-z0-9_-]*$') {
        throw "DepotId '$DepotId' is invalid. Use letters, numbers, '-' or '_' and start with a letter."
    }
}

function Get-PackageDepotInventoryEditInfo {
    [CmdletBinding()]
    param()

    $inventoryPath = Get-PackageDepotInventoryPath
    $documentInfo = Read-PackageJsonDocument -Path $inventoryPath
    Assert-PackageDepotInventorySchema -DepotInventoryDocumentInfo $documentInfo

    if (-not $documentInfo.Document.acquisitionEnvironment.PSObject.Properties['environmentSources'] -or
        $null -eq $documentInfo.Document.acquisitionEnvironment.environmentSources) {
        $documentInfo.Document.acquisitionEnvironment | Add-Member -MemberType NoteProperty -Name 'environmentSources' -Value ([pscustomobject]@{}) -Force
    }

    return $documentInfo
}

function Save-PackageDepotInventoryDocument {
    [CmdletBinding()]
    param(
        [Parameter(Mandatory = $true)]
        [psobject]$DocumentInfo
    )

    Assert-PackageDepotInventorySchema -DepotInventoryDocumentInfo $DocumentInfo

    $directory = Split-Path -Parent $DocumentInfo.Path
    if (-not [string]::IsNullOrWhiteSpace($directory)) {
        $null = New-Item -ItemType Directory -Path $directory -Force
    }

    $temporaryPath = '{0}.{1}.tmp' -f $DocumentInfo.Path, ([guid]::NewGuid().ToString('N'))
    try {
        $DocumentInfo.Document | ConvertTo-Json -Depth 30 | Set-Content -LiteralPath $temporaryPath -Encoding UTF8
        Move-Item -LiteralPath $temporaryPath -Destination $DocumentInfo.Path -Force
    }
    finally {
        if (Test-Path -LiteralPath $temporaryPath -PathType Leaf) {
            Remove-Item -LiteralPath $temporaryPath -Force -ErrorAction SilentlyContinue
        }
    }
}

function Get-PackageDepotSourceProperty {
    [CmdletBinding()]
    param(
        [Parameter(Mandatory = $true)]
        [psobject]$Document,

        [Parameter(Mandatory = $true)]
        [string]$DepotId
    )

    if (-not $Document.acquisitionEnvironment.PSObject.Properties['environmentSources'] -or
        $null -eq $Document.acquisitionEnvironment.environmentSources) {
        return $null
    }

    return $Document.acquisitionEnvironment.environmentSources.PSObject.Properties[$DepotId]
}

function Get-PackageNextDepotSearchOrder {
    [CmdletBinding()]
    param(
        [Parameter(Mandatory = $true)]
        [psobject]$Document
    )

    $maxSearchOrder = 0
    if ($Document.acquisitionEnvironment.PSObject.Properties['environmentSources'] -and
        $null -ne $Document.acquisitionEnvironment.environmentSources) {
        foreach ($sourceProperty in @($Document.acquisitionEnvironment.environmentSources.PSObject.Properties)) {
            if ($sourceProperty.Value -and $sourceProperty.Value.PSObject.Properties['searchOrder']) {
                $current = [int]$sourceProperty.Value.searchOrder
                if ($current -gt $maxSearchOrder) {
                    $maxSearchOrder = $current
                }
            }
        }
    }

    return ([int]([Math]::Ceiling(($maxSearchOrder + 1) / 100.0) * 100))
}

function Get-PackageDepotSearchOrderAfter {
    [CmdletBinding()]
    param(
        [Parameter(Mandatory = $true)]
        [psobject]$Document,

        [Parameter(Mandatory = $true)]
        [string]$AfterDepotId
    )

    $afterProperty = Get-PackageDepotSourceProperty -Document $Document -DepotId $AfterDepotId
    if (-not $afterProperty) {
        throw "Package depot '$AfterDepotId' was not found, so no searchOrder can be placed after it."
    }
    if (-not $afterProperty.Value.PSObject.Properties['searchOrder']) {
        throw "Package depot '$AfterDepotId' has no searchOrder."
    }

    $afterOrder = [int]$afterProperty.Value.searchOrder
    $nextHigherOrder = $null
    foreach ($sourceProperty in @($Document.acquisitionEnvironment.environmentSources.PSObject.Properties)) {
        if (-not $sourceProperty.Value -or -not $sourceProperty.Value.PSObject.Properties['searchOrder']) {
            continue
        }

        $currentOrder = [int]$sourceProperty.Value.searchOrder
        if ($currentOrder -gt $afterOrder -and ($null -eq $nextHigherOrder -or $currentOrder -lt $nextHigherOrder)) {
            $nextHigherOrder = $currentOrder
        }
    }

    if ($null -eq $nextHigherOrder) {
        return ($afterOrder + 100)
    }

    $candidate = [int][Math]::Floor(($afterOrder + $nextHigherOrder) / 2)
    if ($candidate -le $afterOrder -or $candidate -ge $nextHigherOrder) {
        throw "No integer searchOrder slot is available between '$AfterDepotId' ($afterOrder) and the next depot ($nextHigherOrder). Use -SearchOrder explicitly."
    }

    return $candidate
}

function New-PackageFilesystemDepotSource {
    [CmdletBinding()]
    param(
        [Parameter(Mandatory = $true)]
        [string]$BasePath,

        [Parameter(Mandatory = $true)]
        [int]$SearchOrder,

        [bool]$Enabled = $true,

        [string[]]$SiteCodes = @(),

        [bool]$Readable = $true,

        [bool]$Writable = $false,

        [bool]$MirrorTarget = $false,

        [bool]$EnsureExists = $false
    )

    if ([string]::IsNullOrWhiteSpace($BasePath)) {
        throw 'BasePath must not be empty.'
    }
    if ($MirrorTarget -and -not $Writable) {
        throw 'MirrorTarget requires Writable.'
    }
    if ($EnsureExists -and -not $Writable) {
        throw 'EnsureExists requires Writable.'
    }

    $source = [ordered]@{
        kind         = 'filesystem'
        enabled      = $Enabled
        searchOrder  = $SearchOrder
        basePath     = $BasePath
        readable     = $Readable
        writable     = $Writable
        mirrorTarget = $MirrorTarget
        ensureExists = $EnsureExists
    }

    $cleanSiteCodes = @($SiteCodes | ForEach-Object { ([string]$_).Trim() } | Where-Object { -not [string]::IsNullOrWhiteSpace($_) })
    if ($cleanSiteCodes.Count -gt 0) {
        $source['siteCodes'] = @($cleanSiteCodes)
    }

    return [pscustomobject]$source
}

function Resolve-PackageDepotBasePathForDisplay {
    [CmdletBinding()]
    param(
        [AllowNull()]
        [string]$BasePath,

        [AllowNull()]
        [string]$ApplicationRootDirectory
    )

    if ([string]::IsNullOrWhiteSpace($BasePath)) {
        return $null
    }

    try {
        if (-not [string]::IsNullOrWhiteSpace($ApplicationRootDirectory)) {
            return Resolve-PackageConfiguredPath -PathValue $BasePath -ApplicationRootDirectory $ApplicationRootDirectory
        }
        return Resolve-PackagePathValue -PathValue $BasePath
    }
    catch {
        return $null
    }
}

function Select-PackageDepotSummary {
    [CmdletBinding()]
    param(
        [Parameter(Mandatory = $true)]
        [string]$DepotId,

        [Parameter(Mandatory = $true)]
        [psobject]$Source,

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

        [AllowNull()]
        [psobject]$EffectiveSources = $null,

        [AllowNull()]
        [string]$ApplicationRootDirectory = $null,

        [AllowNull()]
        [string[]]$ActiveSiteCodes = @()
    )

    $enabled = if ($Source.PSObject.Properties['enabled']) { [bool]$Source.enabled } else { $true }
    $kind = if ($Source.PSObject.Properties['kind']) { [string]$Source.kind } else { $null }
    $searchOrder = if ($Source.PSObject.Properties['searchOrder']) { [int]$Source.searchOrder } else { $null }
    $basePath = if ($Source.PSObject.Properties['basePath']) { [string]$Source.basePath } else { $null }
    $siteCodes = if ($Source.PSObject.Properties['siteCodes'] -and $null -ne $Source.siteCodes) { @($Source.siteCodes) } else { @() }
    $isEffective = $false
    if ($EffectiveSources -and $EffectiveSources.PSObject.Properties[$DepotId]) {
        $isEffective = $true
    }

    $notes = New-Object System.Collections.Generic.List[string]
    if (-not $enabled) {
        $notes.Add('Disabled; package acquisition will not use this depot.') | Out-Null
    }
    elseif ($siteCodes.Count -gt 0 -and -not $isEffective) {
        $notes.Add(('Enabled, but filtered out by active site codes. Active site codes: {0}.' -f ($(if (@($ActiveSiteCodes).Count -gt 0) { @($ActiveSiteCodes) -join ';' } else { '<none>' })))) | Out-Null
    }
    elseif ($enabled -and -not $isEffective) {
        $notes.Add('Enabled, but not present in the effective acquisition environment.') | Out-Null
    }

    if ([string]::Equals($kind, 'filesystem', [System.StringComparison]::OrdinalIgnoreCase)) {
        if ($Source.PSObject.Properties['readable'] -and -not [bool]$Source.readable) {
            $notes.Add('Not readable; it will not be used as an acquisition source.') | Out-Null
        }
        if ($Source.PSObject.Properties['mirrorTarget'] -and [bool]$Source.mirrorTarget) {
            $notes.Add('Mirror target; verified downloads may be copied here.') | Out-Null
        }
        if ($Source.PSObject.Properties['writable'] -and -not [bool]$Source.writable) {
            $notes.Add('Read-only from Package perspective; no depot directories will be created or mirrored here.') | Out-Null
        }
    }

    return [pscustomobject]@{
        DepotId          = $DepotId
        Kind             = $kind
        Enabled          = $enabled
        Effective        = $isEffective
        SearchOrder      = $searchOrder
        BasePath         = $basePath
        ResolvedBasePath = Resolve-PackageDepotBasePathForDisplay -BasePath $basePath -ApplicationRootDirectory $ApplicationRootDirectory
        Readable         = if ($Source.PSObject.Properties['readable']) { [bool]$Source.readable } else { $null }
        Writable         = if ($Source.PSObject.Properties['writable']) { [bool]$Source.writable } else { $null }
        MirrorTarget     = if ($Source.PSObject.Properties['mirrorTarget']) { [bool]$Source.mirrorTarget } else { $null }
        EnsureExists     = if ($Source.PSObject.Properties['ensureExists']) { [bool]$Source.ensureExists } else { $null }
        SiteCodes        = @($siteCodes)
        InventoryPath    = $InventoryPath
        Notes            = @($notes.ToArray())
    }
}

function Get-PackageDepotSummaries {
    [CmdletBinding()]
    param()

    $documentInfo = Get-PackageDepotInventoryEditInfo
    $stateConfig = $null
    try {
        $stateConfig = Get-PackageStateConfig
    }
    catch {
        Write-Warning "Depot summaries could not resolve the effective acquisition environment. Showing raw depot inventory only. $($_.Exception.Message)"
    }

    $effectiveSources = if ($stateConfig) { $stateConfig.EnvironmentSources } else { $null }
    $applicationRootDirectory = if ($stateConfig) { [string]$stateConfig.ApplicationRootDirectory } else { $null }
    $activeSiteCodes = if ($stateConfig -and $stateConfig.EffectiveAcquisitionEnvironment) { @($stateConfig.EffectiveAcquisitionEnvironment.SiteCodes) } else { @() }

    foreach ($sourceProperty in @($documentInfo.Document.acquisitionEnvironment.environmentSources.PSObject.Properties)) {
        Select-PackageDepotSummary -DepotId $sourceProperty.Name -Source $sourceProperty.Value -InventoryPath $documentInfo.Path -EffectiveSources $effectiveSources -ApplicationRootDirectory $applicationRootDirectory -ActiveSiteCodes $activeSiteCodes
    }
}

function New-PackageDepotCommandResult {
    [CmdletBinding()]
    param(
        [Parameter(Mandatory = $true)]
        [string]$Action,

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

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

        [AllowNull()]
        [psobject]$Before,

        [AllowNull()]
        [psobject]$After,

        [string]$Status = 'Updated',

        [string[]]$Notes = @()
    )

    foreach ($note in @($Notes)) {
        if (-not [string]::IsNullOrWhiteSpace($note)) {
            Write-Warning $note
        }
    }

    return [pscustomobject]@{
        Action        = $Action
        DepotId       = $DepotId
        InventoryPath = $InventoryPath
        Status        = $Status
        Before        = $Before
        After         = $After
        Notes         = @($Notes)
    }
}