ITFabrik.Stepper.psm1

# Built file. Do not edit directly; edit source files and rebuild.
# Build timestamp: 2026-03-06T15:43:33+00:00

# region Private\State.ps1
# Module-scoped state and helpers (not exported)

if (-not $script:StepStack) { $script:StepStack = New-Object System.Collections.Stack }
if (-not $script:StepStateLock) { $script:StepStateLock = New-Object object }

function Push-Step {
    param([Step]$Step)
    [System.Threading.Monitor]::Enter($script:StepStateLock)
    try { $null = $script:StepStack.Push($Step) }
    finally { [System.Threading.Monitor]::Exit($script:StepStateLock) }
}

function Pop-Step {
    [System.Threading.Monitor]::Enter($script:StepStateLock)
    try { if ($script:StepStack.Count -gt 0) { return $script:StepStack.Pop() } }
    finally { [System.Threading.Monitor]::Exit($script:StepStateLock) }
}

function Get-StepStackTop {
    [System.Threading.Monitor]::Enter($script:StepStateLock)
    try { if ($script:StepStack.Count -gt 0) { return $script:StepStack.Peek() } }
    finally { [System.Threading.Monitor]::Exit($script:StepStateLock) }
}

# (Removed) StopExecution flag was unused; keeping state minimal
# endregion

# region Private\Helpers.ps1
# Initialisation du flag d'exécution interne d'un Step
if ($null -eq $script:InsideStep) { $script:InsideStep = $false }
if ($null -eq $script:StepLogCollector) { $script:StepLogCollector = $null }

<#
.SYNOPSIS
Affiche un message d'étape avec indentation.

.DESCRIPTION
Affiche un message d'étape à l'écran, en gris, avec un niveau d'indentation optionnel.

.PARAMETER Message
Le message à afficher.

.PARAMETER IndentLevel
Niveau d'indentation (nombre d'espaces).
#>

function Get-StepManagerLogger {
    [CmdletBinding()]
    param()

    $currentScopeVariable = Get-Variable -Name 'StepManagerLogger' -ErrorAction SilentlyContinue
    if ($null -ne $currentScopeVariable) {
        return $currentScopeVariable.Value
    }

    foreach ($scope in @('Script', 'Global')) {
        $variable = Get-Variable -Name 'StepManagerLogger' -Scope $scope -ErrorAction SilentlyContinue
        if ($null -ne $variable) {
            return $variable.Value
        }
    }

    return $null
}

function ConvertTo-StepLogEntry {
    [CmdletBinding()]
    param(
        [Parameter(Mandatory)]$Entry
    )

    $timestamp = if ($Entry.PSObject.Properties.Match('Timestamp').Count -gt 0 -and $null -ne $Entry.Timestamp) {
        [datetime]$Entry.Timestamp
    } else {
        Get-Date
    }

    return [pscustomobject]@{
        Timestamp = $timestamp
        Source = [string]$Entry.Source
        Component = [string]$Entry.Component
        Message = [string]$Entry.Message
        Severity = [string]$Entry.Severity
        IndentLevel = [int]$Entry.IndentLevel
        StepName = if ($Entry.PSObject.Properties.Match('StepName').Count -gt 0) { [string]$Entry.StepName } else { '' }
        ForegroundColor = if ($Entry.PSObject.Properties.Match('ForegroundColor').Count -gt 0) { [string]$Entry.ForegroundColor } else { '' }
    }
}

function Write-StepLogEntry {
    [CmdletBinding()]
    param(
        [Parameter(Mandatory)]$Entry
    )

    $normalizedEntry = ConvertTo-StepLogEntry -Entry $Entry
    $collector = $script:StepLogCollector
    if ($null -ne $collector) {
        & $collector $normalizedEntry
        return
    }

    $logger = Get-StepManagerLogger
    if ($null -eq $logger) {
        if ($normalizedEntry.Source -eq 'User') {
            Write-StepMessage -Severity $normalizedEntry.Severity -Message $normalizedEntry.Message -IndentLevel $normalizedEntry.IndentLevel -StepName $normalizedEntry.StepName -Timestamp $normalizedEntry.Timestamp -ForegroundColor $normalizedEntry.ForegroundColor
        } else {
            Write-StepMessage -Severity $normalizedEntry.Severity -Message ("[{0}] {1}" -f $normalizedEntry.Component, $normalizedEntry.Message) -IndentLevel $normalizedEntry.IndentLevel -Timestamp $normalizedEntry.Timestamp
        }
    } else {
        & $logger $normalizedEntry.Component $normalizedEntry.Message $normalizedEntry.Severity $normalizedEntry.IndentLevel $normalizedEntry.Timestamp
    }
}

