src/public/Orchestration/Invoke-AitherPlaybook.ps1

#Requires -Version 7.0

<#
.SYNOPSIS
    Execute a playbook with orchestration and dependency management
 
.DESCRIPTION
    Executes a playbook definition, running scripts in sequence or parallel
    based on the playbook configuration. This is the primary way to run
    automation workflows defined as playbooks.
 
    Playbooks can execute scripts in parallel (for speed), sequentially
    (for dependencies), or in a mixed mode (some parallel, some sequential).
    The orchestration engine automatically handles dependencies, retries,
    and error handling.
 
.PARAMETER Name
    Name of the playbook to execute. This parameter is REQUIRED when using
    the ByName parameter set. The playbook must exist in the playbooks directory.
 
    Examples:
    - "test-orchestration"
    - "pr-validation"
    - "deployment"
 
.PARAMETER Playbook
    Playbook object from Get-AitherPlaybook. This parameter is REQUIRED when
    using the ByObject parameter set. Allows piping playbook objects directly.
 
    Use this when you've already loaded a playbook and want to execute it.
 
.PARAMETER Variables
    Variables to pass to the playbook execution. This is a hashtable containing
    key-value pairs that will be available to all scripts in the playbook.
 
    Examples:
    - @{ Environment = "Production"; Approval = "Automatic" }
    - @{ OutputPath = "C:\Reports"; Verbose = $true }
 
    Variables can be accessed in scripts using $Variables.Environment, etc.
 
.PARAMETER DryRun
    Show what would be executed without actually running the playbook.
    Displays the playbook structure, scripts that would run, and execution order.
    Useful for verifying playbook configuration before execution.
 
.PARAMETER ContinueOnError
    Continue execution even if a script fails. By default, playbook execution
    stops on the first error. With this parameter, execution continues through
    all scripts and reports all failures at the end.
 
    Useful for:
    - Running validation scripts where you want to see all failures
    - Testing multiple components independently
    - Gathering comprehensive status information
 
.PARAMETER Parallel
    Override playbook's parallel setting. Forces parallel execution if set to $true,
    or sequential execution if set to $false. If not specified, uses the playbook's
    default execution mode.
 
    Note: Some scripts may require sequential execution due to dependencies,
    which will be respected even when Parallel is $true.
 
.PARAMETER MaxConcurrency
    Maximum concurrent script executions when running in parallel mode.
    Defaults to the value in configuration (usually 4).
 
    Increase this for:
    - Systems with more CPU cores
    - Scripts that are I/O bound rather than CPU bound
    - Faster execution when dependencies allow
 
    Decrease this for:
    - Resource-constrained systems
    - Scripts that consume significant resources
    - Better error visibility (fewer simultaneous failures)
 
.INPUTS
    System.String
    You can pipe playbook names to Invoke-AitherPlaybook.
 
    Hashtable
    You can pipe playbook objects from Get-AitherPlaybook to Invoke-AitherPlaybook.
 
.OUTPUTS
    PSCustomObject
    Returns execution result with properties:
    - Total: Total number of scripts
    - Completed: Number of successfully completed scripts
    - Failed: Number of failed scripts
    - Duration: Total execution time
    - Results: Detailed results for each script
 
.EXAMPLE
    Invoke-AitherPlaybook -Name 'test-orchestration'
 
    Executes the 'test-orchestration' playbook with default settings.
 
.EXAMPLE
    $playbook = Get-AitherPlaybook -Name 'pr-validation'
    Invoke-AitherPlaybook -Playbook $playbook -DryRun
 
    Loads a playbook and shows what would be executed without running it.
 
.EXAMPLE
    Invoke-AitherPlaybook -Name 'deployment' -Variables @{ Environment = "Production" } -ContinueOnError
 
    Executes deployment playbook with variables and continues on errors.
 
.EXAMPLE
    Get-AitherPlaybook -Name 'validation' | Invoke-AitherPlaybook -Parallel $true -MaxConcurrency 8
 
    Pipes a playbook object and executes it in parallel with higher concurrency.
 
.EXAMPLE
    Invoke-AitherPlaybook -Name 'test-suite' -DryRun -Variables @{ TestMode = "Full" }
 
    Shows what would be executed with specific variables without running.
 
.NOTES
    Uses the OrchestrationEngine for execution, which provides:
    - Automatic dependency resolution
    - Parallel and sequential execution modes
    - Error handling and retry logic
    - Progress tracking
    - Execution history
 
    Playbooks are stored in library/playbooks/ directory as .psd1 files.
    Each playbook defines scripts, execution order, dependencies, and success criteria.
 
