Private/EAI/Build-EAISdtSchedulePayload.ps1

$Script:EAISdtWeekOfMonthMap = @{
    First  = 1
    Second = 2
    Third  = 3
    Fourth = 4
    Last   = -1
}

$Script:EAISdtSdtTypeToFrequencyMap = @{
    Daily         = 'DAILY'
    Weekly        = 'WEEKLY'
    Monthly       = 'MONTHLY'
    MonthlyByWeek = 'MONTHLY'
}

function Format-EAISdtLocalDateTime {
    [CmdletBinding()]
    param (
        [Parameter(Mandatory)]
        [Datetime]$DateTime
    )

    return $DateTime.ToString('yyyy-MM-ddTHH:mm:ss')
}

function ConvertTo-EAISdtIsoDuration {
    [CmdletBinding()]
    param (
        [Parameter(Mandatory)]
        [Int]$TotalMinutes
    )

    if ($TotalMinutes -le 0) {
        throw 'Duration must be greater than zero minutes.'
    }

    $hours = [Math]::Floor($TotalMinutes / 60)
    $minutes = $TotalMinutes % 60

    if ($hours -gt 0 -and $minutes -eq 0) {
        return "PT${hours}H"
    }

    if ($hours -gt 0) {
        return "PT${hours}H${minutes}M"
    }

    return "PT${TotalMinutes}M"
}

function ConvertFrom-EAISdtIsoDuration {
    [CmdletBinding()]
    param (
        [Parameter(Mandatory)]
        [String]$Duration
    )

    if ([string]::IsNullOrWhiteSpace($Duration)) {
        throw 'Duration value is required.'
    }

    if ($Duration -match '^P(?:(\d+)D)?(?:T(?:(\d+)H)?(?:(\d+)M)?(?:(\d+)S)?)?$') {
        $days = if ($Matches[1]) { [int]$Matches[1] } else { 0 }
        $hours = if ($Matches[2]) { [int]$Matches[2] } else { 0 }
        $minutes = if ($Matches[3]) { [int]$Matches[3] } else { 0 }
        $seconds = if ($Matches[4]) { [int]$Matches[4] } else { 0 }
        return ($days * 24 * 60) + ($hours * 60) + $minutes + [Math]::Round($seconds / 60.0)
    }

    throw "Unable to parse ISO-8601 duration '$Duration'."
}

function Get-EAISdtWallClockNow {
    [CmdletBinding()]
    param (
        [Parameter(Mandatory)]
        [String]$Timezone
    )

    $timeZoneInfo = Find-LMTimeZoneInfo -Timezone $Timezone
    return [TimeZoneInfo]::ConvertTime([DateTime]::UtcNow, $timeZoneInfo)
}

function Get-EAISdtImminentStartDateTime {
    [CmdletBinding()]
    param (
        [Parameter(Mandatory)]
        [String]$Timezone,

        [ValidateRange(1, 60)]
        [Int]$BufferMinutes = 2
    )

    $now = Get-EAISdtWallClockNow -Timezone $Timezone
    $buffered = $now.AddMinutes($BufferMinutes)
    return [Datetime]::new(
        $buffered.Year,
        $buffered.Month,
        $buffered.Day,
        $buffered.Hour,
        $buffered.Minute,
        0
    )
}

function Assert-EAISdtFutureStartDateTime {
    [CmdletBinding()]
    param (
        [Parameter(Mandatory)]
        [String]$Timezone,

        [Parameter(Mandatory)]
        [Datetime]$StartDateTime
    )

    $now = Get-EAISdtWallClockNow -Timezone $Timezone
    $start = [Datetime]::SpecifyKind($StartDateTime, [System.DateTimeKind]::Unspecified)

    if ($start -le $now) {
        throw @"
StartDate must be in the future for timezone '$Timezone'.
Edwin requires startTime to be after the current local time ($($now.ToString('yyyy-MM-dd HH:mm:ss'))).
"@

    }

    return $start
}