function Write-StepMessage {
    [CmdletBinding()]
    param(
        [Parameter(Mandatory)] [string]$Severity,
        [Parameter(Mandatory)] [string]$Message,
        [int]$IndentLevel = 0,
        [string]$StepName = '',
        [datetime]$Timestamp,
        [string]$ForegroundColor
    )

    # Détection de PowerShell 7+
    $isPwsh7 = $PSVersionTable.PSVersion.Major -ge 7

    # Dictionnaire d'icônes par sévérité (Unicode, fallback via [Severity] si non supporté)
    $icons = @{
        'Info'    = 'ℹ'
        'Success' = '✓'
        'Warning' = '⚠'
        'Error'   = '✖'
        'Debug'   = '⚙'
        'Verbose' = '…'
    }

    $prefixRaw = if ($isPwsh7 -and $icons.ContainsKey($Severity)) { $icons[$Severity] } else { "[$Severity]" }
    # Padding spécifique par sévérité pour un alignement optimal
    $nbsp = [char]0x2007
    function Get-NbspString($count) { [string]::new(@($nbsp) * $count) }
    
    switch ($Severity) {
        'Info'    { $prefix = $prefixRaw + (Get-NbspString 4) ; $ForegroundColor = if(-not $ForegroundColor){ 'Gray'} else{$ForegroundColor} }
        'Success' { $prefix = $prefixRaw + (Get-NbspString 3) ; $ForegroundColor = if(-not $ForegroundColor){ 'Green'} else{$ForegroundColor}}
        'Warning' { $prefix = $prefixRaw + (Get-NbspString 4) ; $ForegroundColor = if(-not $ForegroundColor){ 'Yellow'} else{$ForegroundColor}}
        'Error'   { $prefix = $prefixRaw + (Get-NbspString 3) ; $ForegroundColor = if(-not $ForegroundColor){ 'Red'} else{$ForegroundColor}}
        'Debug'   { $prefix = $prefixRaw + (Get-NbspString 3) ; $ForegroundColor = if(-not $ForegroundColor){ 'Cyan'} else{$ForegroundColor}}
        'Verbose' { $prefix = $prefixRaw + (Get-NbspString 3) ; $ForegroundColor = if(-not $ForegroundColor){ 'Magenta'} else{$ForegroundColor}}
        default   { $prefix = $prefixRaw + (Get-NbspString 3) ; $ForegroundColor = if(-not $ForegroundColor){ 'White'} else{$ForegroundColor}}
    }

    $indent = if ($IndentLevel -gt 0) { ' ' * ($IndentLevel * 2) } else { '' }
    $effectiveTimestamp = if ($PSBoundParameters.ContainsKey('Timestamp')) { $Timestamp } else { Get-Date }
    $now = $effectiveTimestamp.ToString('yyyy-MM-dd HH:mm:ss')
    $step = if ($StepName) { "[$StepName]" } else { '' }
    # Ne plus afficher le Component ici pour éviter la duplication d'étiquettes
    $text = "[$now] $prefix$indent$step $Message"
    Write-Host $text -ForegroundColor $ForegroundColor
}
# endregion

# region Private\Classes\Step.ps1

class Step {
    [string]$Name
    [ValidateSet('Pending','Success','Error')]
    [string]$Status = 'Pending'
    [int]$Level = 0
    [Step]$ParentStep = $null
    [System.Collections.Generic.List[Step]]$Children = [System.Collections.Generic.List[Step]]::new()
    [string]$Detail = ''
    [bool]$ContinueOnError = $false
    [datetime]$StartTime
    [Nullable[datetime]]$EndTime
    [TimeSpan]$Duration = [TimeSpan]::Zero

    Step([string]$Name, [Step]$ParentStep = $null, [bool]$ContinueOnError = $false) {
        $this.Name = $Name
        $this.Status = 'Pending'
        $this.Detail = ''
        $this.ParentStep = $ParentStep
        $this.Level = if ($ParentStep) { $ParentStep.Level + 1 } else { 0 }
        $this.ContinueOnError = $ContinueOnError
        $this.StartTime = Get-Date
        $this.EndTime = $null
        if ($ParentStep) {
            $ParentStep.Children.Add($this)
        }
    }

    [string] ToString() {
        $dur = if ($this.EndTime) { ($this.EndTime - $this.StartTime) } else { [TimeSpan]::Zero }
        return "{0} [{1}] L{2} ({3:c})" -f $this.Name, $this.Status, $this.Level, $dur
    }
}
# endregion

