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.1 07.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'
    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
}

<#
.SYNOPSIS
Shows the active InvokeZEP module configuration.
 
.DESCRIPTION
Returns the current runtime settings for API endpoint, credential target,
cache location and default paging/throttle/cache values.
 
.EXAMPLE
Get-ZEPConfiguration
 
.EXAMPLE
Get-ZEPConfiguration | Format-List *
 
.EXAMPLE
Get-ZEPConfiguration | Select-Object BaseUri, DefaultPageSize, DefaultThrottleDelaySeconds, CacheDirectory
#>

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
        DefaultPageSize = $script:ZEPSettings.DefaultPageSize
        DefaultThrottleDelaySeconds = $script:ZEPSettings.DefaultThrottleDelaySeconds
        DefaultCacheTtlMinutes = $script:ZEPSettings.DefaultCacheTtlMinutes
    }
}

<#
.SYNOPSIS
Updates InvokeZEP module configuration values.
 
.DESCRIPTION
Changes selected runtime settings for the current session. Only explicitly
provided parameters are updated.
 
.PARAMETER ServiceUserName
Credential name used in Azure Automation for Get-AutomationPSCredential.
 
.PARAMETER BaseUri
Base URI of the ZEP REST API.
 
.PARAMETER TokenTarget
Credential target name in Windows Credential Manager.
 
.PARAMETER CacheDirectory
Local directory for offer cache files.
 
.PARAMETER DefaultPageSize
Default page size for list operations.
 
.PARAMETER DefaultThrottleDelaySeconds
Default delay between REST requests.
 
.PARAMETER DefaultCacheTtlMinutes
Default cache TTL value for cache-related workflows.
 
.EXAMPLE
Set-ZEPConfiguration -DefaultPageSize 50
 
.EXAMPLE
Set-ZEPConfiguration -BaseUri 'https://www.zep-online.de/zephhpberlin/next/api/v1' -DefaultThrottleDelaySeconds 0.1
 
.EXAMPLE
Set-ZEPConfiguration -TokenTarget 'ZEP_hhpberlin_BearerToken' -ServiceUserName 'ZEP_hhpberlin'
 
.EXAMPLE
Set-ZEPConfiguration -CacheDirectory (Join-Path $env:LOCALAPPDATA 'InvokeZEP') -DefaultCacheTtlMinutes 120
 
.EXAMPLE
Set-ZEPConfiguration -DefaultPageSize 100 -DefaultThrottleDelaySeconds 0.2 -DefaultCacheTtlMinutes 60
#>

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
}

<#
.SYNOPSIS
Executes a ZEP REST API request.
 
.DESCRIPTION
Sends an HTTP request to the configured ZEP API endpoint with bearer token
authentication and retry handling for transient 429/503 responses.
 
.PARAMETER Path
Relative API path, for example 'offers' or 'offers/12345'.
 
.PARAMETER Method
HTTP method. Default is GET.
 
.PARAMETER Query
Hashtable of query parameters.
 
.PARAMETER Body
Request body object, serialized as JSON.
 
.PARAMETER RetryCount
Maximum retry attempts for transient errors.
 
.PARAMETER ThrottleDelaySeconds
Delay before each request execution.
 
.EXAMPLE
Invoke-ZEPRest -Path 'offers'
 
.EXAMPLE
Invoke-ZEPRest -Path 'offers' -Query @{ limit = 50; page = 1 }
 
.EXAMPLE
Invoke-ZEPRest -Path 'offers/12345' -Method 'GET' -RetryCount 3 -ThrottleDelaySeconds 0
 