function Get-EAISdtFutureRecurringStartDateTime {
    [CmdletBinding()]
    param (
        [Parameter(Mandatory)]
        [hashtable]$ScheduleBound,

        [Parameter(Mandatory)]
        [String]$Timezone
    )

    $startDateTime = Get-EAISdtRecurringStartDateTime -ScheduleBound $ScheduleBound -Timezone $Timezone
    $now = Get-EAISdtWallClockNow -Timezone $Timezone

    while ($startDateTime -le $now) {
        $startDateTime = $startDateTime.AddDays(1)
    }

    return $startDateTime
}

function Resolve-EAISdtTimezone {
    [CmdletBinding()]
    param (
        [AllowNull()]
        [AllowEmptyString()]
        [String]$Timezone
    )

    if (-not [String]::IsNullOrWhiteSpace($Timezone)) {
        Test-LMTimezoneId -Timezone $Timezone | Out-Null
        return (Resolve-LMTimezoneToIANAId -Timezone $Timezone)
    }

    return (Resolve-LMTimezoneToIANAId -Timezone (Get-TimeZone).Id)
}

function Get-EAISdtEnabledApiValue {
    [CmdletBinding()]
    param (
        [Parameter(Mandatory)]
        [hashtable]$BoundParameters,

        [ValidateSet('ENABLED', 'DISABLED')]
        [String]$Default = 'ENABLED'
    )

    if ($BoundParameters.ContainsKey('Enabled')) {
        if ($BoundParameters['Enabled']) {
            return 'ENABLED'
        }

        return 'DISABLED'
    }

    return $Default
}

function Test-EAISdtFilterObject {
    [CmdletBinding()]
    param (
        [Parameter(Mandatory)]
        $Filter
    )

    if ($null -eq $Filter) {
        throw 'Filter is required.'
    }

    foreach ($propertyName in @('schemaName', 'schemaVersion', 'expression')) {
        if ($null -eq $Filter.PSObject.Properties[$propertyName]) {
            throw "Filter is missing required property '$propertyName'."
        }
    }

    if ([string]::IsNullOrWhiteSpace([string]$Filter.schemaName)) {
        throw 'Filter.schemaName must not be blank.'
    }

    if ([string]::IsNullOrWhiteSpace([string]$Filter.schemaVersion)) {
        throw 'Filter.schemaVersion must not be blank.'
    }

    if ($null -eq $Filter.expression) {
        throw 'Filter.expression is required.'
    }

    return $true
}

function ConvertTo-EAISdtFilterObject {
    [CmdletBinding()]
    param (
        [Parameter(Mandatory)]
        $Filter
    )

    if ($Filter -is [hashtable]) {
        $Filter = [PSCustomObject]$Filter
    }

    Test-EAISdtFilterObject -Filter $Filter | Out-Null
    return $Filter
}

function Read-EAISdtFilterFromFile {
    [CmdletBinding()]
    param (
        [Parameter(Mandatory)]
        [String]$Path
    )

    if (-not (Test-Path -LiteralPath $Path)) {
        throw "Filter file not found: $Path"
    }

    try {
        $filter = Get-Content -LiteralPath $Path -Raw | ConvertFrom-Json -ErrorAction Stop
    }
    catch {
        throw "Failed to parse filter JSON from '$Path': $($_.Exception.Message)"
    }

    Test-EAISdtFilterObject -Filter $filter | Out-Null
    return $filter
}

function ConvertTo-EAISdtRecurrencePattern {
    [CmdletBinding()]
    param (
        [Parameter(Mandatory)]
        [hashtable]$ScheduleBound,

        [Parameter(Mandatory)]
        [ValidateSet('OneTimeDuration', 'OneTimeRange', 'Recurring')]
        [String]$ParameterSetName
    )

    switch ($ParameterSetName) {
        { $_ -in @('OneTimeDuration', 'OneTimeRange') } {
            return @{
                downtimeScheduleType = 'ONE_TIME'
                frequency            = 'DAILY'
            }
        }

        'Recurring' {
            $sdtType = if ($ScheduleBound.ContainsKey('SdtType')) {
                $ScheduleBound['SdtType']
            }
            else {
                'Daily'
            }

            $pattern = @{
                downtimeScheduleType = 'RECURRING'
                frequency            = $Script:EAISdtSdtTypeToFrequencyMap[$sdtType]
            }

            switch ($sdtType) {
                'Weekly' {
                    $pattern.weekDays = @($ScheduleBound['WeekDay'])
                }
                'Monthly' {
                    $pattern.monthDay = $ScheduleBound['DayOfMonth']
                }
                'MonthlyByWeek' {
                    $pattern.weekDays = @($ScheduleBound['WeekDay'])
                    $pattern.weekOfMonth = $Script:EAISdtWeekOfMonthMap[$ScheduleBound['WeekOfMonth']]
                }
            }

            return $pattern
        }
    }

    throw "Unsupported schedule parameter set '$ParameterSetName'."
}

