InvokeZEP.psm1

# Invoke-ZEP
# Lokale Sitzungen lesen das Bearer-Token für die hhpberlin-ZEP-Instanz aus dem Windows Credential Manager.
# In Azure Automation Runbooks wird das Token aus Get-AutomationPSCredential gelesen.

# Version 0.1.0 06.08.2026 by Klaus Kupferschmid (tempero.it GmbH & hhpberlin GmbH)

#Requires -Modules @{ ModuleName = 'BetterCredentials'; ModuleVersion = '4.5' }

$script:ZEPSettings = [ordered]@{
    ServiceUserName = 'ZEP_hhpberlin'
    BaseUri = 'https://www.zep-online.de/zephhpberlin/next/api/v1'
    TokenTarget = 'ZEP_hhpberlin_BearerToken'
    CacheDirectory = (Join-Path $env:LOCALAPPDATA 'InvokeZEP')
    OfferCacheFileName = 'offers-cache.json'
    OfferItemCacheFileName = 'offer-items-cache.json'
    DefaultPageSize = 100
    DefaultThrottleDelaySeconds = 0.2
    DefaultCacheTtlMinutes = 60
}

function Update-ZEPDerivedSettings {
    $script:ZEP_cache_directory = $script:ZEPSettings.CacheDirectory
    $script:ZEP_offer_cache_path = Join-Path $script:ZEP_cache_directory $script:ZEPSettings.OfferCacheFileName
    $script:ZEP_offer_item_cache_path = Join-Path $script:ZEP_cache_directory $script:ZEPSettings.OfferItemCacheFileName
}

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

    return [pscustomobject]@{
        ServiceUserName = $script:ZEPSettings.ServiceUserName
        BaseUri = $script:ZEPSettings.BaseUri
        TokenTarget = $script:ZEPSettings.TokenTarget
        CacheDirectory = $script:ZEPSettings.CacheDirectory
        OfferCacheFileName = $script:ZEPSettings.OfferCacheFileName
        OfferItemCacheFileName = $script:ZEPSettings.OfferItemCacheFileName
        DefaultPageSize = $script:ZEPSettings.DefaultPageSize
        DefaultThrottleDelaySeconds = $script:ZEPSettings.DefaultThrottleDelaySeconds
        DefaultCacheTtlMinutes = $script:ZEPSettings.DefaultCacheTtlMinutes
    }
}

function Set-ZEPConfiguration {
    [CmdletBinding()]
    param (
        [Parameter(Mandatory = $false)][string]$ServiceUserName,
        [Parameter(Mandatory = $false)][string]$BaseUri,
        [Parameter(Mandatory = $false)][string]$TokenTarget,
        [Parameter(Mandatory = $false)][string]$CacheDirectory,
        [Parameter(Mandatory = $false)][int]$DefaultPageSize,
        [Parameter(Mandatory = $false)][double]$DefaultThrottleDelaySeconds,
        [Parameter(Mandatory = $false)][int]$DefaultCacheTtlMinutes
    )

    if ($PSBoundParameters.ContainsKey('ServiceUserName')) {
        $script:ZEPSettings.ServiceUserName = $ServiceUserName
    }

    if ($PSBoundParameters.ContainsKey('BaseUri')) {
        $script:ZEPSettings.BaseUri = $BaseUri.TrimEnd('/')
    }

    if ($PSBoundParameters.ContainsKey('TokenTarget')) {
        $script:ZEPSettings.TokenTarget = $TokenTarget
    }

    if ($PSBoundParameters.ContainsKey('CacheDirectory')) {
        $script:ZEPSettings.CacheDirectory = $CacheDirectory
    }

    if ($PSBoundParameters.ContainsKey('DefaultPageSize')) {
        $script:ZEPSettings.DefaultPageSize = $DefaultPageSize
    }

    if ($PSBoundParameters.ContainsKey('DefaultThrottleDelaySeconds')) {
        $script:ZEPSettings.DefaultThrottleDelaySeconds = $DefaultThrottleDelaySeconds
    }

    if ($PSBoundParameters.ContainsKey('DefaultCacheTtlMinutes')) {
        $script:ZEPSettings.DefaultCacheTtlMinutes = $DefaultCacheTtlMinutes
    }

    Update-ZEPDerivedSettings
    return Get-ZEPConfiguration
}