.EXAMPLE
Invoke-ZEPRest -Path 'offers' -Method 'POST' -Body @{ name = 'Test' }
#>

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-ZEPCacheAgeExceeded {
    param (
        [Parameter(Mandatory = $false)]$CacheObject,
        [Parameter(Mandatory = $false)][int]$CacheTtlMinutes = $script:ZEPSettings.DefaultCacheTtlMinutes
    )

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

    if ($CacheTtlMinutes -le 0) {
        return $true
    }

    if (-not ($CacheObject.PSObject.Properties.Name -contains 'generatedAtUtc')) {
        return $true
    }

    $rawTimestamp = $CacheObject.generatedAtUtc
    if ($null -eq $rawTimestamp) {
        return $true
    }

    $generatedAt = [datetimeoffset]::MinValue
    if ($rawTimestamp -is [datetimeoffset]) {
        $generatedAt = [datetimeoffset]$rawTimestamp
    }
    elseif ($rawTimestamp -is [datetime]) {
        $generatedAt = [datetimeoffset]([datetime]$rawTimestamp).ToUniversalTime()
    }
    else {
        $rawTimestampText = [string]$rawTimestamp
        if ([string]::IsNullOrWhiteSpace($rawTimestampText)) {
            return $true
        }

        if (-not [datetimeoffset]::TryParseExact(
                $rawTimestampText,
                'o',
                [System.Globalization.CultureInfo]::InvariantCulture,
                [System.Globalization.DateTimeStyles]::RoundtripKind,
                [ref]$generatedAt
            )) {
            if (-not [datetimeoffset]::TryParse($rawTimestampText, [ref]$generatedAt)) {
                return $true
            }
        }
    }

    $ageMinutes = ((Get-Date).ToUniversalTime() - $generatedAt.UtcDateTime).TotalMinutes
    return $ageMinutes -ge $CacheTtlMinutes
}

<#
.SYNOPSIS
Checks whether the offers cache should be refreshed.
 
.DESCRIPTION
Validates the cache by comparing totals and item counts against either a given
CurrentTotal or a lightweight API validation request.
 
.PARAMETER CacheObject
Cache object loaded from the offer cache file.
 
.PARAMETER CurrentTotal
Optional externally provided current total.
 
.PARAMETER CacheTtlMinutes
Compatibility parameter for cache policy workflows.
 
.EXAMPLE
$cache = Read-ZEPOfferCache
Test-ZEPOffersCacheNeedsRefresh -CacheObject $cache
 
.EXAMPLE
$cache = Read-ZEPOfferCache
Test-ZEPOffersCacheNeedsRefresh -CacheObject $cache -CurrentTotal 250
 
.EXAMPLE
Test-ZEPOffersCacheNeedsRefresh -CacheObject $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 (Test-ZEPCacheAgeExceeded -CacheObject $CacheObject -CacheTtlMinutes $CacheTtlMinutes) {
        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 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
}

<#
.SYNOPSIS
Gets a single ZEP offer by ID.
 
.DESCRIPTION
Returns one offer from cache (when available) or from the API, and writes the
normalized result back to cache.
 
.PARAMETER Id
Offer ID.
 
.PARAMETER UseCache
Enables cache read/write behavior.
 
.PARAMETER Refresh
Forces API retrieval and bypasses cache read.
 
.PARAMETER CacheTtlMinutes
Compatibility parameter for cache policy workflows.
 
.PARAMETER CachePath
Path to offer cache JSON file.
 
.EXAMPLE
Get-ZEPOffer -Id 12345
 
.EXAMPLE
Get-ZEPOffer -Id 12345 -UseCache
 
.EXAMPLE
Get-ZEPOffer -Id 12345 -Refresh
 
.EXAMPLE
Get-ZEPOffer -Id 12345 -UseCache -CachePath (Join-Path $env:LOCALAPPDATA 'InvokeZEP\\offers-cache.json')
 
.EXAMPLE
Get-ZEPOffer -Id 12345 -UseCache -CacheTtlMinutes 60
#>

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 -and -not (Test-ZEPCacheAgeExceeded -CacheObject $cache -CacheTtlMinutes $CacheTtlMinutes)) {
            $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
}

<#
.SYNOPSIS
Gets all ZEP offers.
 
