Modules/IdLE.Core/Public/Invoke-IdlePlanObject.ps1

function Invoke-IdlePlanObject {
    <#
    .SYNOPSIS
    Executes an IdLE plan object and returns a deterministic execution result.

    .DESCRIPTION
    Executes steps in order, emits structured events, and returns a stable execution result.

    Security:
    - ScriptBlocks are rejected in plan and providers.
    - The returned execution result is an output boundary: Providers are redacted.

    .PARAMETER Plan
    Plan object created by New-IdlePlanObject.

    .PARAMETER Providers
    Provider registry/collection passed through to execution.

    .PARAMETER EventSink
    Optional external event sink object. Must provide a WriteEvent(event) method.

    .OUTPUTS
    PSCustomObject (PSTypeName: IdLE.ExecutionResult)
    #>

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

        [Parameter()]
        [AllowNull()]
        [object] $Providers,

        [Parameter()]
        [AllowNull()]
        [object] $EventSink
    )

    function Get-IdleCommandParameterNames {
        [CmdletBinding()]
        param(
            [Parameter(Mandatory)]
            [ValidateNotNull()]
            [object] $Handler
        )

        # Returns a HashSet[string] of parameter names supported by the handler.
        $set = [System.Collections.Generic.HashSet[string]]::new([System.StringComparer]::OrdinalIgnoreCase)

        if ($Handler -is [scriptblock]) {

            $paramBlock = $Handler.Ast.ParamBlock
            if ($null -eq $paramBlock) {
                return $set
            }

            foreach ($p in $paramBlock.Parameters) {
                # Parameter name is stored without the leading '$'
                $null = $set.Add([string]$p.Name.VariablePath.UserPath)
            }

            return $set
        }

        if ($Handler -is [System.Management.Automation.CommandInfo]) {
            foreach ($n in $Handler.Parameters.Keys) {
                $null = $set.Add([string]$n)
            }
            return $set
        }

        # Unknown handler shape: return an empty set.
        return $set
    }

    function Resolve-IdleStepHandler {
        [CmdletBinding()]
        param(
            [Parameter(Mandatory)]
            [ValidateNotNullOrEmpty()]
            [string] $StepType,

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

        $handlerName = $null

        if ($StepRegistry -is [System.Collections.IDictionary]) {
            if ($StepRegistry.Contains($StepType)) {
                $handlerName = $StepRegistry[$StepType]
            }
        }
        else {
            if ($StepRegistry.PSObject.Properties.Name -contains $StepType) {
                $handlerName = $StepRegistry.$StepType
            }
        }

        if ($null -eq $handlerName -or [string]::IsNullOrWhiteSpace([string]$handlerName)) {
            throw [System.ArgumentException]::new("No step handler registered for step type '$StepType'.", 'Providers')
        }

        # Reject ScriptBlock handlers (secure default).
        if ($handlerName -is [scriptblock]) {
            throw [System.ArgumentException]::new(
                "Step registry handler for '$StepType' must be a function name (string), not a ScriptBlock.",
                'Providers'
            )
        }

        $cmd = Get-Command -Name ([string]$handlerName) -CommandType Function -ErrorAction Stop
        return $cmd
    }

    function Get-IdleStepField {
        [CmdletBinding()]
        param(
            [Parameter(Mandatory)]
            [AllowNull()]
            [object] $Step,

            [Parameter(Mandatory)]
            [ValidateNotNullOrEmpty()]
            [string] $Name
        )

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

        if ($Step -is [System.Collections.IDictionary]) {
            if ($Step.Contains($Name)) {
                return $Step[$Name]
            }
            return $null
        }

        $propNames = @($Step.PSObject.Properties.Name)
        if ($propNames -contains $Name) {
            return $Step.$Name
        }

        return $null
    }

    $planPropNames = @($Plan.PSObject.Properties.Name)

    $request = if ($planPropNames -contains 'Request') { $Plan.Request } else { $null }
    $requestPropNames = if ($null -ne $request) { @($request.PSObject.Properties.Name) } else { @() }

    $corr = if ($null -ne $request -and $requestPropNames -contains 'CorrelationId') {
        $request.CorrelationId
    }
    else {
        if ($planPropNames -contains 'CorrelationId') { $Plan.CorrelationId } else { $null }
    }

    $actor = if ($null -ne $request -and $requestPropNames -contains 'Actor') {
        $request.Actor
    }
    else {
        if ($planPropNames -contains 'Actor') { $Plan.Actor } else { $null }
    }

    $events = [System.Collections.Generic.List[object]]::new()

    # Host may pass an external sink. If none is provided, we still buffer events internally.
    $engineEventSink = New-IdleEventSink -CorrelationId $corr -Actor $actor -ExternalEventSink $EventSink -EventBuffer $events

    # Enforce data-only boundary: reject ScriptBlocks in untrusted inputs.
    # Special-case: for auth session acquisition options, throw a contextualized error message.
    $planSteps = if ($planPropNames -contains 'Steps') { $Plan.Steps } else { $null }
    if ($null -ne $planSteps -and ($planSteps -is [System.Collections.IEnumerable]) -and ($planSteps -isnot [string])) {
        $i = 0
        foreach ($step in $planSteps) {
            $stepType = [string](Get-IdleStepField -Step $step -Name 'Type')
            if ($stepType -eq 'IdLE.Step.AcquireAuthSession') {
                $with = Get-IdleStepField -Step $step -Name 'With'
                $options = $null
                if ($null -ne $with) {
                    if ($with -is [System.Collections.IDictionary]) {
                        if ($with.Contains('Options')) { $options = $with['Options'] }
                    }
                    else {
                        if ($with.PSObject.Properties.Name -contains 'Options') { $options = $with.Options }
                    }
                }

                Assert-IdleNoScriptBlockInAuthSessionOptions -InputObject $options -Path "Plan.Steps[$i].With.Options"
            }

            $i++
        }
    }

    Assert-IdleNoScriptBlock -InputObject $Plan -Path 'Plan'
    Assert-IdleNoScriptBlock -InputObject $Providers -Path 'Providers'

    # StepRegistry is constructed via helper to ensure built-in steps and host-provided steps can co-exist.
    $stepRegistry = Get-IdleStepRegistry -Providers $Providers

    $context = [pscustomobject]@{
        PSTypeName = 'IdLE.ExecutionContext'
        Plan       = $Plan
        Providers  = $Providers
        EventSink  = $engineEventSink
    }

    # Expose common run metadata on the execution context so providers can enrich session acquisition requests
    # without having to parse the plan structure themselves.
    $null = $context | Add-Member -MemberType NoteProperty -Name CorrelationId -Value $corr -Force
    $null = $context | Add-Member -MemberType NoteProperty -Name Actor -Value $actor -Force

    # Session acquisition boundary:
    # - Providers MUST NOT implement their own authentication flows.
    # - The host supplies an AuthSessionBroker in Providers.AuthSessionBroker.
    # - Options must be data-only (no ScriptBlocks).
    $null = $context | Add-Member -MemberType ScriptMethod -Name AcquireAuthSession -Value {
        [CmdletBinding()]
        param(
            [Parameter(Mandatory)]
            [ValidateNotNullOrEmpty()]
            [string] $Name,

            [Parameter()]
            [AllowNull()]
            [hashtable] $Options
        )

        $providers = $this.Providers
        $broker = $null

        if ($providers -is [System.Collections.IDictionary]) {
            if ($providers.Contains('AuthSessionBroker')) {
                $broker = $providers['AuthSessionBroker']
            }
        }
        else {
            if ($null -ne $providers -and $providers.PSObject.Properties.Name -contains 'AuthSessionBroker') {
                $broker = $providers.AuthSessionBroker
            }
        }

        if ($null -eq $broker) {
            throw [System.InvalidOperationException]::new(
                'No AuthSessionBroker configured. Provide Providers.AuthSessionBroker to acquire auth sessions during execution.'
            )
        }

        if ($broker.PSObject.Methods.Name -notcontains 'AcquireAuthSession') {
            throw [System.InvalidOperationException]::new(
                'AuthSessionBroker must provide an AcquireAuthSession(Name, Options) method.'
            )
        }

        $normalizedOptions = if ($null -eq $Options) { @{} } else { $Options }
        Assert-IdleNoScriptBlockInAuthSessionOptions -InputObject $normalizedOptions -Path 'AuthSessionOptions'

        # Copy options to avoid mutating caller-owned hashtables.
        $optionsCopy = Copy-IdleDataObject -Value $normalizedOptions

        if ($null -ne $this.CorrelationId) { $optionsCopy['CorrelationId'] = $this.CorrelationId }
        if ($null -ne $this.Actor) { $optionsCopy['Actor'] = $this.Actor }

        return $broker.AcquireAuthSession($Name, $optionsCopy)
    } -Force

    # Fail-fast security validation: Check if AuthSessionBroker is required but missing.
    # AcquireAuthSession steps require an AuthSessionBroker to be present in Providers.
    # Skip NotApplicable steps, as they won't be executed and don't require the broker.
    $requiresAuthBroker = $false
    $steps = if ($planPropNames -contains 'Steps') { $Plan.Steps } else { @() }
    foreach ($step in $steps) {
        if ($null -eq $step) { continue }

        $stepType = $null
        $stepStatus = $null
        if ($step -is [System.Collections.IDictionary]) {
            if ($step.Contains('Type')) {
                $stepType = $step['Type']
            }
            if ($step.Contains('Status')) {
                $stepStatus = $step['Status']
            }
        }
        else {
            $stepPropNames = @($step.PSObject.Properties.Name)
            $stepType = if ($stepPropNames -contains 'Type') { $step.Type } else { $null }
            $stepStatus = if ($stepPropNames -contains 'Status') { $step.Status } else { $null }
        }

        if ($stepType -eq 'IdLE.Step.AcquireAuthSession' -and $stepStatus -ne 'NotApplicable') {
            $requiresAuthBroker = $true
            break
        }
    }

    if ($requiresAuthBroker) {
        $broker = $null
        if ($Providers -is [System.Collections.IDictionary]) {
            if ($Providers.Contains('AuthSessionBroker')) {
                $broker = $Providers['AuthSessionBroker']
            }
        }
        else {
            if ($null -ne $Providers -and $Providers.PSObject.Properties.Name -contains 'AuthSessionBroker') {
                $broker = $Providers.AuthSessionBroker
            }
        }

        if ($null -eq $broker) {
            throw [System.InvalidOperationException]::new(
                'AuthSessionBroker is required but not configured. One or more steps require auth session acquisition. Provide Providers.AuthSessionBroker to proceed.'
            )
        }
    }

    $context.EventSink.WriteEvent(
        'RunStarted',
        'Plan execution started.',
        $null,
        @{
            CorrelationId = $corr
            Actor         = $actor
            StepCount     = @($Plan.Steps).Count
        }
    )

    $failed = $false
    $stepResults = @()

    $i = 0
    foreach ($step in $Plan.Steps) {

        if ($null -eq $step) {
            continue
        }

        $stepName = [string](Get-IdleStepField -Step $step -Name 'Name')
        if ($null -eq $stepName) { $stepName = '' }

        $stepType = Get-IdleStepField -Step $step -Name 'Type'
        $stepWith = Get-IdleStepField -Step $step -Name 'With'
        $stepStatus = [string](Get-IdleStepField -Step $step -Name 'Status')
        if ($null -eq $stepStatus) { $stepStatus = '' }

        # Conditions are evaluated during planning and represented as Step.Status.
        if ($stepStatus -eq 'NotApplicable') {

            $stepResults += [pscustomobject]@{
                PSTypeName = 'IdLE.StepResult'
                Name       = $stepName
                Type       = $stepType
                Status     = 'NotApplicable'
                Attempts   = 1
            }

            $context.EventSink.WriteEvent(
                'StepNotApplicable',
                "Step '$stepName' not applicable (condition not met).",
                $stepName,
                @{
                    StepType = $stepType
                    Index    = $i
                }
            )

            $i++
            continue
        }

        $context.EventSink.WriteEvent(
            'StepStarted',
            "Step '$stepName' started.",
            $stepName,
            @{
                StepType = $stepType
                Index    = $i
            }
        )

        try {
            $impl = Resolve-IdleStepHandler -StepType ([string]$stepType) -StepRegistry $stepRegistry

            $supportedParams = Get-IdleCommandParameterNames -Handler $impl

            $invokeParams = @{}

            # Backwards compatibility: pass -Context only when the handler supports it.
            if ($supportedParams.Contains('Context')) {
                $invokeParams.Context = $context
            }

            if ($null -ne $stepWith -and $supportedParams.Contains('With')) {
                $invokeParams.With = $stepWith
            }

            if ($supportedParams.Contains('Step')) {
                $invokeParams.Step = $step
            }

            # Safe-by-default transient retries:
            # - Only retries if the thrown exception is explicitly marked transient.
            # - Emits 'StepRetrying' events and uses deterministic jitter/backoff.
            $retrySeed = "$corr|$stepType|$stepName|$i"
            $retry = Invoke-IdleWithRetry -Operation { & $impl @invokeParams } -EventSink $context.EventSink -StepName $stepName -OperationName 'StepExecution' -DeterministicSeed $retrySeed

            $result = $retry.Value
            $attempts = [int]$retry.Attempts

            if ($null -eq $result) {
                $result = [pscustomobject]@{
                    PSTypeName = 'IdLE.StepResult'
                    Name       = $stepName
                    Type       = $stepType
                    Status     = 'Completed'
                    Attempts   = $attempts
                }
            }
            else {
                # Normalize result to include Attempts for observability (non-breaking).
                if ($result.PSObject.Properties.Name -notcontains 'Attempts') {
                    $null = $result | Add-Member -MemberType NoteProperty -Name Attempts -Value $attempts -Force
                }
            }

            $stepResults += $result

            if ($result.Status -eq 'Failed') {
                $failed = $true

                $context.EventSink.WriteEvent(
                    'StepFailed',
                    "Step '$stepName' failed.",
                    $stepName,
                    @{
                        StepType = $stepType
                        Index    = $i
                        Error    = $result.Error
                    }
                )

                break
            }

            $context.EventSink.WriteEvent(
                'StepCompleted',
                "Step '$stepName' completed.",
                $stepName,
                @{
                    StepType = $stepType
                    Index    = $i
                }
            )
        }
        catch {
            $failed = $true
            $err = $_

            $stepResults += [pscustomobject]@{
                PSTypeName = 'IdLE.StepResult'
                Name       = $stepName
                Type       = $stepType
                Status     = 'Failed'
                Error      = $err.Exception.Message
                Attempts   = 1
            }

            $context.EventSink.WriteEvent(
                'StepFailed',
                "Step '$stepName' failed.",
                $stepName,
                @{
                    StepType = $stepType
                    Index    = $i
                    Error    = $err.Exception.Message
                }
            )

            break
        }

        $i++
    }

    $runStatus = if ($failed) { 'Failed' } else { 'Completed' }

    # Public result contract: the OnFailure section is always present.
    $onFailure = [pscustomobject]@{
        PSTypeName = 'IdLE.OnFailureExecutionResult'
        Status     = 'NotRun'
        Steps      = [object[]]@()
    }

    $planOnFailureSteps = @()
    if ($planPropNames -contains 'OnFailureSteps') {
        # Treat nulls as empty deterministically.
        $planOnFailureSteps = @($Plan.OnFailureSteps) | Where-Object { $null -ne $_ }
    }

    if ($failed -and @($planOnFailureSteps).Count -gt 0) {
        $context.EventSink.WriteEvent(
            'OnFailureStarted',
            'Executing OnFailureSteps (best effort).',
            $null,
            @{
                OnFailureStepCount = @($planOnFailureSteps).Count
            }
        )

        $onFailureHadFailures = $false
        $onFailureStepResults = @()

        $j = 0
        foreach ($ofStep in @($planOnFailureSteps)) {

            if ($null -eq $ofStep) {
                $j++
                continue
            }

            $ofPropNames = @($ofStep.PSObject.Properties.Name)
            $ofName = if ($ofPropNames -contains 'Name') { [string]$ofStep.Name } else { '' }
            $ofType = if ($ofPropNames -contains 'Type') { $ofStep.Type } else { $null }
            $ofWith = if ($ofPropNames -contains 'With') { $ofStep.With } else { $null }
            $ofStatus = if ($ofPropNames -contains 'Status') { [string]$ofStep.Status } else { '' }

            # Conditions for OnFailure steps are evaluated during planning as well.
            if ($ofStatus -eq 'NotApplicable') {

                $onFailureStepResults += [pscustomobject]@{
                    PSTypeName = 'IdLE.StepResult'
                    Name       = $ofName
                    Type       = $ofType
                    Status     = 'NotApplicable'
                    Attempts   = 1
                }

                $context.EventSink.WriteEvent(
                    'OnFailureStepNotApplicable',
                    "OnFailure step '$ofName' not applicable (condition not met).",
                    $ofName,
                    @{
                        StepType = $ofType
                        Index    = $j
                    }
                )

                $j++
                continue
            }

            $context.EventSink.WriteEvent(
                'OnFailureStepStarted',
                "OnFailure step '$ofName' started.",
                $ofName,
                @{
                    StepType = $ofType
                    Index    = $j
                }
            )

            try {
                $impl = Resolve-IdleStepHandler -StepType ([string]$ofType) -StepRegistry $stepRegistry

                $supportedParams = Get-IdleCommandParameterNames -Handler $impl

                $invokeParams = @{}

                # Backwards compatibility: pass -Context only when the handler supports it.
                if ($supportedParams.Contains('Context')) {
                    $invokeParams.Context = $context
                }

                if ($null -ne $ofWith -and $supportedParams.Contains('With')) {
                    $invokeParams.With = $ofWith
                }

                if ($supportedParams.Contains('Step')) {
                    $invokeParams.Step = $ofStep
                }

                # Reuse safe-by-default transient retries for OnFailure steps.
                $retrySeed = "$corr|OnFailure|$ofType|$ofName|$j"
                $retry = Invoke-IdleWithRetry -Operation { & $impl @invokeParams } -EventSink $context.EventSink -StepName $ofName -OperationName 'OnFailureStepExecution' -DeterministicSeed $retrySeed

                $result = $retry.Value
                $attempts = [int]$retry.Attempts

                if ($null -eq $result) {
                    $result = [pscustomobject]@{
                        PSTypeName = 'IdLE.StepResult'
                        Name       = $ofName
                        Type       = $ofType
                        Status     = 'Completed'
                        Attempts   = $attempts
                    }
                }
                else {
                    # Normalize result to include Attempts for observability (non-breaking).
                    if ($result.PSObject.Properties.Name -notcontains 'Attempts') {
                        $null = $result | Add-Member -MemberType NoteProperty -Name Attempts -Value $attempts -Force
                    }
                }

                $onFailureStepResults += $result

                if ($result.Status -eq 'Failed') {
                    $onFailureHadFailures = $true

                    $context.EventSink.WriteEvent(
                        'OnFailureStepFailed',
                        "OnFailure step '$ofName' failed.",
                        $ofName,
                        @{
                            StepType = $ofType
                            Index    = $j
                            Error    = $result.Error
                        }
                    )
                }
                else {
                    $context.EventSink.WriteEvent(
                        'OnFailureStepCompleted',
                        "OnFailure step '$ofName' completed.",
                        $ofName,
                        @{
                            StepType = $ofType
                            Index    = $j
                        }
                    )
                }
            }
            catch {
                $onFailureHadFailures = $true
                $err = $_

                $onFailureStepResults += [pscustomobject]@{
                    PSTypeName = 'IdLE.StepResult'
                    Name       = $ofName
                    Type       = $ofType
                    Status     = 'Failed'
                    Error      = $err.Exception.Message
                    Attempts   = 1
                }

                $context.EventSink.WriteEvent(
                    'OnFailureStepFailed',
                    "OnFailure step '$ofName' failed.",
                    $ofName,
                    @{
                        StepType = $ofType
                        Index    = $j
                        Error    = $err.Exception.Message
                    }
                )
            }

            $j++
        }

        $onFailureStatus = if ($onFailureHadFailures) { 'PartiallyFailed' } else { 'Completed' }

        $onFailure = [pscustomobject]@{
            PSTypeName = 'IdLE.OnFailureExecutionResult'
            Status     = $onFailureStatus
            Steps      = @($onFailureStepResults)
        }

        $context.EventSink.WriteEvent(
            'OnFailureCompleted',
            "OnFailureSteps finished (status: $onFailureStatus).",
            $null,
            @{
                Status    = $onFailureStatus
                StepCount = @($planOnFailureSteps).Count
            }
        )
    }

    # RunCompleted should always be the last event for deterministic event order.
    $context.EventSink.WriteEvent(
        'RunCompleted',
        "Plan execution finished (status: $runStatus).",
        $null,
        @{
            Status    = $runStatus
            StepCount = @($Plan.Steps).Count
        }
    )

    # Issue #48:
    # Redact provider configuration/state at the output boundary (execution result).
    $redactedProviders = if ($null -ne $Providers) {
        Copy-IdleRedactedObject -Value $Providers
    }
    else {
        $null
    }

    return [pscustomobject]@{
        PSTypeName    = 'IdLE.ExecutionResult'
        Status        = $runStatus
        CorrelationId = $corr
        Actor         = $actor
        Steps         = @($stepResults)
        OnFailure     = $onFailure
        Events        = $events
        Providers     = $redactedProviders
    }
}