Update-ZEPDerivedSettings

function Initialize-AutomationEnvironment {
    if ($script:automationEnvironmentInitialized) {
        return
    }

    $script:automationEnvironmentInitialized = $true
    $script:env_runbook = $false

    try {
        if ($PSPrivateMetadata.JobId) {
            $script:env_runbook = $true
        }
    }
    catch {
        $script:env_runbook = $false
    }
}

function Convert-SecureStringToPlainText {
    param (
        [Parameter(Mandatory = $false)]
        [Security.SecureString] $SecureString
    )

    if ($null -eq $SecureString) {
        return ''
    }

    $bstr = [Runtime.InteropServices.Marshal]::SecureStringToBSTR($SecureString)
    try {
        return [Runtime.InteropServices.Marshal]::PtrToStringBSTR($bstr)
    }
    finally {
        if ($bstr -ne [IntPtr]::Zero) {
            [Runtime.InteropServices.Marshal]::ZeroFreeBSTR($bstr)
        }
    }
}

function Get-StoredCredentialSafe {
    param (
        [Parameter(Mandatory = $true)]
        [string] $Target
    )

    $globalErrorCountBefore = $global:Error.Count

    try {
        return Find-Credential -Filter $Target -ErrorAction Stop | Select-Object -First 1
    }
    catch {
        $message = $PSItem.Exception.Message
        $isExpectedNotFound = (
            $message -match 'Element.*(not found|nicht gefunden)' -or
            $message -match '(Credential|Element).*(not found|nicht gefunden)'
        )

        if ($isExpectedNotFound) {
            while ($global:Error.Count -gt $globalErrorCountBefore) {
                $global:Error.RemoveAt(0)
            }
            return $null
        }

        Write-Warning "Unerwarteter Fehler bei Find-Credential fuer Target '$Target': $message"
        return $null
    }
}

function Get-ZEPBearerToken {
    Initialize-AutomationEnvironment

    if ($env_runbook) {
        $automationCredentialCommand = Get-Command -Name Get-AutomationPSCredential -ErrorAction SilentlyContinue
        if (-not $automationCredentialCommand) {
            throw 'Get-AutomationPSCredential ist in dieser Runbook-Umgebung nicht verfuegbar.'
        }

        try {
            $credential = Get-AutomationPSCredential -Name $script:ZEPSettings.ServiceUserName -ErrorAction Stop
        }
        catch {
            throw "AutomationPSCredential mit dem Namen $($script:ZEPSettings.ServiceUserName) konnte nicht gelesen werden."
        }

        if (-not $credential) {
            throw "AutomationPSCredential mit dem Namen $($script:ZEPSettings.ServiceUserName) wurde nicht gefunden."
        }

        return Convert-SecureStringToPlainText -SecureString $credential.Password
    }

    $storedCredential = Get-StoredCredentialSafe -Target $script:ZEPSettings.TokenTarget
    if ($storedCredential) {
        return Convert-SecureStringToPlainText -SecureString $storedCredential.Password
    }

    Write-Host 'ZEP API-Token wird benoetigt' -ForegroundColor Yellow
    $credential = Microsoft.PowerShell.Security\Get-Credential -UserName $script:ZEPSettings.ServiceUserName -Message 'Geben Sie den ZEP API-Token ein'
    if (-not $credential) {
        throw 'ZEP API-Token wurde nicht eingegeben.'
    }

    Set-Credential -Target $script:ZEPSettings.TokenTarget -Credential $credential -Type Generic -Persistence Enterprise -Description 'ZEP API token' > $null
    return Convert-SecureStringToPlainText -SecureString $credential.Password
}