function Get-EAISdtRecurringStartDateTime {
    [CmdletBinding()]
    param (
        [Parameter(Mandatory)]
        [hashtable]$ScheduleBound,

        [Parameter(Mandatory)]
        [String]$Timezone
    )

    $startHour = $ScheduleBound['StartHour']
    $startMinute = $ScheduleBound['StartMinute']
    $startDate = $ScheduleBound['StartDate']

    if ($startDate) {
        return $startDate.Date.AddHours($startHour).AddMinutes($startMinute)
    }

    $timeZoneInfo = Find-LMTimeZoneInfo -Timezone $Timezone
    $nowInTimezone = [TimeZoneInfo]::ConvertTime([DateTime]::UtcNow, $timeZoneInfo)
    return $nowInTimezone.Date.AddHours($startHour).AddMinutes($startMinute)
}

function Build-EAISdtScheduleFields {
    [CmdletBinding()]
    param (
        [Parameter(Mandatory)]
        [hashtable]$BoundParameters,

        [switch]$AllowPartial,

        [Object]$ExistingSchedule
    )

    $scheduleBound = Get-LMSDTScheduleBoundParameters -BoundParameters $BoundParameters

    if ($AllowPartial -and $ExistingSchedule -and $scheduleBound.Count -gt 0) {
        $existingBound = ConvertFrom-EAISdtScheduleToBoundParameters -Schedule $ExistingSchedule
        foreach ($key in $existingBound.Keys) {
            if (-not $scheduleBound.ContainsKey($key)) {
                $scheduleBound[$key] = $existingBound[$key]
            }
        }
    }

    if ($scheduleBound.Count -eq 0) {
        if ($AllowPartial) {
            return @{}
        }

        throw 'No schedule parameters were specified.'
    }

    $parameterSetName = Resolve-LMSDTScheduleParameterSetName -BoundParameters $scheduleBound -AllowPartial:$AllowPartial

    if ($parameterSetName -eq 'Partial') {
        return @{}
    }

    if ($parameterSetName -eq 'Recurring' -and -not $scheduleBound.ContainsKey('SdtType')) {
        $scheduleBound['SdtType'] = 'Daily'
    }

    Test-LMSDTScheduleParameters -ParameterSetName $parameterSetName -BoundParameters $scheduleBound | Out-Null

    $timezone = Resolve-EAISdtTimezone -Timezone $scheduleBound['Timezone']
    $result = @{
        timezone = $timezone
    }

    switch ($parameterSetName) {
        'OneTimeDuration' {
            $durationMinutes = $scheduleBound['Duration']

            if ($scheduleBound['StartDate']) {
                $effectiveStart = Assert-EAISdtFutureStartDateTime -Timezone $timezone -StartDateTime $scheduleBound['StartDate']
            }
            else {
                $effectiveStart = Get-EAISdtImminentStartDateTime -Timezone $timezone
            }

            $result.startTime = Format-EAISdtLocalDateTime -DateTime $effectiveStart
            $result.duration = ConvertTo-EAISdtIsoDuration -TotalMinutes $durationMinutes
            $result.recurrencePattern = ConvertTo-EAISdtRecurrencePattern -ScheduleBound $scheduleBound -ParameterSetName $parameterSetName
        }

        'OneTimeRange' {
            $startDate = Assert-EAISdtFutureStartDateTime -Timezone $timezone -StartDateTime $scheduleBound['StartDate']
            $endDate = $scheduleBound['EndDate']
            $durationMinutes = [Int][Math]::Round(($endDate - $startDate).TotalMinutes)

            $result.startTime = Format-EAISdtLocalDateTime -DateTime $startDate
            $result.duration = ConvertTo-EAISdtIsoDuration -TotalMinutes $durationMinutes
            $result.recurrencePattern = ConvertTo-EAISdtRecurrencePattern -ScheduleBound $scheduleBound -ParameterSetName $parameterSetName
        }

        'Recurring' {
            $durationMinutes = Get-LMSDTRecurringDurationMinutes `
                -StartHour $scheduleBound['StartHour'] `
                -StartMinute $scheduleBound['StartMinute'] `
                -EndHour $scheduleBound['EndHour'] `
                -EndMinute $scheduleBound['EndMinute']

            $startDateTime = Get-EAISdtFutureRecurringStartDateTime -ScheduleBound $scheduleBound -Timezone $timezone

            $result.startTime = Format-EAISdtLocalDateTime -DateTime $startDateTime
            $result.duration = ConvertTo-EAISdtIsoDuration -TotalMinutes $durationMinutes
            $result.recurrencePattern = ConvertTo-EAISdtRecurrencePattern -ScheduleBound $scheduleBound -ParameterSetName $parameterSetName
        }
    }

    return $result
}

