Modules/IdLE.Steps.Mailbox/Public/Invoke-IdleStepMailboxOutOfOfficeEnsure.ps1

function Invoke-IdleStepMailboxOutOfOfficeEnsure {
    <#
    .SYNOPSIS
    Ensures that a mailbox Out of Office (OOF) configuration matches the desired state.

    .DESCRIPTION
    This is a provider-agnostic step. The host must supply a provider instance via
    Context.Providers[<ProviderAlias>]. The provider must implement an EnsureOutOfOffice
    method with the signature (IdentityKey, Config, AuthSession) and return an object
    that contains a boolean property 'Changed'.

    The step is idempotent by design: it converges OOF configuration to the desired state.

    Out of Office Config shape (data-only hashtable):
    - Mode: 'Disabled' | 'Enabled' | 'Scheduled' (required)
    - Start: DateTime (required when Mode = 'Scheduled')
    - End: DateTime (required when Mode = 'Scheduled')
    - InternalMessage: string (optional)
    - ExternalMessage: string (optional)
    - ExternalAudience: 'None' | 'Known' | 'All' (optional, default provider-specific)

    Authentication:
    - If With.AuthSessionName is present, the step acquires an auth session via
      Context.AcquireAuthSession(Name, Options) and passes it to the provider method.
    - If With.AuthSessionName is absent, defaults to With.Provider value (e.g., 'ExchangeOnline').
    - With.AuthSessionOptions (optional, hashtable) is passed to the broker for
      session selection (e.g., @{ Role = 'Admin' }).

    .PARAMETER Context
    Execution context created by IdLE.Core.

    .PARAMETER Step
    Normalized step object from the plan. Must contain a 'With' hashtable.

    .OUTPUTS
    PSCustomObject (PSTypeName: IdLE.StepResult)

    .EXAMPLE
    # In workflow definition (enable OOF):
    @{
        Name = 'Enable Out of Office'
        Type = 'IdLE.Step.Mailbox.OutOfOffice.Ensure'
        With = @{
            Provider = 'ExchangeOnline'
            IdentityKey = 'user@contoso.com'
            Config = @{
                Mode = 'Enabled'
                InternalMessage = 'I am out of office.'
                ExternalMessage = 'I am currently unavailable.'
                ExternalAudience = 'All'
            }
        }
    }

    .EXAMPLE
    # In workflow definition (with ValueFrom for dynamic values):
    @{
        Name = 'Enable Out of Office for Leaver'
        Type = 'IdLE.Step.Mailbox.OutOfOffice.Ensure'
        With = @{
            Provider = 'ExchangeOnline'
            IdentityKey = @{ ValueFrom = 'Request.Input.UserPrincipalName' }
            Config = @{
                Mode = 'Enabled'
                InternalMessage = 'This person is no longer with the organization. For assistance, please contact their manager or the main office.'
                ExternalMessage = 'This person is no longer with the organization. Please contact the main office for assistance.'
                ExternalAudience = 'All'
            }
        }
    }

    .EXAMPLE
    # In workflow definition (scheduled OOF):
    @{
        Name = 'Schedule Out of Office'
        Type = 'IdLE.Step.Mailbox.OutOfOffice.Ensure'
        With = @{
            Provider = 'ExchangeOnline'
            IdentityKey = 'user@contoso.com'
            Config = @{
                Mode = 'Scheduled'
                Start = '2025-02-01T00:00:00Z'
                End = '2025-02-15T00:00:00Z'
                InternalMessage = 'I am on vacation until February 15.'
                ExternalMessage = 'I am currently out of office.'
            }
        }
    }

    .EXAMPLE
    # In workflow definition (disable OOF):
    @{
        Name = 'Disable Out of Office'
        Type = 'IdLE.Step.Mailbox.OutOfOffice.Ensure'
        With = @{
            Provider = 'ExchangeOnline'
            IdentityKey = 'user@contoso.com'
            Config = @{
                Mode = 'Disabled'
            }
        }
    }
    #>

    [CmdletBinding()]
    param(
        [Parameter(Mandatory)]
        [ValidateNotNull()]
        [object] $Context,

        [Parameter(Mandatory)]
        [ValidateNotNull()]
        [object] $Step
    )

    $with = $Step.With
    if ($null -eq $with -or -not ($with -is [hashtable])) {
        throw "Mailbox.OutOfOffice.Ensure requires 'With' to be a hashtable."
    }

    foreach ($key in @('IdentityKey', 'Config')) {
        if (-not $with.ContainsKey($key)) {
            throw "Mailbox.OutOfOffice.Ensure requires With.$key."
        }
    }

    $config = $with.Config
    if ($null -eq $config -or -not ($config -is [hashtable])) {
        throw "Mailbox.OutOfOffice.Ensure requires With.Config to be a hashtable."
    }

    # Validate Config shape
    if (-not $config.ContainsKey('Mode')) {
        throw "Mailbox.OutOfOffice.Ensure requires With.Config.Mode (Disabled, Enabled, or Scheduled)."
    }

    $validModes = @('Disabled', 'Enabled', 'Scheduled')
    if ($config.Mode -notin $validModes) {
        throw "Mailbox.OutOfOffice.Ensure requires With.Config.Mode to be one of: $($validModes -join ', '). Got: $($config.Mode)"
    }

    # Validate Scheduled mode requirements
    if ($config.Mode -eq 'Scheduled') {
        foreach ($key in @('Start', 'End')) {
            if (-not $config.ContainsKey($key)) {
                throw "Mailbox.OutOfOffice.Ensure with Mode 'Scheduled' requires With.Config.$key."
            }
        }
    }

    # Security: reject ScriptBlocks in Config (data-only constraint)
    foreach ($key in $config.Keys) {
        if ($config[$key] -is [ScriptBlock]) {
            throw "Mailbox.OutOfOffice.Ensure With.Config must not contain ScriptBlocks. Found ScriptBlock in key '$key'."
        }
    }

    $providerAlias = if ($with.ContainsKey('Provider')) { [string]$with.Provider } else { 'ExchangeOnline' }

    if (-not ($Context.PSObject.Properties.Name -contains 'Providers')) {
        throw "Context does not contain a Providers hashtable."
    }
    if ($null -eq $Context.Providers -or -not ($Context.Providers -is [hashtable])) {
        throw "Context.Providers must be a hashtable."
    }
    if (-not $Context.Providers.ContainsKey($providerAlias)) {
        throw "Provider '$providerAlias' was not supplied by the host."
    }

    # Create execution-local copy of With to avoid mutating the plan
    $effectiveWith = if ($with -is [hashtable]) { $with.Clone() } else { @{} + $with }

    # Apply AuthSessionName convention: default to Provider if not specified
    if (-not $effectiveWith.ContainsKey('AuthSessionName')) {
        $effectiveWith['AuthSessionName'] = $providerAlias
    }

    $result = Invoke-IdleProviderMethod `
        -Context $Context `
        -With $effectiveWith `
        -ProviderAlias $providerAlias `
        -MethodName 'EnsureOutOfOffice' `
        -MethodArguments @([string]$effectiveWith.IdentityKey, $config)

    $changed = $false
    if ($null -ne $result -and ($result.PSObject.Properties.Name -contains 'Changed')) {
        $changed = [bool]$result.Changed
    }

    return [pscustomobject]@{
        PSTypeName = 'IdLE.StepResult'
        Name       = [string]$Step.Name
        Type       = [string]$Step.Type
        Status     = 'Completed'
        Changed    = $changed
        Error      = $null
    }
}