function Invoke-ZEPRest {
    [CmdletBinding()]
    param (
        [Parameter(Mandatory = $true)][string]$Path,
        [Parameter(Mandatory = $false)][string]$Method = 'GET',
        [Parameter(Mandatory = $false)][hashtable]$Query,
        [Parameter(Mandatory = $false)]$Body,
        [Parameter(Mandatory = $false)][int]$RetryCount = 2,
        [Parameter(Mandatory = $false)][double]$ThrottleDelaySeconds = $script:ZEPSettings.DefaultThrottleDelaySeconds
    )

    $token = Get-ZEPBearerToken
    $headers = @{
        Accept = 'application/json'
        Authorization = "Bearer $token"
    }

    $resourceUri = $script:ZEPSettings.BaseUri.TrimEnd('/')
    if ($Path) {
        $resourceUri = "$resourceUri/$($Path.TrimStart('/'))"
    }

    $uriBuilder = [System.UriBuilder]::new($resourceUri)
    if ($Query -and $Query.Count -gt 0) {
        $queryCollection = [System.Web.HttpUtility]::ParseQueryString([string]::Empty)
        foreach ($key in $Query.Keys) {
            $queryCollection[$key] = [string]$Query[$key]
        }

        $queryString = $queryCollection.ToString()
        if (-not [string]::IsNullOrWhiteSpace($queryString)) {
            $uriBuilder.Query = $queryString
        }
    }

    $validatedUri = [uri]::new($uriBuilder.Uri.AbsoluteUri)

    $invokeParams = @{
        Uri = $validatedUri
        Method = $Method
        Headers = $headers
        ErrorAction = 'Stop'
    }

    if ($Body) {
        $invokeParams.ContentType = 'application/json'
        $invokeParams.Body = $Body | ConvertTo-Json -Depth 20
    }

    for ($attempt = 0; $attempt -le $RetryCount; $attempt++) {
        try {
            if ($ThrottleDelaySeconds -gt 0) {
                Start-Sleep -Milliseconds ([int]($ThrottleDelaySeconds * 1000))
            }

            return Invoke-RestMethod @invokeParams
        }
        catch {
            $statusCode = $null
            if ($_.Exception.Response -and $_.Exception.Response.StatusCode) {
                $statusCode = [int]$_.Exception.Response.StatusCode
            }

            if ($attempt -lt $RetryCount -and ($statusCode -eq 429 -or $statusCode -eq 503)) {
                $backoffSeconds = [Math]::Min(10, [Math]::Pow(2, $attempt + 1))
                Start-Sleep -Seconds $backoffSeconds
                continue
            }

            throw
        }
    }
}

function ConvertTo-ZEPObject {
    param (
        [Parameter(ValueFromPipeline = $true)]
        $InputObject
    )

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

        if ($InputObject -is [string] -or $InputObject -is [bool] -or $InputObject -is [int] -or $InputObject -is [long] -or $InputObject -is [double] -or $InputObject -is [decimal] -or $InputObject -is [datetime]) {
            return $InputObject
        }

        if ($InputObject -is [System.Collections.IDictionary]) {
            $result = [pscustomobject]::new()
            foreach ($key in $InputObject.Keys) {
                $value = $InputObject[$key]
                if ($value -is [System.Collections.IEnumerable] -and -not ($value -is [string])) {
                    $value = @($value | ForEach-Object { ConvertTo-ZEPObject -InputObject $_ })
                }
                elseif ($value -is [pscustomobject] -or $value -is [hashtable]) {
                    $value = ConvertTo-ZEPObject -InputObject $value
                }
                $result | Add-Member -NotePropertyName $key -NotePropertyValue $value
            }
            return $result
        }

        if ($InputObject -is [System.Collections.IEnumerable] -and -not ($InputObject -is [string])) {
            return @($InputObject | ForEach-Object { ConvertTo-ZEPObject -InputObject $_ })
        }

        $result = [pscustomobject]::new()
        foreach ($property in $InputObject.PSObject.Properties) {
            $value = $property.Value
            if ($value -is [System.Collections.IEnumerable] -and -not ($value -is [string])) {
                $value = @($value | ForEach-Object { ConvertTo-ZEPObject -InputObject $_ })
            }
            elseif ($value -is [pscustomobject] -or $value -is [hashtable]) {
                $value = ConvertTo-ZEPObject -InputObject $value
            }
            $result | Add-Member -NotePropertyName $property.Name -NotePropertyValue $value
        }

        return $result
    }
}