# region Private\Functions\Complete-Step.ps1
function Complete-Step {
    [CmdletBinding()]
    param()

    $current = Get-CurrentStep
    if ($current) {
        $current.EndTime = Get-Date
        if ($null -ne $current.StartTime -and $null -ne $current.EndTime) {
            $current.Duration = ($current.EndTime - $current.StartTime)
        }
    }

    Invoke-Logger -Component 'StepManager' -Severity 'Verbose' -Message "Étape [$($current.Name)] terminée." -IndentLevel ($current.Level)


    # Dépile le niveau d'indentation contextuel (protégé pour runspace)
    [System.Threading.Monitor]::Enter($script:StepStateLock)
    try {
        if ($script:CurrentStepIndentStack) {
            $script:CurrentStepIndentStack = $script:CurrentStepIndentStack[0..($script:CurrentStepIndentStack.Count-2)]
            if ($script:CurrentStepIndentStack.Count -eq 0) {
                Remove-Variable -Name CurrentStepIndentStack -Scope Script -ErrorAction SilentlyContinue
            }
        }
    } finally { [System.Threading.Monitor]::Exit($script:StepStateLock) }

    # Pop current step and restore parent (if any)
    $null = Pop-Step
}
# endregion

# region Private\Functions\Get-CurrentStep.ps1
function Get-CurrentStep {
    [CmdletBinding()] param()
    return (Get-StepStackTop)
}
# endregion

# region Private\Functions\Invoke-Logger.ps1
<#
.SYNOPSIS
    Centralise l'affichage des messages de log pour StepManager.
.DESCRIPTION
    Permet d'afficher des messages typés (Info, Success, Warning, Error, Debug, Verbose) avec indentation et support d'un logger personnalisé.
    Si aucun logger n'est défini, affiche le message formaté dans la console.
    Les messages Debug ne sont affichés que si $DebugPreference le permet.
.PARAMETER Component
    Nom du composant ou de l'étape à l'origine du message.
.PARAMETER Message
    Le message à afficher.
.PARAMETER Severity
    Le niveau de sévérité du message : Info, Success, Warning, Error, Debug, Verbose.
.PARAMETER IndentLevel
    Niveau d'indentation pour l'affichage (entier, optionnel).
#>

function Invoke-Logger {
    param(
        [Parameter(Mandatory)] [string]$Component,
        [Parameter(Mandatory)] [string]$Message,
        [Parameter(Mandatory)] [ValidateSet('Info','Success','Warning','Error','Debug','Verbose')] [string]$Severity,
        [int]$IndentLevel = [int]::MinValue
    )
    # Indentation: respecte une valeur explicite sinon calcule depuis la step courante
    $explicitIndent = ($IndentLevel -ne [int]::MinValue)
    $finalIndent = if ($explicitIndent) { $IndentLevel } else { 0 }
    if (-not $explicitIndent) {
        if ($script:InsideStep) {
            try {
                $currentStep = Get-CurrentStep
                if ($null -ne $currentStep) { $finalIndent = $currentStep.Level + 1 } else { $finalIndent = 1 }
            } catch { $finalIndent = 1 }
        }
    }
    # Gestion du niveau Debug et Verbose
    if ($Severity -eq 'Debug') { if ($DebugPreference -eq 'SilentlyContinue') { return } }
    if ($Severity -eq 'Verbose') { if ($VerbosePreference -eq 'SilentlyContinue') { return } }
    Write-StepLogEntry -Entry ([pscustomobject]@{
            Source = 'Internal'
            Component = $Component
            Message = $Message
            Severity = $Severity
            IndentLevel = $finalIndent
        })
}
# endregion

# region Private\Functions\New-Step.ps1
<#
.SYNOPSIS
Crée une nouvelle étape et la pousse dans la pile d'exécution.

.DESCRIPTION
Crée un objet Step, gère l'imbrication et l'état, et affiche le nom de l'étape. Les messages sont affichés en gris par défaut.

.PARAMETER Name
Nom de l'étape à créer.

.PARAMETER ContinueOnError
Indique si l'exécution doit continuer en cas d'erreur dans l'étape.

.OUTPUTS
Step
#>