function ConvertFrom-EAISdtScheduleToBoundParameters {
    [CmdletBinding()]
    param (
        [Parameter(Mandatory)]
        $Schedule
    )

    $result = @{}

    if ($Schedule.timezone) {
        $result.Timezone = $Schedule.timezone
    }

    if ($Schedule.startTime) {
        $startTime = [Datetime]$Schedule.startTime
        $result.StartHour = $startTime.Hour
        $result.StartMinute = $startTime.Minute
    }

    if ($Schedule.duration) {
        $durationMinutes = ConvertFrom-EAISdtIsoDuration -Duration $Schedule.duration
        if ($null -ne $result.StartHour -and $null -ne $result.StartMinute) {
            $startTotalMinutes = ($result.StartHour * 60) + $result.StartMinute
            $endTotalMinutes = $startTotalMinutes + $durationMinutes
            $result.EndHour = [Math]::Floor(($endTotalMinutes % (24 * 60)) / 60)
            $result.EndMinute = $endTotalMinutes % 60
        }
        else {
            $result.Duration = $durationMinutes
            if ($Schedule.startTime) {
                $result.StartDate = [Datetime]$Schedule.startTime
            }
        }
    }

    $pattern = $Schedule.recurrencePattern
    if ($pattern) {
        if ($pattern.downtimeScheduleType -eq 'ONE_TIME') {
            if ($Schedule.startTime) {
                $result.StartDate = [Datetime]$Schedule.startTime
            }
            if ($Schedule.duration) {
                $result.Duration = ConvertFrom-EAISdtIsoDuration -Duration $Schedule.duration
            }
        }
        else {
            switch ($pattern.frequency) {
                'DAILY' { $result.SdtType = 'Daily' }
                'WEEKLY' {
                    $result.SdtType = 'Weekly'
                    if ($pattern.weekDays) {
                        $result.WeekDay = @($pattern.weekDays)
                    }
                }
                'MONTHLY' {
                    if ($pattern.monthDay) {
                        $result.SdtType = 'Monthly'
                        $result.DayOfMonth = $pattern.monthDay
                    }
                    elseif ($pattern.weekOfMonth -and $pattern.weekDays) {
                        $result.SdtType = 'MonthlyByWeek'
                        $result.WeekDay = @($pattern.weekDays)
                        $weekOfMonthEntry = $Script:EAISdtWeekOfMonthMap.GetEnumerator() |
                            Where-Object { $_.Value -eq $pattern.weekOfMonth } |
                            Select-Object -First 1
                        if ($weekOfMonthEntry) {
                            $result.WeekOfMonth = $weekOfMonthEntry.Key
                        }
                    }
                    else {
                        $result.SdtType = 'Monthly'
                    }
                }
            }
        }
    }

    return $result
}