function Get-ZEPResponseItems {
    param (
        [Parameter(Mandatory = $false)]$Payload
    )

    if ($null -eq $Payload) {
        return @()
    }

    if ($Payload -is [System.Collections.IEnumerable] -and -not ($Payload -is [string]) -and -not ($Payload -is [System.Collections.IDictionary])) {
        return @($Payload)
    }

    if ($Payload.PSObject.Properties.Name -contains 'data') {
        return Get-ZEPResponseItems -Payload $Payload.data
    }

    if ($Payload.PSObject.Properties.Name -contains 'items') {
        return Get-ZEPResponseItems -Payload $Payload.items
    }

    if ($Payload.PSObject.Properties.Name -contains 'offers') {
        return Get-ZEPResponseItems -Payload $Payload.offers
    }

    return @($Payload)
}

function Get-ZEPResponseTotal {
    param (
        [Parameter(Mandatory = $false)]$Payload
    )

    if ($null -eq $Payload) {
        return $null
    }

    if ($Payload -is [System.Collections.IDictionary]) {
        if ($Payload.Contains('meta')) {
            $meta = $Payload['meta']
            if ($meta -is [System.Collections.IDictionary] -and $meta.Contains('total')) {
                return [int]$meta['total']
            }

            if ($meta -and $meta.PSObject.Properties.Name -contains 'total') {
                return [int]$meta.total
            }
        }

        if ($Payload.Contains('data')) {
            return Get-ZEPResponseTotal -Payload $Payload.data
        }

        if ($Payload.Contains('total')) {
            return [int]$Payload.total
        }

        return $null
    }

    if ($Payload.PSObject.Properties.Name -contains 'meta') {
        $meta = $Payload.meta
        if ($meta -and $meta.PSObject.Properties.Name -contains 'total') {
            return [int]$meta.total
        }
    }

    if ($Payload.PSObject.Properties.Name -contains 'data') {
        return Get-ZEPResponseTotal -Payload $Payload.data
    }

    if ($Payload.PSObject.Properties.Name -contains 'total') {
        return [int]$Payload.total
    }

    return $null
}

function Test-ZEPOffersCacheNeedsRefresh {
    [CmdletBinding()]
    param (
        [Parameter(Mandatory = $false)]$CacheObject,
        [Parameter(Mandatory = $false)][int]$CurrentTotal,
        [Parameter(Mandatory = $false)][int]$CacheTtlMinutes = $script:ZEPSettings.DefaultCacheTtlMinutes
    )

    if (-not $CacheObject) {
        return $true
    }

    if ($PSBoundParameters.ContainsKey('CurrentTotal')) {
        $totalChanged = [int]$CacheObject.total -ne [int]$CurrentTotal
        if ($totalChanged) {
            return $true
        }

        $cachedItems = @($CacheObject.items)
        $itemCount = @($cachedItems).Count
        return $itemCount -ne [int]$CurrentTotal
    }

    $cachedTotal = $null
    if ($CacheObject.PSObject.Properties.Name -contains 'total' -and $null -ne $CacheObject.total) {
        $cachedTotal = [int]$CacheObject.total
    }

    if ($null -eq $cachedTotal) {
        return $true
    }

    try {
        $validationResponse = Invoke-ZEPRest -Path 'offers' -Query @{ limit = 1; page = 1 } -ThrottleDelaySeconds 0
        $validationTotal = Get-ZEPResponseTotal -Payload $validationResponse
        if ($null -eq $validationTotal) {
            return $false
        }

        if ([int]$validationTotal -ne [int]$cachedTotal) {
            return $true
        }

        $cachedItems = @($CacheObject.items)
        return @($cachedItems).Count -ne [int]$validationTotal
    }
    catch {
        return $false
    }
}