function New-Step {
    [CmdletBinding(SupportsShouldProcess)]
    param(
        [Parameter(Mandatory)] [ValidateNotNullOrEmpty()] [string]$Name,
        [switch]$ContinueOnError
    )

    if (-not $PSCmdlet.ShouldProcess($Name, 'Create step')) {
        return
    }

    $parent = Get-CurrentStep
    $step = [Step]::new($Name, $parent, $ContinueOnError.IsPresent)

    # Gestion d'une pile d'indentation contextuelle pour chaque Step imbriquée (protégée pour runspace)
    [System.Threading.Monitor]::Enter($script:StepStateLock)
    try {
        if (-not $script:CurrentStepIndentStack) { $script:CurrentStepIndentStack = @() }
        $script:CurrentStepIndentStack += ($step.Level + 1)
    }
    finally { [System.Threading.Monitor]::Exit($script:StepStateLock) }

    Push-Step -Step $step

    Invoke-Logger -Component 'StepManager' -Message "Création de l'étape : $Name" -Severity Verbose -IndentLevel $step.Level
    Invoke-Logger -Component 'StepManager' -Message "$Name" -Severity Info -IndentLevel $step.Level

    return $step
}
# endregion

# region Private\Functions\Set-Step.ps1
function Set-Step {
    [CmdletBinding(SupportsShouldProcess)]
    param(
        [Parameter(Mandatory)] [ValidateSet('Pending','Success','Error')] [string]$Status,
        [string]$Detail = ''
    )

    $current = Get-CurrentStep
    if (-not $current) { return }
    if (-not $PSCmdlet.ShouldProcess($current.Name, "Set step status to $Status")) {
        return
    }

    $current.Status = $Status
    $current.Detail = $Detail

    if ($Status -eq 'Error') {
        # Laisser l'auto-indentation gérer le niveau quand non spécifié
        Invoke-Logger -Component 'StepManager' -Severity 'Error' -Message "Erreur dans l'étape [$($current.Name)] : $Detail"
    }
    else{
        Invoke-Logger -Component 'StepManager' -Severity 'Verbose' -Message "Étape [$($current.Name)] définie sur le statut : $Status"
    }
}
# endregion

# region Public\Invoke-Step.ps1
<#
.SYNOPSIS
Exécute un bloc de script dans une étape avec gestion d'état, d'imbrication et d'erreur.

.DESCRIPTION
Encapsule l'exécution d'un ScriptBlock dans une étape typée, avec gestion du statut
(Success, Error), imbrication et option de poursuite sur erreur.

Le cmdlet peut aussi traiter une collection d'éléments. Dans ce mode, une étape
parente est créée avec une sous-étape par élément. L'exécution est séquentielle
par défaut. En PowerShell 7+, l'option `-Parallel` utilise `ForEach-Object -Parallel`.
En Windows PowerShell 5.1, elle utilise `Start-Job` avec gestion du `ThrottleLimit`.

.PARAMETER Name
Nom de l'étape à exécuter.

.PARAMETER ScriptBlock
Bloc de code à exécuter dans l'étape.

.PARAMETER ContinueOnError
Indique si l'exécution doit continuer en cas d'erreur dans l'étape. Par défaut : $false.

.PARAMETER InputObject
Collection d'éléments à traiter. Si ce paramètre est fourni, `Invoke-Step` exécute
le `ScriptBlock` une fois par élément.

.PARAMETER Parallel
Active l'exécution parallèle dans le mode collection.

.PARAMETER ThrottleLimit
Nombre maximal d'éléments exécutés en parallèle.

.PARAMETER ParallelThreshold
Nombre minimal d'éléments requis avant d'activer réellement le mode parallèle.

.OUTPUTS
Step
#>


function Get-InvokeStepModuleManifestPath {
    [CmdletBinding()]
    param()

    foreach ($candidate in @(
            (Join-Path $PSScriptRoot 'ITFabrik.Stepper.psd1'),
            (Join-Path (Split-Path -Parent $PSScriptRoot) 'ITFabrik.Stepper.psd1')
        )) {
        if (Test-Path -LiteralPath $candidate) {
            return (Resolve-Path -LiteralPath $candidate).Path
        }
    }

    throw 'Unable to resolve ITFabrik.Stepper.psd1 from the current module context.'
}