.DESCRIPTION
Loads all offers from cache when valid, or fetches paged results from the API.
Optionally validates cache freshness before reuse.
 
.PARAMETER UseCache
Enables cache read/write behavior.
 
.PARAMETER Refresh
Forces API retrieval and bypasses cache read.
 
.PARAMETER ValidateCache
Validates cache against API total count before reuse.
 
.PARAMETER PageSize
Page size for API pagination.
 
.PARAMETER CacheTtlMinutes
Compatibility parameter for cache policy workflows.
 
.PARAMETER CachePath
Path to offer cache JSON file.
 
.EXAMPLE
Get-ZEPOffers
 
.EXAMPLE
Get-ZEPOffers -UseCache
 
.EXAMPLE
Get-ZEPOffers -UseCache -ValidateCache
 
.EXAMPLE
Get-ZEPOffers -Refresh -PageSize 200
 
.EXAMPLE
Get-ZEPOffers -UseCache -CachePath (Join-Path $env:LOCALAPPDATA 'InvokeZEP\\offers-cache.json')
 
.EXAMPLE
Get-ZEPOffers -UseCache -ValidateCache -CacheTtlMinutes 60
#>

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) {
            if (Test-ZEPCacheAgeExceeded -CacheObject $cache -CacheTtlMinutes $CacheTtlMinutes) {
                $cacheInvalidReason = "Cache ungültig: älter als $CacheTtlMinutes Minuten."
                Write-Host $cacheInvalidReason -ForegroundColor Yellow
            }

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

            $needsRefresh = $false
            if ($cacheInvalidReason) {
                $needsRefresh = $true
            }
            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))
        }

        if ($null -ne $collectionTotal) {
            Write-Host ("Fetched page $page with $($pageItems.Count) items. Total fetched so far: $($allRecords.Count) of $collectionTotal.")
        }
        else {
            Write-Host ("Fetched page $page with $($pageItems.Count) items. Total fetched so far: $($allRecords.Count).")
        }

        $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
        }
        $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)
}

<#
.SYNOPSIS
Gets offer items for a specific ZEP offer.
 
.DESCRIPTION
Returns one page of offer items directly from the API.
 
.PARAMETER OfferId
Offer ID for which items are requested.
 
.PARAMETER PageSize
Page size for item paging.
 
.PARAMETER Page
Page number to retrieve.
 
.EXAMPLE
Get-ZEPOfferItems -OfferId 12345
 
.EXAMPLE
Get-ZEPOfferItems -OfferId 12345 -Page 2
 
.EXAMPLE
Get-ZEPOfferItems -OfferId 12345 -PageSize 200 -Page 1
#>

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

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