function Get-ZEPDefaultOfferCachePath {
    return $script:ZEP_offer_cache_path
}

function Get-ZEPDefaultOfferItemCachePath {
    return $script:ZEP_offer_item_cache_path
}

function Initialize-ZEPCacheStore {
    param (
        [Parameter(Mandatory = $false)][string]$Path = $script:ZEP_offer_cache_path
    )

    $directory = Split-Path -Parent $Path
    if (-not [string]::IsNullOrWhiteSpace($directory) -and -not (Test-Path -LiteralPath $directory)) {
        New-Item -ItemType Directory -Path $directory -Force | Out-Null
    }
}

function ConvertTo-ZEPFingerprint {
    param (
        [Parameter(Mandatory = $true)]$Record
    )

    $canonicalJson = $Record | ConvertTo-Json -Depth 20 -Compress
    $sha256 = [System.Security.Cryptography.SHA256]::Create()
    $bytes = [System.Text.Encoding]::UTF8.GetBytes($canonicalJson)
    $hash = $sha256.ComputeHash($bytes)
    return [System.BitConverter]::ToString($hash).Replace('-', '').ToLowerInvariant()
}

function Read-ZEPOfferCache {
    param (
        [Parameter(Mandatory = $false)][string]$Path = $script:ZEP_offer_cache_path
    )

    if (-not (Test-Path -LiteralPath $Path)) {
        return $null
    }

    try {
        return Get-Content -LiteralPath $Path -Raw | ConvertFrom-Json -Depth 100
    }
    catch {
        return $null
    }
}

function Write-ZEPOfferCache {
    param (
        [Parameter(Mandatory = $true)]$CacheObject,
        [Parameter(Mandatory = $false)][string]$Path = $script:ZEP_offer_cache_path
    )

    Initialize-ZEPCacheStore -Path $Path
    $CacheObject | ConvertTo-Json -Depth 100 | Set-Content -LiteralPath $Path -Encoding UTF8
}

function Get-ZEPOffer {
    [CmdletBinding()]
    param (
        [Parameter(Mandatory = $true)][int]$Id,
        [Parameter(Mandatory = $false)][switch]$UseCache,
        [Parameter(Mandatory = $false)][switch]$Refresh,
        [Parameter(Mandatory = $false)][int]$CacheTtlMinutes = $script:ZEPSettings.DefaultCacheTtlMinutes,
        [Parameter(Mandatory = $false)][string]$CachePath = $script:ZEP_offer_cache_path
    )

    $shouldUseCache = $true
    if ($PSBoundParameters.ContainsKey('UseCache')) {
        $shouldUseCache = $UseCache.IsPresent
    }
    if ($Refresh) {
        $shouldUseCache = $false
    }

    $cache = $null
    if ($shouldUseCache) {
        $cache = Read-ZEPOfferCache -Path $CachePath
        if ($cache -and $cache.items) {
            $cachedEntry = $cache.items | Where-Object { [int]$_.id -eq $Id } | Select-Object -First 1
            if ($cachedEntry -and $cachedEntry.data) {
                return $cachedEntry.data
            }
        }
    }

    $response = Invoke-ZEPRest -Path "offers/$Id"
    $items = Get-ZEPResponseItems -Payload $response
    $record = $items | Select-Object -First 1

    if ($record) {
        $normalizedRecord = ConvertTo-ZEPObject -InputObject $record
        if ($shouldUseCache) {
            $cache = Read-ZEPOfferCache -Path $CachePath
            if (-not $cache) {
                $cache = [pscustomobject]@{ version = 1; generatedAtUtc = (Get-Date).ToUniversalTime().ToString('o'); total = $null; items = @() }
            }

            $existingEntries = @($cache.items)
            $updatedEntries = @()
            $updated = $false
            foreach ($entry in $existingEntries) {
                if ([int]$entry.id -eq $Id) {
                    $updatedEntries += [pscustomobject]@{
                        id = $Id
                        data = $normalizedRecord
                        fingerprint = ConvertTo-ZEPFingerprint -Record $normalizedRecord
                    }
                    $updated = $true
                }
                else {
                    $updatedEntries += $entry
                }
            }

            if (-not $updated) {
                $updatedEntries += [pscustomobject]@{
                    id = $Id
                    data = $normalizedRecord
                    fingerprint = ConvertTo-ZEPFingerprint -Record $normalizedRecord
                }
            }

            $cache.items = @($updatedEntries)
            $cache.generatedAtUtc = (Get-Date).ToUniversalTime().ToString('o')
            Write-ZEPOfferCache -CacheObject $cache -Path $CachePath
        }

        return $normalizedRecord
    }

    return $null
}