function Get-InvokeStepCapturedVariableMap {
    [CmdletBinding()]
    param(
        [Parameter(Mandatory)][ScriptBlock]$ScriptBlock
    )

    $captured = @{}
    $excluded = [System.Collections.Generic.HashSet[string]]::new([System.StringComparer]::OrdinalIgnoreCase)
    foreach ($name in @(
            '_', 'PSItem', 'args', 'input', 'this', 'true', 'false', 'null',
            'PWD', 'HOME', 'PID', 'PSVersionTable', 'PSScriptRoot', 'PSCommandPath',
            'ExecutionContext', 'MyInvocation', 'PSBoundParameters', 'Matches', 'Error',
            'Host', 'VerbosePreference', 'DebugPreference', 'ErrorActionPreference',
            'WarningPreference', 'InformationPreference', 'ConfirmPreference', 'WhatIfPreference'
        )) {
        [void]$excluded.Add($name)
    }

    $paramNames = @()
    if ($ScriptBlock.Ast.ParamBlock) {
        $paramNames = @($ScriptBlock.Ast.ParamBlock.Parameters | ForEach-Object { $_.Name.VariablePath.UserPath })
        foreach ($paramName in $paramNames) {
            [void]$excluded.Add($paramName)
        }
    }

    $variableAsts = $ScriptBlock.Ast.FindAll({
            param($ast)
            $ast -is [System.Management.Automation.Language.VariableExpressionAst]
        }, $true)

    foreach ($variableAst in $variableAsts) {
        if ($variableAst.Splatted) { continue }

        $name = $variableAst.VariablePath.UserPath
        if ([string]::IsNullOrWhiteSpace($name)) { continue }
        if ($excluded.Contains($name)) { continue }
        if ($captured.ContainsKey($name)) { continue }

        $variable = $null
        if ($ScriptBlock.Module) {
            $variable = $ScriptBlock.Module.SessionState.PSVariable.Get($name)
        }

        foreach ($scope in 0..10) {
            if ($null -ne $variable) {
                break
            }

            try {
                $variable = Get-Variable -Name $name -Scope $scope -ErrorAction Stop
            }
            catch {
                $variable = $null
            }

            if ($null -ne $variable) {
                break
            }
        }

        if ($null -eq $variable) {
            $variable = Get-Variable -Name $name -Scope Global -ErrorAction SilentlyContinue
        }

        if ($null -ne $variable) {
            $captured[$name] = $variable.Value
        }
    }

    return $captured
}

function Get-InvokeStepChildName {
    [CmdletBinding()]
    param(
        [Parameter(Mandatory)][string]$BaseName,
        [Parameter(Mandatory)]$Item,
        [Parameter(Mandatory)][int]$Index
    )

    $itemLabel = $null
    if ($null -eq $Item) {
        $itemLabel = 'null'
    } elseif ($Item -is [string] -or $Item -is [ValueType]) {
        $itemLabel = [string]$Item
    }

    if ([string]::IsNullOrWhiteSpace($itemLabel)) {
        $itemLabel = "#{0}" -f ($Index + 1)
    }

    $itemLabel = ($itemLabel -replace '[\\/:*?"<>|]', '-').Trim()
    if ([string]::IsNullOrWhiteSpace($itemLabel)) {
        $itemLabel = "#{0}" -f ($Index + 1)
    }

    $name = "{0} [{1}]" -f $BaseName, $itemLabel
    if ($name.Length -gt 80) {
        $name = $name.Substring(0, 80).TrimEnd()
    }

    return $name
}

function ConvertTo-InvokeStepData {
    [CmdletBinding()]
    param(
        [Parameter(Mandatory)][Step]$Step
    )

    return [pscustomobject]@{
        Name = $Step.Name
        Status = $Step.Status
        Detail = $Step.Detail
        Level = $Step.Level
        ContinueOnError = $Step.ContinueOnError
        StartTime = $Step.StartTime
        EndTime = $Step.EndTime
        Duration = $Step.Duration
        Children = @($Step.Children | ForEach-Object { ConvertTo-InvokeStepData -Step $_ })
    }
}

function ConvertFrom-InvokeStepData {
    [CmdletBinding()]
    param(
        [Parameter(Mandatory)]$StepData,
        [Step]$Parent = $null
    )

    $step = [Step]::new([string]$StepData.Name, $Parent, [bool]$StepData.ContinueOnError)
    $step.Status = [string]$StepData.Status
    $step.Detail = [string]$StepData.Detail
    $step.StartTime = [datetime]$StepData.StartTime
    $step.EndTime = if ($null -ne $StepData.EndTime -and [string]$StepData.EndTime -ne '') { [datetime]$StepData.EndTime } else { $null }
    $step.Duration = [TimeSpan]$StepData.Duration
    $step.Children.Clear()

    foreach ($childData in @($StepData.Children)) {
        [void](ConvertFrom-InvokeStepData -StepData $childData -Parent $step)
    }

    return $step
}