function Get-ZEPPropertyPathsFromObject {
    param (
        [Parameter(Mandatory = $false)]$InputObject,
        [Parameter(Mandatory = $false)][string]$Prefix = '',
        [Parameter(Mandatory = $false)][System.Collections.Generic.HashSet[string]]$PropertySet,
        [Parameter(Mandatory = $false)][int]$MaxDepth = 10
    )

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

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

    $isScalar = {
        param($Value)
        if ($null -eq $Value) { return $true }
        if (
            $Value -is [string] -or
            $Value -is [bool] -or
            $Value -is [int] -or
            $Value -is [long] -or
            $Value -is [double] -or
            $Value -is [decimal] -or
            $Value -is [datetime] -or
            $Value -is [datetimeoffset] -or
            $Value -is [timespan] -or
            $Value -is [guid]
        ) {
            return $true
        }

        $valueType = $Value.GetType()
        return $valueType.IsEnum
    }

    $stack = [System.Collections.Generic.List[object]]::new()
    $stack.Add([pscustomobject]@{ Value = $InputObject; Prefix = $Prefix; Depth = 0 })

    while ($stack.Count -gt 0) {
        $node = $stack[$stack.Count - 1]
        $stack.RemoveAt($stack.Count - 1)

        $value = $node.Value
        $prefixValue = [string]$node.Prefix
        $depth = [int]$node.Depth

        if ($null -eq $value -or (& $isScalar -Value $value)) {
            continue
        }

        if ($depth -ge $MaxDepth) {
            continue
        }

        if ($value -is [System.Collections.IDictionary]) {
            foreach ($key in $value.Keys) {
                $name = if ([string]::IsNullOrWhiteSpace($prefixValue)) { [string]$key } else { "$prefixValue.$key" }
                [void]$PropertySet.Add($name)

                $childValue = $value[$key]
                if ($null -ne $childValue -and -not (& $isScalar -Value $childValue)) {
                    $stack.Add([pscustomobject]@{ Value = $childValue; Prefix = $name; Depth = ($depth + 1) })
                }
            }
            continue
        }

        if ($value -is [System.Collections.IEnumerable] -and -not ($value -is [string])) {
            foreach ($entry in $value) {
                if ($null -ne $entry -and -not (& $isScalar -Value $entry)) {
                    $stack.Add([pscustomobject]@{ Value = $entry; Prefix = $prefixValue; Depth = ($depth + 1) })
                }
            }
            continue
        }

        foreach ($property in $value.PSObject.Properties) {
            $name = if ([string]::IsNullOrWhiteSpace($prefixValue)) { [string]$property.Name } else { "$prefixValue.$($property.Name)" }
            [void]$PropertySet.Add($name)

            $childValue = $property.Value
            if ($null -ne $childValue -and -not (& $isScalar -Value $childValue)) {
                $stack.Add([pscustomobject]@{ Value = $childValue; Prefix = $name; Depth = ($depth + 1) })
            }
        }
    }
}

function Get-ZEPValuesByPropertyPath {
    param (
        [Parameter(Mandatory = $false)]$InputObject,
        [Parameter(Mandatory = $true)][string[]]$PathSegments,
        [Parameter(Mandatory = $false)][int]$SegmentIndex = 0
    )

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

    if ($SegmentIndex -ge $PathSegments.Length) {
        return @($InputObject)
    }

    if ($InputObject -is [System.Collections.IEnumerable] -and -not ($InputObject -is [string])) {
        $values = [System.Collections.Generic.List[object]]::new()
        foreach ($entry in $InputObject) {
            foreach ($value in (Get-ZEPValuesByPropertyPath -InputObject $entry -PathSegments $PathSegments -SegmentIndex $SegmentIndex)) {
                $values.Add($value)
            }
        }
        return @($values)
    }

    $segment = $PathSegments[$SegmentIndex]
    $nextValue = $null

    if ($InputObject -is [System.Collections.IDictionary]) {
        if (-not $InputObject.Contains($segment)) {
            return @()
        }
        $nextValue = $InputObject[$segment]
    }
    else {
        $property = $InputObject.PSObject.Properties | Where-Object { $_.Name -ieq $segment } | Select-Object -First 1
        if (-not $property) {
            return @()
        }
        $nextValue = $property.Value
    }

    return Get-ZEPValuesByPropertyPath -InputObject $nextValue -PathSegments $PathSegments -SegmentIndex ($SegmentIndex + 1)
}

<#
.SYNOPSIS
Searches ZEP offers by property and substring.
 
.DESCRIPTION
Searches offers by a specific property path (for example title or status.name)
and performs case-insensitive partial-string matching on the property value.
The allowed searchable properties are discovered from the last sample entries
in the local offers cache (default: last 1000 cache records). If the offers
cache is stale (TTL exceeded or total-count mismatch), the full offers cache is
rebuilt before searching.
 
.PARAMETER PropertyName
Property name or path to search, for example title, name, or status.name.
 
.PARAMETER SearchTerm
Substring to search for inside the selected property value.
 
.PARAMETER ReferenceSampleSize
Number of latest cache records used to determine allowed searchable properties.
Default is 1000.
 