function Build-EAISdtSchedulePayload {
    [CmdletBinding()]
    param (
        [Parameter(Mandatory)]
        [hashtable]$BoundParameters
    )

    if (-not $BoundParameters.ContainsKey('Name') -or [string]::IsNullOrWhiteSpace([string]$BoundParameters['Name'])) {
        throw 'Name is required.'
    }

    if (-not $BoundParameters.ContainsKey('Filter')) {
        throw 'Filter is required.'
    }

    $scheduleFields = Build-EAISdtScheduleFields -BoundParameters $BoundParameters

    $payload = @{
        name    = [string]$BoundParameters['Name']
        enabled = Get-EAISdtEnabledApiValue -BoundParameters $BoundParameters
        filter  = ConvertTo-EAISdtFilterObject -Filter $BoundParameters['Filter']
        version = 1
    }

    if ($BoundParameters.ContainsKey('Description') -and -not [string]::IsNullOrWhiteSpace([string]$BoundParameters['Description'])) {
        $payload.description = [string]$BoundParameters['Description']
    }

    foreach ($key in $scheduleFields.Keys) {
        $payload[$key] = $scheduleFields[$key]
    }

    return $payload
}

function Test-EAISdtScheduleSnapshot {
    [CmdletBinding()]
    param (
        [Parameter(Mandatory)]
        $InputObject
    )

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

    return $null -ne $InputObject.PSObject.Properties['name'] -and
        $null -ne $InputObject.PSObject.Properties['filter']
}

function ConvertTo-EAISdtScheduleSnapshot {
    [CmdletBinding()]
    param (
        [Parameter(Mandatory)]
        $ExistingSchedule
    )

    if (-not (Test-EAISdtScheduleSnapshot -InputObject $ExistingSchedule)) {
        throw 'Existing schedule is missing required fields. Retrieve the schedule with Get-EAISdt before updating metadata.'
    }

    $payload = @{
        name    = [string]$ExistingSchedule.name
        enabled = [string]$ExistingSchedule.enabled
        filter  = ConvertTo-EAISdtFilterObject -Filter $ExistingSchedule.filter
    }

    if ($null -ne $ExistingSchedule.PSObject.Properties['description']) {
        $payload.description = [string]$ExistingSchedule.description
    }

    foreach ($key in @('timezone', 'startTime', 'recurrencePattern', 'endTime', 'duration')) {
        if ($null -ne $ExistingSchedule.PSObject.Properties[$key] -and $null -ne $ExistingSchedule.$key) {
            $payload[$key] = $ExistingSchedule.$key
        }
    }

    return $payload
}

function Build-EAISdtMetadataUpdatePayload {
    [CmdletBinding()]
    param (
        [Parameter(Mandatory)]
        [hashtable]$BoundParameters,

        [Parameter(Mandatory)]
        $ExistingSchedule
    )

    $hasMetadataUpdate = $false
    foreach ($key in @('Name', 'Description', 'Enabled')) {
        if ($BoundParameters.ContainsKey($key)) {
            $hasMetadataUpdate = $true
            break
        }
    }

    if (-not $hasMetadataUpdate) {
        throw 'No metadata fields were specified for update.'
    }

    $payload = ConvertTo-EAISdtScheduleSnapshot -ExistingSchedule $ExistingSchedule

    if ($BoundParameters.ContainsKey('Name') -and -not [string]::IsNullOrWhiteSpace([string]$BoundParameters['Name'])) {
        $payload.name = [string]$BoundParameters['Name']
    }

    if ($BoundParameters.ContainsKey('Description')) {
        $payload.description = [string]$BoundParameters['Description']
    }

    if ($BoundParameters.ContainsKey('Enabled')) {
        $payload.enabled = Get-EAISdtEnabledApiValue -BoundParameters $BoundParameters
    }

    return $payload
}

function Build-EAISdtFilterUpdatePayload {
    [CmdletBinding()]
    param (
        [Parameter(Mandatory)]
        [hashtable]$BoundParameters
    )

    if (-not $BoundParameters.ContainsKey('Filter')) {
        throw 'Filter is required for filter updates.'
    }

    return @{
        newFilter = ConvertTo-EAISdtFilterObject -Filter $BoundParameters['Filter']
    }
}

function Build-EAISdtRecurrenceUpdatePayload {
    [CmdletBinding()]
    param (
        [Parameter(Mandatory)]
        [hashtable]$BoundParameters,

        [Parameter(Mandatory)]
        $ExistingSchedule
    )

    $scheduleFields = Build-EAISdtScheduleFields -BoundParameters $BoundParameters -AllowPartial -ExistingSchedule $ExistingSchedule

    if (-not $scheduleFields.ContainsKey('recurrencePattern')) {
        throw 'No recurrence fields were specified for update.'
    }

    return @{
        newPattern = $scheduleFields.recurrencePattern
    }
}