function Invoke-StepInternal {
    [CmdletBinding()]
    param(
        [Parameter(Mandatory)][string]$Name,
        [Parameter(Mandatory)][bool]$ContinueOnError,
        [Parameter(Mandatory)][ScriptBlock]$ScriptBlock
    )

    $step = New-Step -Name $Name -ContinueOnError:$ContinueOnError
    $threw = $false
    $shouldThrow = $false
    $exception = $null
    $script:InsideStep = $true

    try {
        $null = & $ScriptBlock
    }
    catch {
        $threw = $true
        $exception = $_
        Set-Step -Status Error -Detail $_.Exception.Message | Out-Null
        $shouldThrow = -not $ContinueOnError
        if ($shouldThrow -and $step.ParentStep -and $step.ParentStep.ContinueOnError) {
            $shouldThrow = $false
        }
    }
    finally {
        $script:InsideStep = $false
        if (-not $threw) {
            Set-Step -Status Success | Out-Null
        }
        Complete-Step
    }

    return [pscustomobject]@{
        Step = $step
        Threw = $threw
        ShouldThrow = $shouldThrow
        Exception = $exception
    }
}

function Invoke-StepForEachWorker {
    [CmdletBinding()]
    param(
        [Parameter(Mandatory)][string]$StepName,
        [Parameter(Mandatory)][string]$ScriptText,
        [Parameter(Mandatory)][hashtable]$CapturedVariables,
        [Parameter(Mandatory)]$Item,
        [Parameter(Mandatory)][int]$Index,
        [Parameter(Mandatory)][bool]$ContinueOnError
    )

    foreach ($entry in $CapturedVariables.GetEnumerator()) {
        Set-Variable -Name $entry.Key -Value $entry.Value -Scope Local
    }

    $logEntries = [System.Collections.Generic.List[object]]::new()
    $previousCollector = $script:StepLogCollector
    $script:StepLogCollector = {
        param($entry)
        [void]$logEntries.Add((ConvertTo-StepLogEntry -Entry $entry))
    }
    try {
        $userScript = [scriptblock]::Create($ScriptText)
        $currentItem = $Item
        $currentIndex = $Index
        $result = Invoke-StepInternal -Name $StepName -ContinueOnError:$ContinueOnError -ScriptBlock {
            & $userScript $currentItem $currentIndex
        }
    }
    finally {
        $script:StepLogCollector = $previousCollector
    }

    return [pscustomobject]@{
        Index = $Index
        Threw = $result.Threw
        ShouldThrow = $result.ShouldThrow
        ErrorMessage = if ($null -ne $result.Exception) { $result.Exception.Exception.Message } else { $null }
        StepData = ConvertTo-InvokeStepData -Step $result.Step
        LogEntries = @($logEntries.ToArray())
    }
}

function Receive-InvokeStepJobResultSet {
    [CmdletBinding()]
    param(
        [Parameter(Mandatory)][System.Collections.ArrayList]$Jobs
    )

    $results = @()
    foreach ($job in @($Jobs)) {
        $payload = Receive-Job -Job $job -Wait -AutoRemoveJob -ErrorAction SilentlyContinue
        if ($null -ne $payload) {
            $results += $payload
        }
        [void]$Jobs.Remove($job)
    }

    return $results
}