.PARAMETER MaxResults
Maximum number of matching offers to return. Default is 500.
 
.PARAMETER CacheTtlMinutes
Maximum cache age in minutes before refresh. Default is module configuration.
 
.PARAMETER ValidateCache
Controls whether cached total count is validated against API before reuse.
Default is $true.
 
.PARAMETER SkipPropertyValidation
Skips pre-validation of PropertyName against reference cache records.
Use this for maximum performance when the property path is known to be valid.
 
.PARAMETER RefreshPageSize
Page size used when rebuilding the full offers cache.
 
.PARAMETER CachePath
Path to the offers cache JSON file.
 
.EXAMPLE
Search-ZEPOffers -PropertyName 'title' -SearchTerm 'test'
 
.EXAMPLE
Search-ZEPOffers -PropertyName 'status.name' -SearchTerm 'aktiv'
 
.EXAMPLE
Search-ZEPOffers -PropertyName 'name' -SearchTerm 'berlin' -MaxResults 100
 
.EXAMPLE
Search-ZEPOffers -PropertyName 'title' -SearchTerm 'test' -ReferenceSampleSize 1000
 
.EXAMPLE
Search-ZEPOffers -PropertyName 'title' -SearchTerm 'test' -CacheTtlMinutes 60 -ValidateCache
 
.EXAMPLE
Search-ZEPOffers -PropertyName 'title' -SearchTerm 'test' -ValidateCache:$false -SkipPropertyValidation
#>

