Private/State.ps1

function Read-State {
    [CmdletBinding()]
    param(
        [Parameter(Mandatory = $true)]
        [VSphereConnectorConfiguration]$Config
    )
    $statePath = Get-StatePath -Config $Config

    if (-not (Test-Path -Path $statePath)) {
        $default = New-State
        Save-State -State $default -Config $Config
        return $default
    }

    try {
        $raw = Get-Content -Path $statePath -Raw
        $parsed = $raw | ConvertFrom-Json
        Assert-StateObject -ParsedState $parsed

        $lastReceivedUtc = ConvertTo-StateUtcTimestamp -Value $parsed.watermarks.last_received_utc -FieldName 'watermarks.last_received_utc'
        $rawLastSentUtc = [string]$parsed.watermarks.last_sent_utc
        $lastSentUtc = if ([string]::IsNullOrWhiteSpace($rawLastSentUtc)) { '' } else { ConvertTo-StateUtcTimestamp -Value $rawLastSentUtc -FieldName 'watermarks.last_sent_utc' }

        $state = [VSphereConnectorState]::new()
        $state.schema_version = '1.0'
        $state.last_update_utc = [DateTime]::UtcNow.ToString('o')

        $watermarks = [VSphereConnectorStateWatermarks]::new()
        $watermarks.last_received_utc = $lastReceivedUtc
        $watermarks.last_sent_utc = $lastSentUtc
        $state.watermarks = $watermarks

        Write-CustomLog -Message "Loaded state from: $statePath" -Severity 'INFO'
        return $state
    }
    catch {
        Write-CustomLog -Message "Failed to load state. Path=$statePath Error=$($_.Exception.Message)" -Severity 'ERROR'
        throw
    }
}

function Assert-StateObject {
    [CmdletBinding()]
    param(
        [object]$ParsedState
    )

    if ($null -eq $ParsedState) {
        $errorMsg = "Invalid state: state file parsed to null"
        Write-CustomLog -Message $errorMsg -Severity 'ERROR'
        throw $errorMsg
    }

    if ([string]::IsNullOrWhiteSpace([string]$ParsedState.schema_version)) {
        $errorMsg = "Invalid state: 'schema_version' is required."
        Write-CustomLog -Message $errorMsg -Severity 'ERROR'
        throw $errorMsg
    }

    if ([string]$ParsedState.schema_version -ne '1.0') {
        $errorMsg = "Invalid state: unsupported schema_version '$($ParsedState.schema_version)'."
        Write-CustomLog -Message $errorMsg -Severity 'ERROR'
        throw $errorMsg
    }

    if ($null -eq $ParsedState.watermarks) {
        $errorMsg = "Invalid state: 'watermarks' section is required."
        Write-CustomLog -Message $errorMsg -Severity 'ERROR'
        throw $errorMsg
    }
}

function Get-StatePath {
    [CmdletBinding()]
    param(
        [Parameter(Mandatory = $true)]
        [VSphereConnectorConfiguration]$Config
    )

    $stateDir = Join-Path -Path $Config.ScriptRootPath -ChildPath (Join-Path -Path 'State' -ChildPath $Config.EnvironmentConfig.Name)
    return (Join-Path -Path $stateDir -ChildPath 'state.json')
}

function New-State {
    [CmdletBinding()]
    param()

    $now = [DateTime]::UtcNow

    $state = [VSphereConnectorState]::new()
    $state.schema_version = '1.0'
    $state.last_update_utc = $now.ToString('o')

    $watermarks = [VSphereConnectorStateWatermarks]::new()
    $watermarks.last_received_utc = $now.AddMinutes(-5).ToString('o')
    $watermarks.last_sent_utc = ''

    $state.watermarks = $watermarks
    return $state
}

function Save-State {
    [CmdletBinding()]
    param(
        [VSphereConnectorState]$State,

        [Parameter(Mandatory = $true)]
        [VSphereConnectorConfiguration]$Config
    )

    try {
        $path = Get-StatePath -Config $Config
        $stateDir = Split-Path -Path $path -Parent

        if (-not (Test-Path -Path $stateDir)) {
            New-Item -Path $stateDir -ItemType Directory -Force | Out-Null
            Write-CustomLog -Message "Created state directory: $stateDir" -Severity 'INFO'
        }
        if ($null -eq $State -or $null -eq $State.watermarks) {
            throw "State object is missing required 'watermarks' section."
        }

        $now = [DateTime]::UtcNow

        $stateToSave = [VSphereConnectorState]::new()
        $stateToSave.schema_version = '1.0'
        $stateToSave.last_update_utc = $now.ToString('o')

        $watermarks = [VSphereConnectorStateWatermarks]::new()
        $watermarks.last_received_utc = [string]$State.watermarks.last_received_utc
        $watermarks.last_sent_utc = [string]$State.watermarks.last_sent_utc
        $stateToSave.watermarks = $watermarks

        $json = $stateToSave | ConvertTo-Json -Depth 10
        $tempPath = "$path.tmp-$([Guid]::NewGuid().ToString('N'))"

        Set-Content -Path $tempPath -Value $json -Encoding utf8
        Move-Item -Path $tempPath -Destination $path -Force

        Write-CustomLog -Message "Saved state to: $path" -Severity 'INFO'
    }
    catch {
        Write-CustomLog -Message "Failed to save state. Path=$path Error=$($_.Exception.Message)" -Severity 'ERROR'
        throw
    }
}

function ConvertTo-StateUtcTimestamp {
    param(
        [object]$Value,
        [string]$FieldName
    )

    $normalized = if ($Value -is [DateTime]) {
        ([DateTime]$Value).ToUniversalTime().ToString('o')
    }
    elseif ($Value -is [DateTimeOffset]) {
        ([DateTimeOffset]$Value).ToUniversalTime().ToString('o')
    }
    else {
        [string]$Value
    }

    if ([string]::IsNullOrWhiteSpace($normalized)) {
        $errorMsg = "Invalid state: '$FieldName' is required."
        Write-CustomLog -Message $errorMsg -Severity 'ERROR'
        throw $errorMsg
    }

    if (-not $normalized.EndsWith('Z')) {
        $errorMsg = "Invalid state: '$FieldName' must be in UTC (RFC3339 with 'Z'). Value: '$normalized'"
        Write-CustomLog -Message $errorMsg -Severity 'ERROR'
        throw $errorMsg
    }

    try {
        [void](ConvertFrom-RfcUtcTimestamp -Value $normalized)
    }
    catch {
        $errorMsg = "Invalid state: '$FieldName' must be a valid RFC3339 datetime. Value: '$normalized'"
        Write-CustomLog -Message $errorMsg -Severity 'ERROR'
        throw $errorMsg
    }

    return $normalized
}

function Test-StateHasPendingData {
    [CmdletBinding()]
    param(
        [Parameter(Mandatory = $true)]
        [VSphereConnectorState]$State
    )

    if ([string]::IsNullOrWhiteSpace($State.watermarks.last_sent_utc)) {
        return $true
    }

    $lastReceivedUtc = ConvertFrom-RfcUtcTimestamp -Value $State.watermarks.last_received_utc
    $lastSentUtc = ConvertFrom-RfcUtcTimestamp -Value $State.watermarks.last_sent_utc

    return ($lastReceivedUtc -gt $lastSentUtc)
}