function Invoke-StepForEachParallelInternal {
    [CmdletBinding()]
    param(
        [Parameter(Mandatory)][string]$Name,
        [Parameter(Mandatory)][object[]]$InputObject,
        [Parameter(Mandatory)][ScriptBlock]$ScriptBlock,
        [Parameter(Mandatory)][bool]$ContinueOnError,
        [Parameter(Mandatory)][int]$ThrottleLimit
    )

    $moduleManifestPath = Get-InvokeStepModuleManifestPath
    $scriptText = $ScriptBlock.ToString()
    $capturedVariables = Get-InvokeStepCapturedVariableMap -ScriptBlock $ScriptBlock
    $parentStep = Get-CurrentStep
    $payloads = for ($index = 0; $index -lt $InputObject.Count; $index++) {
        [pscustomobject]@{
            Item = $InputObject[$index]
            Index = $index
            StepName = Get-InvokeStepChildName -BaseName $Name -Item $InputObject[$index] -Index $index
        }
    }

    if ($PSVersionTable.PSVersion.Major -ge 7) {
        $results = $payloads | ForEach-Object -ThrottleLimit $ThrottleLimit -Parallel {
            $payload = $_
            try {
                Import-Module $using:moduleManifestPath -Force
                $module = Get-Module ITFabrik.Stepper

                & $module {
                    param($scriptText, $capturedVariables, $stepName, $item, $index, $continueOnError)
                    Invoke-StepForEachWorker -StepName $stepName -ScriptText $scriptText -CapturedVariables $capturedVariables -Item $item -Index $index -ContinueOnError:$continueOnError
                } $using:scriptText $using:capturedVariables $payload.StepName $payload.Item $payload.Index $using:ContinueOnError
            }
            catch {
                $now = Get-Date
                [pscustomobject]@{
                    Index = $payload.Index
                    Threw = $true
                    ShouldThrow = (-not $using:ContinueOnError)
                    ErrorMessage = $_.Exception.Message
                    StepData = [pscustomobject]@{
                        Name = $payload.StepName
                        Status = 'Error'
                        Detail = $_.Exception.Message
                        Level = 1
                        ContinueOnError = $using:ContinueOnError
                        StartTime = $now
                        EndTime = $now
                        Duration = [TimeSpan]::Zero
                        Children = @()
                    }
                }
            }
        }
    } else {
        $results = @()
        $jobs = [System.Collections.ArrayList]::new()
        $currentLocation = (Get-Location).Path
        $jobScript = {
            param($moduleManifestPath, $scriptText, $capturedVariables, $stepName, $item, $index, $continueOnError, $currentLocation)

            try {
                Set-Location -LiteralPath $currentLocation
                Import-Module $moduleManifestPath -Force
                $module = Get-Module ITFabrik.Stepper

                & $module {
                    param($scriptText, $capturedVariables, $stepName, $item, $index, $continueOnError)
                    Invoke-StepForEachWorker -StepName $stepName -ScriptText $scriptText -CapturedVariables $capturedVariables -Item $item -Index $index -ContinueOnError:$continueOnError
                } $scriptText $capturedVariables $stepName $item $index $continueOnError
            }
            catch {
                $now = Get-Date
                [pscustomobject]@{
                    Index = $index
                    Threw = $true
                    ShouldThrow = (-not $continueOnError)
                    ErrorMessage = $_.Exception.Message
                    StepData = [pscustomobject]@{
                        Name = $stepName
                        Status = 'Error'
                        Detail = $_.Exception.Message
                        Level = 1
                        ContinueOnError = $continueOnError
                        StartTime = $now
                        EndTime = $now
                        Duration = [TimeSpan]::Zero
                        Children = @()
                    }
                }
            }
        }

        foreach ($payload in $payloads) {
            while ($jobs.Count -ge $ThrottleLimit) {
                $completed = Wait-Job -Job @($jobs) -Any -Timeout 1
                if ($null -ne $completed) {
                    $results += Receive-Job -Job $completed -AutoRemoveJob -ErrorAction SilentlyContinue
                    [void]$jobs.Remove($completed)
                }
            }

            $job = Start-Job -ScriptBlock $jobScript -ArgumentList @(
                $moduleManifestPath,
                $scriptText,
                $capturedVariables,
                $payload.StepName,
                $payload.Item,
                $payload.Index,
                $ContinueOnError,
                $currentLocation
            )
            [void]$jobs.Add($job)
        }

        if ($jobs.Count -gt 0) {
            $results += Receive-InvokeStepJobResultSet -Jobs $jobs
        }
    }

    $orderedResults = @($results | Sort-Object Index)
    $firstFailure = $null

    foreach ($result in $orderedResults) {
        if ($null -ne $result.StepData) {
            [void](ConvertFrom-InvokeStepData -StepData $result.StepData -Parent $parentStep)
        }
        foreach ($logEntry in @($result.LogEntries)) {
            Write-StepLogEntry -Entry $logEntry
        }
        if ($result.ShouldThrow -and [string]::IsNullOrWhiteSpace($firstFailure)) {
            $firstFailure = $result.ErrorMessage
        }
    }

    if (-not [string]::IsNullOrWhiteSpace($firstFailure)) {
        throw $firstFailure
    }
}

function Invoke-StepForEachSequentialInternal {
    [CmdletBinding()]
    param(
        [Parameter(Mandatory)][string]$Name,
        [Parameter(Mandatory)][object[]]$InputObject,
        [Parameter(Mandatory)][ScriptBlock]$ScriptBlock,
        [Parameter(Mandatory)][bool]$ContinueOnError
    )

    $userScriptBlock = $ScriptBlock

    for ($index = 0; $index -lt $InputObject.Count; $index++) {
        $item = $InputObject[$index]
        $childName = Get-InvokeStepChildName -BaseName $Name -Item $item -Index $index
        $result = Invoke-StepInternal -Name $childName -ContinueOnError:$ContinueOnError -ScriptBlock {
            & $userScriptBlock $item $index
        }

        if ($result.ShouldThrow) {
            throw $result.Exception
        }
    }
}