function Search-ZEPOffers {
    [CmdletBinding()]
    param (
        [Parameter(Mandatory = $true)][string]$PropertyName,
        [Parameter(Mandatory = $true)][string]$SearchTerm,
        [Parameter(Mandatory = $false)][int]$ReferenceSampleSize = 1000,
        [Parameter(Mandatory = $false)][int]$MaxResults = 500,
        [Parameter(Mandatory = $false)][int]$CacheTtlMinutes = $script:ZEPSettings.DefaultCacheTtlMinutes,
        [Parameter(Mandatory = $false)][bool]$ValidateCache = $true,
        [Parameter(Mandatory = $false)][switch]$SkipPropertyValidation,
        [Parameter(Mandatory = $false)][int]$RefreshPageSize = $script:ZEPSettings.DefaultPageSize,
        [Parameter(Mandatory = $false)][string]$CachePath = $script:ZEP_offer_cache_path
    )

    if ([string]::IsNullOrWhiteSpace($PropertyName)) {
        throw 'PropertyName darf nicht leer sein.'
    }

    if ([string]::IsNullOrWhiteSpace($SearchTerm)) {
        throw 'SearchTerm darf nicht leer sein.'
    }

    $shouldValidateCache = [bool]$ValidateCache

    $cache = Read-ZEPOfferCache -Path $CachePath
    $cacheNeedsRefresh = $false

    if (-not $cache -or -not $cache.items) {
        $cacheNeedsRefresh = $true
        Write-Host 'Offers-Cache nicht vorhanden oder leer. Vollständiger Cache wird aufgebaut.' -ForegroundColor Yellow
    }
    elseif (Test-ZEPCacheAgeExceeded -CacheObject $cache -CacheTtlMinutes $CacheTtlMinutes) {
        $cacheNeedsRefresh = $true
        Write-Host "Offers-Cache älter als $CacheTtlMinutes Minuten. Vollständiger Cache wird aufgebaut." -ForegroundColor Yellow
    }
    elseif ($shouldValidateCache) {
        $cacheNeedsRefresh = Test-ZEPOffersCacheNeedsRefresh -CacheObject $cache -CacheTtlMinutes $CacheTtlMinutes
        if ($cacheNeedsRefresh) {
            Write-Host 'Offers-Cache ist inhaltlich veraltet (z. B. geänderte Gesamtanzahl). Vollständiger Cache wird aufgebaut.' -ForegroundColor Yellow
        }
    }

    if ($cacheNeedsRefresh) {
        # Forces a full fetch and cache rewrite before executing the search.
        Get-ZEPOffers -Refresh -PageSize $RefreshPageSize -CacheTtlMinutes $CacheTtlMinutes -CachePath $CachePath > $null
        $cache = Read-ZEPOfferCache -Path $CachePath
        if (-not $cache -or -not $cache.items) {
            throw "Offers-Cache konnte nicht aufgebaut werden: $CachePath"
        }
    }

    $allCacheItems = @($cache.items)
    if ($allCacheItems.Count -eq 0) {
        return @()
    }

    $segments = $PropertyName -split '\\.'
    if (-not $SkipPropertyValidation) {
        $sampleSize = [Math]::Max(1, [Math]::Min($ReferenceSampleSize, $allCacheItems.Count))
        $referenceItems = @($allCacheItems | Select-Object -Last $sampleSize)

        $propertyExists = $false
        foreach ($entry in $referenceItems) {
            if (-not $entry -or -not $entry.data) {
                continue
            }

            $referenceValues = Get-ZEPValuesByPropertyPath -InputObject $entry.data -PathSegments $segments
            if ($referenceValues -and @($referenceValues).Count -gt 0) {
                $propertyExists = $true
                break
            }
        }

        if (-not $propertyExists) {
            # Build deep property suggestions only on invalid input to avoid expensive traversal on normal searches.
            $propertySet = [System.Collections.Generic.HashSet[string]]::new([System.StringComparer]::OrdinalIgnoreCase)
            foreach ($entry in $referenceItems) {
                if ($entry -and $entry.data) {
                    Get-ZEPPropertyPathsFromObject -InputObject $entry.data -PropertySet $propertySet
                }
            }

            $matchingProperties = @($propertySet | Where-Object { $_ -like "*$PropertyName*" } | Sort-Object)
            $preview = @($matchingProperties | Select-Object -First 20)
            $previewText = if ($preview.Count -gt 0) { ($preview -join ', ') } else { 'keine aehnlichen Felder gefunden' }
            throw "Property '$PropertyName' ist in den letzten $sampleSize Cache-Datensaetzen nicht vorhanden. Vorschlaege: $previewText"
        }
    }

    $result = [System.Collections.Generic.List[object]]::new()
    $max = [Math]::Max(1, $MaxResults)
    $isSingleSegment = $segments.Length -eq 1
    $singleSegmentName = if ($isSingleSegment) { $segments[0] } else { $null }

    foreach ($entry in $allCacheItems) {
        if (-not $entry -or -not $entry.data) {
            continue
        }

        if ($isSingleSegment) {
            $singleValue = $null
            if ($entry.data -is [System.Collections.IDictionary]) {
                if ($entry.data.Contains($singleSegmentName)) {
                    $singleValue = $entry.data[$singleSegmentName]
                }
            }
            else {
                $singleValue = $entry.data.$singleSegmentName
            }

            if ($singleValue -is [System.Collections.IEnumerable] -and -not ($singleValue -is [string])) {
                $values = @($singleValue)
            }
            elseif ($null -ne $singleValue) {
                $values = @($singleValue)
            }
            else {
                $values = @()
            }
        }
        else {
            $values = Get-ZEPValuesByPropertyPath -InputObject $entry.data -PathSegments $segments
        }

        if (-not $values -or @($values).Count -eq 0) {
            continue
        }

        $isMatch = $false
        foreach ($value in $values) {
            if ($null -eq $value) {
                continue
            }

            $candidate = [string]$value
            if ($candidate.IndexOf($SearchTerm, [System.StringComparison]::OrdinalIgnoreCase) -ge 0) {
                $isMatch = $true
                break
            }
        }

        if ($isMatch) {
            $result.Add($entry.data)
            if ($result.Count -ge $max) {
                break
            }
        }
    }

    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
Export-ModuleMember Search-ZEPOffers