function Get-ZEPOffers {
    [CmdletBinding()]
    param (
        [Parameter(Mandatory = $false)][switch]$UseCache,
        [Parameter(Mandatory = $false)][switch]$Refresh,
        [Parameter(Mandatory = $false)][switch]$ValidateCache,
        [Parameter(Mandatory = $false)][int]$PageSize = $script:ZEPSettings.DefaultPageSize,
        [Parameter(Mandatory = $false)][int]$CacheTtlMinutes = $script:ZEPSettings.DefaultCacheTtlMinutes,
        [Parameter(Mandatory = $false)][string]$CachePath = $script:ZEP_offer_cache_path
    )

    $shouldUseCache = $true
    if ($PSBoundParameters.ContainsKey('UseCache')) {
        $shouldUseCache = $UseCache.IsPresent
    }
    if ($Refresh) {
        $shouldUseCache = $false
    }

    $shouldValidateCache = $true
    if ($PSBoundParameters.ContainsKey('ValidateCache')) {
        $shouldValidateCache = $ValidateCache.IsPresent
    }

    $cacheInvalidReason = $null
    if ($shouldUseCache) {
        $cache = Read-ZEPOfferCache -Path $CachePath
        if ($cache -and $cache.items) {
            $cachedTotal = $null
            if ($cache.PSObject.Properties.Name -contains 'total' -and $null -ne $cache.total) {
                $cachedTotal = [int]$cache.total
            }

            $needsRefresh = $false
            if ($shouldValidateCache) {
                try {
                    $validationResponse = Invoke-ZEPRest -Path 'offers' -Query @{ limit = 1; page = 1 } -ThrottleDelaySeconds 0
                    $validationTotal = Get-ZEPResponseTotal -Payload $validationResponse
                    if ($null -ne $validationTotal -and $null -ne $cachedTotal -and [int]$validationTotal -ne [int]$cachedTotal) {
                        $needsRefresh = $true
                        $cacheInvalidReason = "Cache ungültig: Gesamtanzahl der Angebote hat sich geändert (cached=$cachedTotal, aktuell=$validationTotal)."
                    }
                }
                catch {
                    $needsRefresh = $false
                }
            }

            if (-not $needsRefresh) {
                return @($cache.items | ForEach-Object { $_.data })
            }

            if (-not $cacheInvalidReason) {
                $cacheInvalidReason = 'Cache wird nicht verwendet, weil eine Validierung oder Aktualisierung erforderlich ist.'
            }
            Write-Host $cacheInvalidReason -ForegroundColor Yellow
        }
        else {
            $cacheInvalidReason = 'Cache ist leer oder enthält keine gültigen Einträge.'
            Write-Host $cacheInvalidReason -ForegroundColor Yellow
        }
    }

    $allRecords = [System.Collections.Generic.List[object]]::new()
    $page = 1
    $collectionTotal = $null
    while ($true) {
        $query = @{ limit = $PageSize; page = $page }

        $response = Invoke-ZEPRest -Path 'offers' -Query $query
        if ($null -eq $collectionTotal) {
            $collectionTotal = Get-ZEPResponseTotal -Payload $response
        }

        $pageItems = Get-ZEPResponseItems -Payload $response
        if (-not $pageItems -or $pageItems.Count -eq 0) {
            break
        }

        foreach ($pageItem in $pageItems) {
            $allRecords.Add((ConvertTo-ZEPObject -InputObject $pageItem))
        }

        $lastPage = $null
        if ($response.PSObject.Properties.Name -contains 'meta') {
            $meta = $response.meta
            if ($meta -and $meta.PSObject.Properties.Name -contains 'last_page') {
                $lastPage = [int]$meta.last_page
            }
        }

        if ($null -ne $lastPage -and $page -ge $lastPage) {
            break
        }

        if ($pageItems.Count -lt $PageSize -and $null -eq $lastPage) {
            break
        }

        Write-Host ("Fetched page $page with $($pageItems.Count) items. Total fetched so far: $($allRecords.Count).")
        $page++
    }

    $cacheObject = [pscustomobject]@{
        version = 1
        generatedAtUtc = (Get-Date).ToUniversalTime().ToString('o')
        total = if ($null -ne $collectionTotal) { [int]$collectionTotal } else { $allRecords.Count }
        items = @()
    }

    foreach ($record in $allRecords) {
        $cacheObject.items += [pscustomobject]@{
            id = $record.id
            data = $record
            fingerprint = ConvertTo-ZEPFingerprint -Record $record
        }
    }

    Write-ZEPOfferCache -CacheObject $cacheObject -Path $CachePath
    return @($allRecords)
}