function Write-EAISdtSchedulePreview {
    [CmdletBinding()]
    [Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSAvoidUsingWriteHost', '', Justification = 'Required for interactive wizards')]
    param (
        [Parameter(Mandatory)]
        [hashtable]$PreviewPayload
    )

    if ($PreviewPayload.ContainsKey('timezone')) {
        Write-Host "Timezone: $($PreviewPayload.timezone)"
    }

    if ($PreviewPayload.ContainsKey('startTime')) {
        Write-Host "Start: $($PreviewPayload.startTime)"
    }

    if ($PreviewPayload.ContainsKey('duration')) {
        Write-Host "Duration: $($PreviewPayload.duration)"
    }

    if ($PreviewPayload.ContainsKey('recurrencePattern')) {
        $pattern = $PreviewPayload.recurrencePattern
        Write-Host "Recurrence: $($pattern.downtimeScheduleType) / $($pattern.frequency)"
        if ($pattern.weekDays) {
            Write-Host "WeekDays: $($pattern.weekDays -join ', ')"
        }
        if ($pattern.monthDay) {
            Write-Host "MonthDay: $($pattern.monthDay)"
        }
        if ($pattern.weekOfMonth) {
            Write-Host "WeekOfMonth: $($pattern.weekOfMonth)"
        }
    }
}

function Confirm-EAISdtWizardSummary {
    [CmdletBinding()]
    [Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSAvoidUsingWriteHost', '', Justification = 'Required for interactive wizards')]
    param (
        [Parameter(Mandatory)]
        [ValidateSet('Create', 'Update')]
        [String]$Mode,

        [Parameter(Mandatory)]
        [hashtable]$BoundParameters
    )

    Write-Host ''
    Write-Host '--- Edwin SDT Summary ---' -ForegroundColor Cyan

    if ($Mode -eq 'Update') {
        $schedule = Get-EAISdt -Id $BoundParameters['Id'] -ErrorAction SilentlyContinue
        if ($schedule) {
            $label = if ([string]::IsNullOrWhiteSpace([string]$schedule.name)) { '(no name)' } else { $schedule.name }
            Write-Host "Schedule: $label (ID: $($schedule.scheduleId))"
        }
        else {
            Write-Host "Schedule ID: $($BoundParameters['Id'])"
        }
    }

    if ($Mode -eq 'Create' -and $BoundParameters.ContainsKey('Name')) {
        Write-Host "Name: $($BoundParameters['Name'])"
    }

    if ($BoundParameters.ContainsKey('Description') -and -not [string]::IsNullOrWhiteSpace([string]$BoundParameters['Description'])) {
        Write-Host "Description: $($BoundParameters['Description'])"
    }

    if ($BoundParameters.ContainsKey('Filter')) {
        $filter = ConvertTo-EAISdtFilterObject -Filter $BoundParameters['Filter']
        Write-Host "Filter: $($filter.schemaName) v$($filter.schemaVersion)"
    }

    if ($BoundParameters.ContainsKey('Enabled')) {
        Write-Host "Enabled: $(Get-EAISdtEnabledApiValue -BoundParameters $BoundParameters)"
    }

    $scheduleBound = Get-LMSDTScheduleBoundParameters -BoundParameters $BoundParameters
    if ($scheduleBound.Count -gt 0) {
        if ($Mode -eq 'Create') {
            $previewPayload = Build-EAISdtScheduleFields -BoundParameters $BoundParameters
        }
        else {
            $existingSchedule = if ($schedule) { $schedule } else { $null }
            $previewPayload = Build-EAISdtScheduleFields -BoundParameters $BoundParameters -AllowPartial -ExistingSchedule $existingSchedule
        }

        Write-EAISdtSchedulePreview -PreviewPayload $previewPayload
    }

    Write-Host '-------------------------'

    if (-not (Get-LMUserConfirmation -Prompt 'Proceed with this SDT?' -DefaultAnswer 'y')) {
        Write-Host 'SDT wizard cancelled.' -ForegroundColor Yellow
        return $false
    }

    return $true
}