.LINK
    Get-AitherPlaybook
    Save-AitherPlaybook
    New-AitherPlaybook
    Get-AitherOrchestrationStatus
    Get-AitherExecutionHistory
#>

function Invoke-AitherPlaybook {
    [OutputType([PSCustomObject])]
    [CmdletBinding(SupportsShouldProcess, DefaultParameterSetName = 'ByName')]
    param(
        [Parameter(ParameterSetName = 'ByName', Mandatory = $false, Position = 0, ValueFromPipeline, ValueFromPipelineByPropertyName, HelpMessage = "Name of the playbook to execute (e.g., 'pr-validation').")]
        [ArgumentCompleter({
                param($commandName, $parameterName, $wordToComplete, $commandAst, $fakeBoundParameters)
                if (Get-Command Get-AitherPlaybook -ErrorAction SilentlyContinue) {
                    Get-AitherPlaybook -List | Where-Object { $_.Name -like "$wordToComplete*" } | ForEach-Object {
                        [System.Management.Automation.CompletionResult]::new($_.Name, $_.Name, 'ParameterValue', $_.Description)
                    }
                }
            })]
        [AllowEmptyString()]
        [string]$Name,

        [Parameter(ParameterSetName = 'ByObject', Mandatory = $false, ValueFromPipeline, ValueFromPipelineByPropertyName, HelpMessage = "Playbook object or hashtable definition.")]
        [hashtable]$Playbook,

        [Parameter(HelpMessage = "Variables to pass to the playbook execution.")]
        [hashtable]$Variables = @{},

        [Parameter(HelpMessage = "Show what would be executed without running it.")]
        [switch]$DryRun,

        [Parameter(HelpMessage = "Continue execution even if a step fails.")]
        [switch]$ContinueOnError,

        [Parameter(HelpMessage = "Execute independent steps in parallel.")]
        [bool]$Parallel,

        [Parameter(HelpMessage = "Maximum number of concurrent parallel executions.")]
        [ValidateRange(1, 32)]
        [int]$MaxConcurrency,

        [Parameter(HelpMessage = "Show playbook execution output in console.")]
        [switch]$ShowOutput,

        [Parameter(HelpMessage = "Display transcript content after execution.")]
        [switch]$ShowTranscript
    )    begin {
        # Manage logging targets for this execution
        $originalLogTargets = $script:AitherLogTargets
        if ($ShowOutput) {
            if ($script:AitherLogTargets -notcontains 'Console') {
                $script:AitherLogTargets += 'Console'
            }
        }
        else {
            # Ensure Console is NOT in targets if ShowOutput is not specified
            $script:AitherLogTargets = $script:AitherLogTargets | Where-Object { $_ -ne 'Console' }
        }

        # Get scripts directory using robust discovery
        try {
            $scriptsPath = Get-AitherScriptsPath
        }
        catch {
            Write-AitherLog -Level Warning -Message "Could not resolve scripts path: $($_.Exception.Message)" -Source 'Invoke-AitherPlaybook'
            $scriptsPath = $null
        }

        $executionResults = @()
        $startTime = Get-Date
    }

    process {
        try {
            # Get playbook if name provided
            if ($Name) {
                $Playbook = Get-AitherPlaybook -Name $Name
                if (-not $Playbook) {
                    throw "Playbook not found: $Name"
                }
            }

            if (-not $Playbook -and -not $Name) {
                # During module validation, parameters may be empty - skip validation
                if ($PSCmdlet.MyInvocation.InvocationName -eq '.') {
                    return
                }
                throw "Playbook must be provided via -Name or -Playbook parameter"
            }

            # Merge playbook variables with provided variables.
            # G9 fix: also read from Playbook.Parameters if Variables is empty.
            # StrictMode-safe: a playbook hashtable that defines Parameters (not
            # Variables) would make a bare `$Playbook.Variables` THROW under
            # Set-StrictMode ("property 'Variables' cannot be found"), so probe
            # with ContainsKey before dereferencing.
            $playbookVariables = if ($Playbook -is [hashtable]) {
                if ($Playbook.ContainsKey('Variables') -and $Playbook.Variables) { $Playbook.Variables }
                elseif ($Playbook.ContainsKey('Parameters') -and $Playbook.Parameters) { $Playbook.Parameters }
                else { @{} }
            } else {
                if ($Playbook.PSObject.Properties['Variables'] -and $Playbook.Variables) { $Playbook.Variables }
                elseif ($Playbook.PSObject.Properties['Parameters'] -and $Playbook.Parameters) { $Playbook.Parameters }
                else { @{} }
            }
            $mergedVariables = $playbookVariables.Clone()
            foreach ($key in $Variables.Keys) {
                $mergedVariables[$key] = $Variables[$key]
            }

            # Optional 'Options' block — resolved StrictMode-safely (a playbook
            # without an Options key would make bare `$Playbook.Options` THROW).
            $pbOptions = if ($Playbook -is [hashtable]) {
                if ($Playbook.ContainsKey('Options')) { $Playbook.Options } else { $null }
            } elseif ($Playbook.PSObject.Properties['Options']) { $Playbook.Options } else { $null }

            # Determine execution mode
            $executeParallel = if ($PSBoundParameters.ContainsKey('Parallel')) {
                $Parallel
            }
            elseif ($pbOptions -and $pbOptions.ContainsKey('Parallel')) {
                $pbOptions.Parallel
            }
            else {
                $false  # Default to sequential for safety
            }

            $maxConcurrency = if ($PSBoundParameters.ContainsKey('MaxConcurrency')) {
                $MaxConcurrency
            }
            elseif ($pbOptions -and $pbOptions.ContainsKey('MaxConcurrency')) {
                $pbOptions.MaxConcurrency
            }
            else {
                $config = Get-AitherConfigs -ErrorAction SilentlyContinue
                # StrictMode-safe nested access (bare $config.Automation throws when absent).
                $oe = Get-AitherMember (Get-AitherMember $config 'Automation') 'OrchestrationEngine'
                $mc = Get-AitherMember $oe 'MaxConcurrency'
                if ($null -ne $mc) { $mc } else { 4 }
            }

            $continueOnError = if ($PSBoundParameters.ContainsKey('ContinueOnError')) {
                $ContinueOnError
            }
            elseif ($pbOptions -and $pbOptions.ContainsKey('StopOnError')) {
                -not $pbOptions.StopOnError
            }
            else {
                $false
            }

            # Get sequence from playbook (StrictMode-safe key probe).
            $pbHasSequence = if ($Playbook -is [hashtable]) { $Playbook.ContainsKey('Sequence') }
                             else { [bool]$Playbook.PSObject.Properties['Sequence'] }
            $sequence = if ($pbHasSequence -and $Playbook.Sequence) {
                $Playbook.Sequence
            }
            else {
                throw "Playbook does not contain a Sequence definition"
            }

            Write-AitherLog -Message "Executing playbook: $($Playbook.Name)" -Level Information -Source 'Invoke-AitherPlaybook'
            Write-AitherLog -Message " Scripts: $($sequence.Count)" -Level Information -Source 'Invoke-AitherPlaybook'
            Write-AitherLog -Message " Mode: $(if ($executeParallel) { 'Parallel' } else { 'Sequential' })" -Level Information -Source 'Invoke-AitherPlaybook'
            Write-AitherLog -Message " ContinueOnError: $continueOnError" -Level Information -Source 'Invoke-AitherPlaybook'

            # Dry run mode
            if ($DryRun) {
                Write-AitherLog -Level Information -Message "[DRY RUN] Playbook: $($Playbook.Name)" -Source 'Invoke-AitherPlaybook'
                Write-AitherLog -Level Information -Message ("=" * 60) -Source 'Invoke-AitherPlaybook'
                foreach ($item in $sequence) {
                    # StrictMode-safe: sequence items are hashtables with optional
                    # keys (Description/Params may be absent) — bare $item.X throws.
                    $scriptId = if ($item -is [System.Collections.IDictionary]) { Get-AitherMember $item 'Script' } else { $null }
                    $dryCmd = if ($item -is [System.Collections.IDictionary]) { Get-AitherMember $item 'Command' } else { $null }
                    if (-not $scriptId) { $scriptId = if ($dryCmd) { "(command step)" } else { $item } }
                    $descVal = Get-AitherMember $item 'Description'
                    $desc = if ($descVal) { $descVal } else { "Script $scriptId" }
                    Write-AitherLog -Level Information -Message " - $scriptId : $desc" -Source 'Invoke-AitherPlaybook'
                    if ($dryCmd) {
                        Write-AitherLog -Level Information -Message " Command: $dryCmd" -Source 'Invoke-AitherPlaybook'
                    }
                    $itemParams = Get-AitherMember $item 'Parameters'
                    if (-not $itemParams) { $itemParams = Get-AitherMember $item 'Params' }
                    if ($itemParams) {
                        Write-AitherLog -Level Information -Message " Parameters: $($itemParams | ConvertTo-Json -Compress)" -Source 'Invoke-AitherPlaybook'
                    }
                }

                # A playbook may opt in to a DEEP dry run: instead of only listing the
                # sequence, each step is invoked with -DryRun so the script's own
                # dry-run path runs. Listing proves the playbook parses; it cannot
                # prove the steps would work, which is the whole question a rehearsal
                # before a destructive migration is asking.
                #
                # Opt-in, so the existing playbooks are unaffected. Invoke-AitherScript
                # additionally refuses to execute any step that does not declare -DryRun.
                $dryRunMode = Get-AitherMember $Playbook 'DryRunMode'
                if ($dryRunMode -ne 'Execute') {
                    return
                }

                Write-AitherLog -Level Information -Message "[DRY RUN] DryRunMode=Execute - invoking each step's own dry-run path" -Source 'Invoke-AitherPlaybook'
            }

            # Execute sequence
            if (-not $PSCmdlet.ShouldProcess($Playbook.Name, "Execute playbook")) {
                return
            }

            # Define ModuleRoot for jobs
            $ModuleRoot = Get-AitherModuleRoot

            $scriptResults = @()
            $completed = 0
            $failed = 0
            $skipped = 0

            if ($executeParallel) {
                # Parallel execution with concurrency limit
                $jobs = @()
                $runningJobs = @{}
                $index = 0

                while ($index -lt $sequence.Count -or $runningJobs.Count -gt 0) {
                    # Start new jobs up to concurrency limit
                    while ($runningJobs.Count -lt $maxConcurrency -and $index -lt $sequence.Count) {
                        $item = $sequence[$index]
                        # StrictMode-safe optional-key access (see DryRun branch note).
                        $scriptId = if ($item -is [System.Collections.IDictionary]) { Get-AitherMember $item 'Script' } else { $null }
                        if (-not $scriptId) { $scriptId = $item }
                        $itemParams = Get-AitherMember $item 'Parameters'
                        if (-not $itemParams) { $itemParams = Get-AitherMember $item 'Params' }
                        $scriptParams = if ($itemParams) { $itemParams.Clone() } else { @{} }

                        # G8 fix: Interpolate '$VarName' placeholder strings with actual merged variable values
                        $keysToUpdate = @($scriptParams.Keys)
                        foreach ($key in $keysToUpdate) {
                            $val = $scriptParams[$key]
                            if ($val -is [string] -and $val -match '^\$\{?([^}]+)\}?$') {
                                $varName = $Matches[1]
                                if ($mergedVariables.ContainsKey($varName)) {
                                    $scriptParams[$key] = $mergedVariables[$varName]
                                } elseif ($val -eq "`$$key") {
                                    $scriptParams.Remove($key)
                                }
                            }
                        }

                        # Merge variables into parameters (only add keys not already present)
                        foreach ($key in $mergedVariables.Keys) {
                            if (-not $scriptParams.ContainsKey($key)) {
                                $scriptParams[$key] = $mergedVariables[$key]
                            }
                        }

                        Write-AitherLog -Message "Starting script: $scriptId" -Level Information -Source 'Invoke-AitherPlaybook'

                        $job = Start-Job -ScriptBlock {
                            param($ScriptId, $ModuleRoot, $Params, $ShowOutput, $ShowTranscript)
                            $modulePath = Join-Path $ModuleRoot 'AitherZero' 'AitherZero.psd1'
                            Import-Module $modulePath -Force
                            Invoke-AitherScript -Script $ScriptId -Parameters $Params -ErrorAction Stop -ShowOutput:$ShowOutput -ShowTranscript:$ShowTranscript
                        } -ArgumentList $scriptId, $moduleRoot, $scriptParams, $true, $ShowTranscript

                        $runningJobs[$job.Id] = @{
                            Job       = $job
                            ScriptId  = $scriptId
                            Index     = $index
                            StartTime = Get-Date
                        }
                        $index++
                    }

                    # Check for completed jobs
                    $completedJobs = @()
                    foreach ($jobId in $runningJobs.Keys) {
                        $jobInfo = $runningJobs[$jobId]
                        if ($jobInfo.Job.State -eq 'Completed' -or $jobInfo.Job.State -eq 'Failed') {
                            $result = Receive-Job -Job $jobInfo.Job

                            # Display output if requested (captured from job)
                            if ($ShowOutput) {
                                $result | ForEach-Object { Write-AitherLog -Level Information -Message $_ -Source 'Invoke-AitherPlaybook' }
                            }

                            $duration = (Get-Date) - $jobInfo.StartTime

                            $scriptResult = [PSCustomObject]@{
                                Script   = $jobInfo.ScriptId
                                Success  = $jobInfo.Job.State -eq 'Completed'
                                Duration = $duration
                                Output   = $result
                                Error    = if ($jobInfo.Job.State -eq 'Failed') { $jobInfo.Job.ChildJobs[0].Error } else { $null }
                            }

                            $scriptResults += $scriptResult

                            if ($scriptResult.Success) {
                                $completed++
                            }
                            else {
                                $failed++
                                Write-AitherLog -Message "Script failed: $($jobInfo.ScriptId)" -Level Error -Source 'Invoke-AitherPlaybook'
                                if (-not $continueOnError) {
                                    Remove-Job -Job $jobInfo.Job
                                    $completedJobs += $jobId
                                    break
                                }
                            }

                            Remove-Job -Job $jobInfo.Job
                            $completedJobs += $jobId
                        }
                    }

                    foreach ($jobId in $completedJobs) {
                        $runningJobs.Remove($jobId)
                    }

                    if ($runningJobs.Count -gt 0) {
                        Start-Sleep -Milliseconds 100
                    }
                }
            }
            else {
                # ── ForEach step-expansion ───────────────────────────────────
                # A step may set `ForEach = '<VarName>'` where <VarName> is an array
                # variable (e.g. Nodes). Expand it into ONE concrete step per item,
                # resolving `$_.Field` placeholders in Parameters (kept TYPED) and in
                # Command/Condition (stringified), and — because the Script path does
                # not gate Condition — dropping items whose Condition is false HERE.
                # Steps WITHOUT a ForEach key pass through unchanged, so existing
                # playbooks are unaffected.
                $expandedSequence = @()
                foreach ($seqItem in $sequence) {
                    $feVar = if ($seqItem -is [System.Collections.IDictionary]) { Get-AitherMember $seqItem 'ForEach' } else { $null }
                    if (-not $feVar -or -not $mergedVariables.ContainsKey($feVar)) {
                        $expandedSequence += , $seqItem
                        continue
                    }
                    foreach ($feCur in @($mergedVariables[$feVar])) {
                        $resolved = @{}
                        foreach ($sk in @($seqItem.Keys)) {
                            $sv = $seqItem[$sk]
                            if (($sk -eq 'Parameters' -or $sk -eq 'Params') -and $sv -is [System.Collections.IDictionary]) {
                                $rp = @{}
                                foreach ($pk in @($sv.Keys)) {
                                    $pv = $sv[$pk]
                                    if ($pv -is [string] -and $pv -match '^\$_\.(\w+)$') {
                                        $fld = $Matches[1]
                                        $rp[$pk] = if ($feCur -is [System.Collections.IDictionary]) { $feCur[$fld] } else { $feCur.$fld }
                                    } elseif ($pv -is [string] -and $pv -match '\$_\.\w+') {
                                        $rp[$pk] = [regex]::Replace($pv, '\$_\.(\w+)', {
                                            param($m) $f = $m.Groups[1].Value
                                            [string]$(if ($feCur -is [System.Collections.IDictionary]) { $feCur[$f] } else { $feCur.$f }) })
                                    } else { $rp[$pk] = $pv }
                                }
                                $resolved[$sk] = $rp
                            }
                            elseif (($sk -eq 'Command' -or $sk -eq 'Condition') -and $sv -is [string]) {
                                $isCond = ($sk -eq 'Condition')
                                $resolved[$sk] = [regex]::Replace($sv, '\$_\.(\w+)', {
                                    param($m) $f = $m.Groups[1].Value
                                    $val = if ($feCur -is [System.Collections.IDictionary]) { $feCur[$f] } else { $feCur.$f }
                                    if ($isCond) { if ($val -is [string]) { "'" + ($val -replace "'", "''") + "'" } else { [string]$val } }
                                    else { [string]$val } })
                            }
                            else { $resolved[$sk] = $sv }
                        }
                        $feCond = if ($resolved.ContainsKey('Condition')) { $resolved['Condition'] } else { $null }
                        if ($feCond) {
                            $condPass = $true
                            try {
                                foreach ($vk in $mergedVariables.Keys) { Set-Variable -Name $vk -Value $mergedVariables[$vk] -Scope Local -Force }
                                $condPass = [bool](Invoke-Expression $feCond)
                            } catch { $condPass = $false }
                            if (-not $condPass) { continue }
                        }
                        $expandedSequence += , $resolved
                    }
                }

                # Sequential execution
                # Step index is stamped onto every result: resume needs to know WHICH
                # steps completed, and a count cannot identify them.
                $stepIndex = -1
                foreach ($item in $expandedSequence) {
                    $stepIndex++
                    # StrictMode-safe optional-key access (see DryRun branch note).
                    $scriptId = if ($item -is [System.Collections.IDictionary]) { Get-AitherMember $item 'Script' } else { $null }

                    # ── Command-step support ─────────────────────────────────
                    # Playbooks authored in the deploy-bonsai-node shape use
                    # Command/Environment/Condition steps (a shell command with
                    # env-var injection), NOT numbered Script ids. Before this
                    # branch existed the engine stringified the step hashtable
                    # and failed with "Script not found: System.Collections.
                    # Hashtable" — no Command playbook had ever actually run.
                    # NOTE: Command paths are repo-relative; run from repo root.
                    $stepCommand = if ($item -is [System.Collections.IDictionary]) { Get-AitherMember $item 'Command' } else { $null }
                    if (-not $scriptId -and $stepCommand) {
                        $stepName = Get-AitherMember $item 'Name'
                        if (-not $stepName) { $stepName = $stepCommand }
                        $stepCoE2 = Get-AitherMember $item 'ContinueOnError'
                        $effCoE = if ($null -ne $stepCoE2) { [bool]$stepCoE2 } else { $continueOnError }

                        # Condition gate: expression over playbook variables
                        # (e.g. '$WireRouter -eq $true'). Variables come from the
                        # merged Parameters+(-Variables) set.
                        $condExpr = Get-AitherMember $item 'Condition'
                        $condOk = $true
                        if ($condExpr) {
                            try {
                                foreach ($vk in $mergedVariables.Keys) {
                                    Set-Variable -Name $vk -Value $mergedVariables[$vk] -Scope Local -Force
                                }
                                $condOk = [bool](Invoke-Expression $condExpr)
                            } catch {
                                Write-AitherLog -Message "Condition eval failed for '$stepName': $($_.Exception.Message) — skipping step" -Level Warning -Source 'Invoke-AitherPlaybook'
                                $condOk = $false
                            }
                        }
                        if (-not $condOk) {
                            $skipped++
                            $scriptResults += [PSCustomObject]@{
                                Script = $stepName; Success = $true; Duration = [timespan]::Zero
                                Output = '(skipped: condition false)'; Error = $null
                            }
                            continue
                        }

                        # Environment injection with '$VarName' interpolation
                        # (same placeholder syntax the Script path supports).
                        $envMap = Get-AitherMember $item 'Environment'
                        $savedEnv = @{}
                        if ($envMap) {
                            foreach ($ek in @($envMap.Keys)) {
                                $ev = $envMap[$ek]
                                if ($ev -is [string] -and $ev -match '^\$\{?([^}]+)\}?$') {
                                    $vn = $Matches[1]
                                    $ev = if ($mergedVariables.ContainsKey($vn)) { [string]$mergedVariables[$vn] } else { '' }
                                }
                                $savedEnv[$ek] = [Environment]::GetEnvironmentVariable($ek)
                                [Environment]::SetEnvironmentVariable($ek, [string]$ev)
                            }
                        }

                        Write-AitherLog -Message "Executing command step: $stepName" -Level Information -Source 'Invoke-AitherPlaybook'
                        $cmdStart = Get-Date
                        try {
                            $global:LASTEXITCODE = 0
                            $cmdOutput = Invoke-Expression $stepCommand 2>&1
                            if ($LASTEXITCODE -ne 0) {
                                throw "command exited with code $LASTEXITCODE"
                            }
                            $scriptResults += [PSCustomObject]@{
                                Script = $stepName; Success = $true
                                Duration = (Get-Date) - $cmdStart
                                Output = ($cmdOutput | Out-String); Error = $null
                            }
                            $completed++
                        } catch {
                            $scriptResults += [PSCustomObject]@{
                                Script = $stepName; Success = $false
                                Duration = (Get-Date) - $cmdStart
                                Output = ($cmdOutput | Out-String); Error = $_.Exception.Message
                            }
                            $failed++
                            Write-AitherLog -Message "Command step failed: $stepName - $($_.Exception.Message)" -Level Error -Source 'Invoke-AitherPlaybook'
                            if (-not $effCoE) { break }
                        } finally {
                            foreach ($ek in $savedEnv.Keys) {
                                [Environment]::SetEnvironmentVariable($ek, $savedEnv[$ek])
                            }
                        }
                        continue
                    }
                    # ── end Command-step support ─────────────────────────────

                    if (-not $scriptId) { $scriptId = $item }

                    # ── Condition gate for SCRIPT steps (D-849) ──────────────
                    # `Condition` used to be honored ONLY for Command steps and
                    # for ForEach-expanded items, so a gate written on an
                    # ordinary Script step was silently ignored and the step
                    # ALWAYS ran. That is how `optimize-host -Variables
                    # @{Report=$true}` — documented as inspect-only — still
                    # reached its disk-cleanup step.
                    #
                    # MIGRATION SAFETY — this gate FAILS OPEN on purpose.
                    # ~35 existing playbooks carry conditions that were never
                    # evaluated, and some are not PowerShell at all: mustache
                    # templates the engine never substitutes ('{{Target}} -eq
                    # "hyperv"') and the bare word 'Always'. Evaluating those
                    # THROWS, and skipping on a throw would silently disable
                    # every hyperv/gke/gce deploy step the moment this shipped.
                    # So: a cleanly-evaluated $false SKIPS (the declared
                    # intent); anything that cannot be evaluated RUNS, exactly
                    # as it did before. This is an execution gate, not a
                    # security decision — fail-open is the safe direction here.
                    $stepCondition = if ($item -is [System.Collections.IDictionary]) { Get-AitherMember $item 'Condition' } else { $null }
                    if ($stepCondition -is [string] -and $stepCondition.Trim()) {
                        $condText = $stepCondition.Trim()
                        # Legacy non-expressions: an unsubstituted mustache
                        # placeholder or the sentinel word 'Always'. Both mean
                        # "run" and must not produce warning noise.
                        $isLegacyAlways = ($condText -eq 'Always') -or ($condText -like '*{{*')
                        if (-not $isLegacyAlways) {
                            $scriptCondPass = $true
                            try {
                                foreach ($vk in $mergedVariables.Keys) {
                                    Set-Variable -Name $vk -Value $mergedVariables[$vk] -Scope Local -Force
                                }
                                $scriptCondPass = [bool](Invoke-Expression $condText)
                            }
                            catch {
                                # Cannot evaluate -> preserve legacy behaviour and
                                # SAY SO, so a broken condition is visible instead
                                # of quietly gating (or quietly not gating).
                                Write-AitherLog -Message "Condition '$condText' on step '$scriptId' could not be evaluated ($($_.Exception.Message)) - RUNNING the step (legacy behaviour)" -Level Warning -Source 'Invoke-AitherPlaybook'
                                $scriptCondPass = $true
                            }
                            if (-not $scriptCondPass) {
                                Write-AitherLog -Message "Skipping script: $scriptId (condition false: $condText)" -Level Information -Source 'Invoke-AitherPlaybook'
                                $skipped++
                                $scriptResults += [PSCustomObject]@{
                                    Script = $scriptId; Success = $true; Duration = [timespan]::Zero
                                    Output = '(skipped: condition false)'; Error = $null
                                }
                                continue
                            }
                        }
                    }
                    # ── end Condition gate ───────────────────────────────────

                    $itemParams = Get-AitherMember $item 'Parameters'
                    if (-not $itemParams) { $itemParams = Get-AitherMember $item 'Params' }
                    $scriptParams = if ($itemParams) { $itemParams.Clone() } else { @{} }

                    # Per-step ContinueOnError. Playbook Sequence items set this
                    # individually (e.g. a validator phase continues on error while an
                    # install phase must halt). The engine previously honored only the
                    # playbook-level -ContinueOnError switch, so a phase marked
                    # ContinueOnError=$true still aborted the whole run. Fall back to the
                    # global switch when the step omits it.
                    $stepCoE = if ($item -is [System.Collections.IDictionary]) { Get-AitherMember $item 'ContinueOnError' } else { $null }
                    $effectiveContinueOnError = if ($null -ne $stepCoE) { [bool]$stepCoE } else { $continueOnError }

                    # G8 fix: Interpolate '$VarName' placeholder strings with actual merged variable values
                    $keysToUpdate = @($scriptParams.Keys)
                    foreach ($key in $keysToUpdate) {
                        $val = $scriptParams[$key]
                        if ($val -is [string] -and $val -match '^\$\{?([^}]+)\}?$') {
                            $varName = $Matches[1]
                            if ($mergedVariables.ContainsKey($varName)) {
                                $scriptParams[$key] = $mergedVariables[$varName]
                            } elseif ($val -eq "`$$key") {
                                # Self-referencing placeholder with no merged value — remove so script default applies
                                $scriptParams.Remove($key)
                            }
                        }
                    }

                    # Merge variables into parameters (only add keys not already present)
                    foreach ($key in $mergedVariables.Keys) {
                        if (-not $scriptParams.ContainsKey($key)) {
                            $scriptParams[$key] = $mergedVariables[$key]
                        }
                    }

                    Write-AitherLog -Message "Executing script: $scriptId" -Level Information -Source 'Invoke-AitherPlaybook'

                    $scriptStartTime = Get-Date
                    try {
                        # Pass Verbose preference explicitly
                        $result = Invoke-AitherScript -Script $scriptId -Parameters $scriptParams -ErrorAction Stop -ShowOutput:$ShowOutput -ShowTranscript:$ShowTranscript -DryRun:$DryRun -Verbose:$VerbosePreference
                        $duration = (Get-Date) - $scriptStartTime

                        $scriptResult = [PSCustomObject]@{
                            Index    = $stepIndex
                            Script   = $scriptId
                            Success  = $true
                            Duration = $duration
                            Output   = $result
                            Error    = $null
                        }

                        $scriptResults += $scriptResult
                        $completed++
                    }
                    catch {
                        $duration = (Get-Date) - $scriptStartTime

                        $scriptResult = [PSCustomObject]@{
                            Index    = $stepIndex
                            Script   = $scriptId
                            Success  = $false
                            Duration = $duration
                            Output   = $null
                            Error    = $_.Exception.Message
                        }

                        $scriptResults += $scriptResult
                        $failed++

                        Write-AitherLog -Message "Script failed: $scriptId - $($_.Exception.Message)" -Level Error -Source 'Invoke-AitherPlaybook'

                        # OnFailure names a script to run with the failure context --
                        # a handler, not a log line. Without this a playbook could not
                        # drive its own rollback, so the migration playbook's rollback
                        # step had to be invoked by the cutover script itself.
                        $onFailure = Get-AitherMember $Playbook 'OnFailure'
                        if ($onFailure) {
                            try {
                                Write-AitherLog -Message "Invoking OnFailure handler: $onFailure" -Level Warning -Source 'Invoke-AitherPlaybook'
                                Invoke-AitherScript -Script $onFailure -ErrorAction Stop -ShowOutput:$ShowOutput
                            }
                            catch {
                                # Must not mask the original failure, must not vanish:
                                # a rollback that did not run is the most important
                                # thing on the page.
                                Write-AitherLog -Message "OnFailure handler '$onFailure' FAILED: $($_.Exception.Message)" -Level Error -Source 'Invoke-AitherPlaybook'
                            }
                        }

                        if (-not $effectiveContinueOnError) {
                            break
                        }
                    }
                }
            }

            $endTime = Get-Date
            $totalDuration = $endTime - $startTime

            # Build result object
            $result = [PSCustomObject]@{
                PSTypeName   = 'AitherZero.PlaybookExecutionResult'
                PlaybookName = $Playbook.Name
                Success      = $failed -eq 0
                Total        = $sequence.Count
                Completed    = $completed
                Failed       = $failed
                Skipped      = $skipped
                Duration     = $totalDuration
                Results      = $scriptResults
            }

            Write-AitherLog -Message "Playbook execution completed: $completed/$($sequence.Count) succeeded, $failed failed" -Level Information -Source 'Invoke-AitherPlaybook'

            # Persist the execution record. Until this existed, per-step Results were
            # built in memory and discarded on return, and the three consumers of the
            # execution-history store all read a directory nothing ever wrote -- each
            # failing soft to empty, which reads as "no executions have run".
            # Resume is impossible without this: it is the only record of WHICH steps
            # completed.
            try {
                if (Get-Command Write-AitherExecutionRecord -ErrorAction SilentlyContinue) {
                    Write-AitherExecutionRecord `
                        -ExecutionId ($script:AitherExecutionId ?? [guid]::NewGuid().ToString()) `
                        -PlaybookName $Playbook.Name `
                        -Status $(if ($failed -eq 0) { 'Completed' } else { 'Failed' }) `
                        -Results $scriptResults `
                        -StartTime $startTime `
                        -Variables $mergedVariables | Out-Null
                }
            }
            catch {
                # Never fail a completed playbook because bookkeeping failed, but never
                # swallow it either -- a silent write failure here is what makes resume
                # unusable later, at the worst possible moment.
                Write-AitherLog -Message "Failed to persist execution record: $($_.Exception.Message)" -Level Warning -Source 'Invoke-AitherPlaybook'
            }

            return $result
        }
        catch {
            Invoke-AitherErrorHandler -ErrorRecord $_ -Operation "Executing playbook: $($Name ?? $Playbook.Name)" -Parameters $PSBoundParameters -ThrowOnError
        }
        finally {
            # Restore original log targets
            $script:AitherLogTargets = $originalLogTargets
        }
    }

}