function Get-ZEPOfferItems {
    [CmdletBinding()]
    param (
        [Parameter(Mandatory = $true)][int]$OfferId,
        [Parameter(Mandatory = $false)][int]$PageSize = $script:ZEPSettings.DefaultPageSize,
        [Parameter(Mandatory = $false)][int]$Page = 1,
        [Parameter(Mandatory = $false)][string]$CachePath = $script:ZEP_offer_item_cache_path
    )

    $cache = $null
    if (Test-Path -LiteralPath $CachePath) {
        $cache = Read-ZEPOfferCache -Path $CachePath
    }

    if ($cache -and $cache.items) {
        $cachedEntry = $cache.items | Where-Object { [int]$_.offer_id -eq $OfferId -and [int]$_.page -eq $Page } | Select-Object -First 1
        if ($cachedEntry -and $cachedEntry.data) {
            return @($cachedEntry.data)
        }
    }

    $response = Invoke-ZEPRest -Path "offers/$OfferId/items" -Query @{ limit = $PageSize; page = $Page }
    $pageItems = Get-ZEPResponseItems -Payload $response
    $result = @($pageItems | ForEach-Object { ConvertTo-ZEPObject -InputObject $_ })

    if (-not $cache) {
        $cache = [pscustomobject]@{ version = 1; generatedAtUtc = (Get-Date).ToUniversalTime().ToString('o'); items = @() }
    }

    $cache.items += [pscustomobject]@{
        offer_id = $OfferId
        page = $Page
        data = $result
        fingerprint = ConvertTo-ZEPFingerprint -Record $result
        lastValidatedUtc = (Get-Date).ToUniversalTime().ToString('o')
    }

    Write-ZEPOfferCache -CacheObject $cache -Path $CachePath
    return $result
}

Export-ModuleMember Get-ZEPConfiguration
Export-ModuleMember Set-ZEPConfiguration
Export-ModuleMember Invoke-ZEPRest
Export-ModuleMember Get-ZEPOffer
Export-ModuleMember Get-ZEPOffers
Export-ModuleMember Get-ZEPOfferItems
Export-ModuleMember Test-ZEPOffersCacheNeedsRefresh