function Invoke-Step {
    [CmdletBinding()]
    [OutputType('Step')]
    param(
        [Parameter(Mandatory)]
        [ValidateNotNullOrEmpty()]
        [ValidateScript({
            if ($_.Length -gt 80) { throw "Le nom de l'étape ne doit pas dépasser 80 caractères." }
            if ($_ -match '[\\/:*?"<>|]') { throw "Le nom de l'étape contient des caractères interdits (\\ / : * ? \"" < > |)." }
            return $true
        })]
        [string]$Name,

        [switch]$ContinueOnError = $false,

        [Parameter(Mandatory)]
        [ScriptBlock]$ScriptBlock,

        [Alias('Items')]
        [object[]]$InputObject,

        [switch]$Parallel,

        [Alias('Threshold')]
        [ValidateRange(1, 2147483647)]
        [int]$ParallelThreshold = 2,

        [ValidateRange(1, 2147483647)]
        [int]$ThrottleLimit = 5,

        [switch]$PassThru
    )

    $isCollectionMode = $PSBoundParameters.ContainsKey('InputObject')
    $continueOnErrorEnabled = [bool]$ContinueOnError
    $userScriptBlock = $ScriptBlock

    if (-not $isCollectionMode -and $Parallel.IsPresent) {
        throw 'The -Parallel option requires -InputObject.'
    }

    if (-not $isCollectionMode) {
        $result = Invoke-StepInternal -Name $Name -ContinueOnError:$continueOnErrorEnabled -ScriptBlock $userScriptBlock
        if ($result.Threw -and $result.ShouldThrow) {
            throw $result.Exception
        }
        if ($PassThru) {
            return $result.Step
        }
        return
    }

    $items = @($InputObject)
    $useParallel = $Parallel.IsPresent -and $items.Count -ge $ParallelThreshold
    $result = Invoke-StepInternal -Name $Name -ContinueOnError:$continueOnErrorEnabled -ScriptBlock {
        if ($useParallel) {
            Invoke-StepForEachParallelInternal -Name $Name -InputObject $items -ScriptBlock $userScriptBlock -ContinueOnError:$continueOnErrorEnabled -ThrottleLimit $ThrottleLimit
        } else {
            Invoke-StepForEachSequentialInternal -Name $Name -InputObject $items -ScriptBlock $userScriptBlock -ContinueOnError:$continueOnErrorEnabled
        }
    }

    if ($result.Threw -and $result.ShouldThrow) {
        throw $result.Exception
    }
    if ($PassThru) {
        return $result.Step
    }
}
# endregion

# region Public\Write-Log.ps1
<#
.SYNOPSIS
    Fonction publique pour écrire un message de log utilisateur dans StepManager.
.DESCRIPTION
    Permet à l'utilisateur d'écrire un message dans le log StepManager, avec un niveau d'indentation adapté pour les logs utilisateurs (toujours un cran de plus que la Step courante).
.PARAMETER Message
    Le message à afficher.
.PARAMETER Severity
    Le niveau de sévérité du message : Info, Success, Warning, Error, Debug, Verbose.
.EXAMPLE
    Write-Log -Message 'Début du traitement' -Severity 'Info'
#>

function Write-Log {
    [CmdletBinding()]
    param(
        [Parameter(Mandatory)] [string]$Message,
        [Parameter(Mandatory=$false)] [ValidateSet('Info','Success','Warning','Error','Debug','Verbose')] [string]$Severity = 'Info'
    )
    
    try {
        $currentStep = Get-CurrentStep
        if ($null -ne $currentStep) {
            $finalIndent = $currentStep.Level + 1
        } else {
            $finalIndent = 1
        }
    } catch {
        $finalIndent = 1
    }

    if ($Severity -eq 'Info') {
        $ForegroundColor = 'DarkGray'
    }
    
    $component = if ($null -ne $currentStep -and -not [string]::IsNullOrWhiteSpace($currentStep.Name)) {
        $currentStep.Name
    } else {
        'StepManager'
    }

    Write-StepLogEntry -Entry ([pscustomobject]@{
            Source = 'User'
            Component = $component
            Message = $Message
            Severity = $Severity
            IndentLevel = $finalIndent
            StepName = if ($null -ne $currentStep) { $currentStep.Name } else { '' }
            ForegroundColor = $ForegroundColor
        })
}
# endregion

# Exports
Export-ModuleMember -Function Invoke-Step,Write-Log -Alias @()