ITFabrik.Logger.psm1

# Built file. Do not edit directly; edit source files and rebuild.
# Build timestamp: 2026-03-05T08:15:28+00:00

# region Private\Classes\LoggerService.ps1
class LoggerSink {
    [string]$Type
    [hashtable]$Options
    LoggerSink([string]$type, [hashtable]$options) {
        $this.Type = $type
        $this.Options = $options
    }
}

class LoggerService {
    hidden static [LoggerService] $instance
    [System.Collections.ArrayList]$Sinks

    LoggerService() {
        $this.Sinks = [System.Collections.ArrayList]::new()
    }

    static [LoggerService] GetInstance() {
        if (-not [LoggerService]::instance) { [LoggerService]::instance = [LoggerService]::new() }
        return [LoggerService]::instance
    }

    static [void] Reset() {
        [LoggerService]::instance = [LoggerService]::new()
    }

    [void] RegisterSink([string]$type, [hashtable]$options) {
        [void]$this.Sinks.Add([LoggerSink]::new($type, $options))
    }

    [object[]] GetSinks() { return ,$this.Sinks.ToArray() }

    [void] ConfigureService() {
        $dispatcher = {
            param(
                [Parameter(Mandatory)] [string]$Component,
                [Parameter(Mandatory)] [string]$Message,
                [Parameter(Mandatory)] [ValidateSet('Info','Success','Warning','Error','Debug','Verbose')] [string]$Severity,
                [int]$IndentLevel = 0
            )
            $svc = [LoggerService]::GetInstance()
            foreach ($sink in $svc.Sinks) {
                switch ($sink.Type.ToLower()) {
                    'console' { Invoke-SMConsoleLogger $Component $Message $Severity $IndentLevel }
                    'file'    { Invoke-FileSink -Options $sink.Options -Component $Component -Message $Message -Severity $Severity -IndentLevel $IndentLevel }
                    'web'     { Invoke-WebSink  -Options $sink.Options -Component $Component -Message $Message -Severity $Severity -IndentLevel $IndentLevel }
                }
            }
        }
        Set-Variable -Name 'StepManagerLogger' -Scope Global -Value $dispatcher
    }
}
# endregion

# region Private\Functions\Format-ConsoleMessage.ps1
function Format-ConsoleMessage {
    param(
        [Parameter(Mandatory)] [string]$Component,
        [Parameter(Mandatory)] [string]$Message,
        [Parameter(Mandatory)] [ValidateSet('Info','Success','Warning','Error','Debug','Verbose')] [string]$Severity,
        [int]$IndentLevel = 0,
        [string]$StepName = '',
        [string]$ForegroundColor
    )
    $null = $Component

    # Préfixe partagé (padding sévérité, indentation, step)
    $parts = Get-LoggerPrefix -Severity $Severity -IndentLevel $IndentLevel -StepName $StepName

    switch ($Severity) {
        'Info'    { if(-not $ForegroundColor){ $ForegroundColor = 'Gray' } }
        'Success' { if(-not $ForegroundColor){ $ForegroundColor = 'Green' } }
        'Warning' { if(-not $ForegroundColor){ $ForegroundColor = 'Yellow' } }
        'Error'   { if(-not $ForegroundColor){ $ForegroundColor = 'Red' } }
        'Debug'   { if(-not $ForegroundColor){ $ForegroundColor = 'Cyan' } }
        'Verbose' { if(-not $ForegroundColor){ $ForegroundColor = 'Magenta' } }
        default   { if(-not $ForegroundColor){ $ForegroundColor = 'White' } }
    }

    $now = (Get-Date -Format 'yyyy-MM-dd HH:mm:ss')
    $prefix = "[$Severity]" + $parts.SeverityPad
    $text = "[$now] $prefix$($parts.Indent)$($parts.StepTag) $Message"

    [pscustomobject]@{
        Text = $text
        ForegroundColor = $ForegroundColor
    }
}
# endregion

# region Private\Functions\Format-LoggerLineCmtrace.ps1
function Format-LoggerLineCmtrace {
    param(
        [Parameter(Mandatory)][string]$Severity,
        [Parameter(Mandatory)][string]$Component,
        [Parameter(Mandatory)][string]$Message,
        [int]$IndentLevel = 0,
        [bool]$IsLast = $false
    )
    # CMTrace XML-like format expected by cmtrace.exe
    # <![LOG[Message]LOG]!><time="HH:mm:ss.ffffff" date="M-d-yyyy" component="..." context="user" type="N" thread="id" file="">

    # Map Severity to CMTrace numeric type (1=Info, 2=Warning, 3=Error)
    $type = switch ($Severity) {
        'Warning' { '2' }
        'Error'   { '3' }
        default   { '1' }
    }

    $time    = Get-Date -Format 'HH:mm:ss.ffffff'
    $date    = Get-Date -Format 'M-d-yyyy'
    $context = [System.Security.Principal.WindowsIdentity]::GetCurrent().Name
    $thread  = [Threading.Thread]::CurrentThread.ManagedThreadId

    $prefix = ''
    if ($IndentLevel -gt 1) {
        for ($i = 1; $i -lt $IndentLevel; $i++) { $prefix += '│ ' }
    }
    if ($IndentLevel -gt 0) {
        $branchGlyph = if ($IsLast) { '└─ ' } else { '├─ ' }
        $prefix += $branchGlyph
    }
    $msgWithIndent = "$prefix$Message"

    $content = ('<![LOG[{0}]LOG]!><time="{1}" date="{2}" component="{3}" context="{4}" type="{5}" thread="{6}" file="">' -f `
        $msgWithIndent, $time, $date, $Component, $context, $type, $thread)
    return $content
}
# endregion

# region Private\Functions\Format-LoggerLineDefault.ps1
function Format-LoggerLineDefault {
    param(
        [Parameter(Mandatory)][string]$Timestamp,
        [Parameter(Mandatory)][string]$Severity,
        [Parameter(Mandatory)][string]$Component,
        [Parameter(Mandatory)][string]$Message,
        [int]$IndentLevel = 0
    )

    # Use shared prefix helper to keep width/indent consistent with console
    $parts = Get-LoggerPrefix -Severity $Severity -IndentLevel $IndentLevel
    $sevPadded = $Severity.PadLeft($parts.FieldWidth)
    $indent = $parts.Indent
    return "[$Timestamp] [$sevPadded]$indent[$Component] $Message"
}
# endregion

# region Private\Functions\Get-LoggerPrefix.ps1
function Get-LoggerPrefix {
    param(
        [Parameter(Mandatory)][ValidateSet('Info','Success','Warning','Error','Debug','Verbose')][string]$Severity,
        [int]$IndentLevel = 0,
        [string]$StepName = ''
    )

    # Pad severity to a consistent visual width (inside or after brackets depending on consumer)
    $fieldWidth = 10
    $padCount = [Math]::Max(0, $fieldWidth - $Severity.Length)
    $severityPad = ' ' * $padCount

    $indent = if ($IndentLevel -gt 0) { ' ' * ($IndentLevel * 2) } else { '' }
    $stepTag = if ($StepName) { "[$StepName]" } else { '' }

    [pscustomobject]@{
        SeverityPad = $severityPad
        Indent      = $indent
        StepTag     = $stepTag
        FieldWidth  = $fieldWidth
    }
}
# endregion

# region Private\Functions\Invoke-FileSink.ps1
function Invoke-FileSink {
    [CmdletBinding()]
    param(
        [Parameter(Mandatory)][hashtable]$Options,
        [Parameter(Mandatory)][string]$Component,
        [Parameter(Mandatory)][string]$Message,
        [Parameter(Mandatory)][ValidateSet('Info','Success','Warning','Error','Debug','Verbose')][string]$Severity,
        [int]$IndentLevel = 0
    )

    $path = $Options.Path
    if (-not $path) { return }
    $onError = ($Options.OnError | ForEach-Object { $_ }) -as [string]
    if (-not $onError) { $onError = 'Warn' }
    if ($onError -notin @('Warn','Continue','Throw')) { $onError = 'Warn' }
    $format   = ($Options.Format   | ForEach-Object { $_ }) -as [string]; if (-not $format) { $format = 'Default' }
    $rotation = ($Options.Rotation | ForEach-Object { $_ }) -as [string]
    $maxSizeMB = [double]($(if ($Options.ContainsKey('MaxSizeMB')) { $Options.MaxSizeMB } else { 5 }))
    $maxRolls  = [int]  ($(if ($Options.ContainsKey('MaxRolls'))  { $Options.MaxRolls }  else { 3 }))
    $encoding  = $(if ($Options.ContainsKey('Encoding')) { $Options.Encoding } else { 'UTF8BOM' })

    $effectivePath = $path
    if ($rotation -match 'Daily') {
        $dir = Split-Path -Parent $path
        $leaf = Split-Path -Leaf $path
        $date = Get-Date -Format 'yyyy-MM-dd'
        if ($leaf -match '\.log$') {
            $base = [System.IO.Path]::GetFileNameWithoutExtension($leaf)
            $ext  = [System.IO.Path]::GetExtension($leaf)
            $effectivePath = Join-Path $dir ("{0}.{1}{2}" -f $base,$date,$ext)
        } else {
            $effectivePath = Join-Path $dir ("{0}.{1}" -f $leaf,$date)
        }
    }

    $dirToEnsure = Split-Path -Parent $effectivePath
    if ($dirToEnsure -and -not (Test-Path -LiteralPath $dirToEnsure)) {
        try {
            New-Item -ItemType Directory -Path $dirToEnsure -Force -ErrorAction Stop | Out-Null
        } catch {
            Invoke-LoggerSinkError -Sink 'File' -Action 'creating target directory' -ErrorRecord $_ -Policy $onError
            return
        }
    }

    # Rotation mode: NewFile -> archive existing file once per session before first write
    if ($rotation -match 'NewFile') {
        if (-not $script:FileSinkInitialized) { $script:FileSinkInitialized = @{} }
        $initKey = "newfile::" + $effectivePath
        if (-not $script:FileSinkInitialized.ContainsKey($initKey)) {
            try {
                if (Test-Path -LiteralPath $effectivePath) {
                    $dir = Split-Path -Parent $effectivePath
                    $base = Split-Path -Leaf $effectivePath
                    for ($i = $maxRolls; $i -ge 1; $i--) {
                        $dstLeaf = "$base.$i"
                        $dst = Join-Path $dir $dstLeaf
                        if (Test-Path -LiteralPath $dst) { Remove-Item -LiteralPath $dst -Force -ErrorAction SilentlyContinue }
                        if ($i -eq 1) {
                            if (Test-Path -LiteralPath $effectivePath) { Rename-Item -LiteralPath $effectivePath -NewName $dstLeaf -Force -ErrorAction SilentlyContinue }
                        } else {
                            $srcLeaf = "$base.$($i-1)"
                            $src = Join-Path $dir $srcLeaf
                            if (Test-Path -LiteralPath $src) { Rename-Item -LiteralPath $src -NewName $dstLeaf -Force -ErrorAction SilentlyContinue }
                        }
                    }
                }
            } catch {
                Invoke-LoggerSinkError -Sink 'File' -Action 'rotation NewFile' -ErrorRecord $_ -Policy $onError
            }
            $script:FileSinkInitialized[$initKey] = $true
        }
    }

    if ($rotation -match 'Size') {
        try {
            if (Test-Path -LiteralPath $effectivePath) {
                $lenBytes = (Get-Item -LiteralPath $effectivePath).Length
                $lenMB = $lenBytes / 1MB
                if ($lenMB -ge $maxSizeMB) {
                    $dir = Split-Path -Parent $effectivePath
                    $base = Split-Path -Leaf $effectivePath
                    for ($i = $maxRolls; $i -ge 1; $i--) {
                        $dstLeaf = "$base.$i"
                        $dst = Join-Path $dir $dstLeaf
                        if (Test-Path -LiteralPath $dst) { Remove-Item -LiteralPath $dst -Force -ErrorAction SilentlyContinue }
                        if ($i -eq 1) {
                            if (Test-Path -LiteralPath $effectivePath) { Rename-Item -LiteralPath $effectivePath -NewName $dstLeaf -Force -ErrorAction SilentlyContinue }
                        } else {
                            $srcLeaf = "$base.$($i-1)"
                            $src = Join-Path $dir $srcLeaf
                            if (Test-Path -LiteralPath $src) { Rename-Item -LiteralPath $src -NewName $dstLeaf -Force -ErrorAction SilentlyContinue }
                        }
                    }
                }
            }
        } catch {
            Invoke-LoggerSinkError -Sink 'File' -Action 'rotation Size' -ErrorRecord $_ -Policy $onError
        }
    }

    $now = Get-Date

    switch -Regex ($format) {
        '^cmtrace$' {
            $line = Format-LoggerLineCmtrace -Severity $Severity -Component $Component -Message $Message -IndentLevel $IndentLevel
        }
        default {
            $ts = $now.ToString('yyyy-MM-dd HH:mm:ss')
            $line = Format-LoggerLineDefault -Timestamp $ts -Severity $Severity -Component $Component -Message $Message -IndentLevel $IndentLevel
        }
    }

    try {
        $encObj = switch -Regex ($encoding) {
            '^UTF8$'      { New-Object System.Text.UTF8Encoding($false); break }
            '^UTF8BOM$'   { New-Object System.Text.UTF8Encoding($true); break }
            '^ASCII$'     { [System.Text.Encoding]::ASCII; break }
            '^Unicode$'   { [System.Text.Encoding]::Unicode; break }
            '^UTF7$'      { [System.Text.Encoding]::UTF7; break }
            '^UTF32$'     { [System.Text.Encoding]::UTF32; break }
            '^Default$'   { [System.Text.Encoding]::Default; break }
            '^OEM$'       { [System.Text.Encoding]::GetEncoding([System.Console]::OutputEncoding.CodePage); break }
            default       { New-Object System.Text.UTF8Encoding($false) }
        }
        if (-not $script:FileSinkInitialized) { $script:FileSinkInitialized = @{} }
        $firstWrite = $false
        if (-not $rotation) {
            if (-not $script:FileSinkInitialized.ContainsKey($effectivePath)) {
                $script:FileSinkInitialized[$effectivePath] = $true
                $firstWrite = $true
            }
        }

        if ($firstWrite) {
            [System.IO.File]::WriteAllText($effectivePath, $line + [Environment]::NewLine, $encObj)
        } else {
            [System.IO.File]::AppendAllText($effectivePath, $line + [Environment]::NewLine, $encObj)
        }
    } catch {
        Invoke-LoggerSinkError -Sink 'File' -Action 'writing log line' -ErrorRecord $_ -Policy $onError
    }
}
# endregion

# region Private\Functions\Invoke-LoggerSinkError.ps1
function Invoke-LoggerSinkError {
    [CmdletBinding()]
    param(
        [Parameter(Mandatory)][string]$Sink,
        [Parameter(Mandatory)][string]$Action,
        [Parameter(Mandatory)][System.Management.Automation.ErrorRecord]$ErrorRecord,
        [ValidateSet('Warn','Continue','Throw')][string]$Policy = 'Warn'
    )

    $message = "ITFabrik.Logger sink '$Sink' failed during ${Action}: $($ErrorRecord.Exception.Message)"

    switch ($Policy) {
        'Throw' { throw $ErrorRecord }
        'Warn' { Write-Warning $message }
        'Continue' { }
    }
}
# endregion

# region Private\Functions\Invoke-SMConsoleLogger.ps1
function Invoke-SMConsoleLogger {
    [Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSReviewUnusedParameter', 'Component', Justification = 'Keep ITFabrik.Stepper-compatible signature (legacy StepManagerLogger contract).')]
    [Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSAvoidUsingWriteHost', '', Justification = 'Console sink intentionally writes to host.')]
    param(
        [Parameter(Mandatory)] [string]$Component,
        [Parameter(Mandatory)] [string]$Message,
        [Parameter(Mandatory)] [ValidateSet('Info','Success','Warning','Error','Debug','Verbose')] [string]$Severity,
        [int]$IndentLevel = 0,
        [string]$StepName = '',
        [string]$ForegroundColor
    )

    $obj = Format-ConsoleMessage -Component $Component -Message $Message -Severity $Severity -IndentLevel $IndentLevel -StepName $StepName -ForegroundColor $ForegroundColor
    Write-Host $obj.Text -ForegroundColor $obj.ForegroundColor
}
# endregion

# region Private\Functions\Invoke-WebSink.ps1
function Invoke-WebSink {
    [CmdletBinding()]
    param(
        [Parameter(Mandatory)][hashtable]$Options,
        [Parameter(Mandatory)][string]$Component,
        [Parameter(Mandatory)][string]$Message,
        [Parameter(Mandatory)][ValidateSet('Info','Success','Warning','Error','Debug','Verbose')][string]$Severity,
        [int]$IndentLevel = 0
    )

    $url = $Options.Url
    if (-not $url) { return }
    $onError = ($Options.OnError | ForEach-Object { $_ }) -as [string]
    if (-not $onError) { $onError = 'Warn' }
    if ($onError -notin @('Warn','Continue','Throw')) { $onError = 'Warn' }
    $apiKey = $Options.APIKey
    $headers = @{}
    if ($apiKey) { $headers['X-API-Key'] = $apiKey }
    if ($Options.Headers -is [hashtable]) { $Options.Headers.GetEnumerator() | ForEach-Object { $headers[$_.Key] = $_.Value } }
    $payload = [ordered]@{
        timestamp  = (Get-Date).ToString('o')
        component  = $Component
        message    = $Message
        severity   = $Severity
        indent     = $IndentLevel
        host       = $env:COMPUTERNAME
        processId  = $PID
    }
    try {
        Invoke-RestMethod -Method Post -Uri $url -Headers $headers -Body ($payload | ConvertTo-Json -Depth 5) -ContentType 'application/json' -ErrorAction Stop | Out-Null
    } catch {
        Invoke-LoggerSinkError -Sink 'Web' -Action 'posting HTTP payload' -ErrorRecord $_ -Policy $onError
    }
}
# endregion

# region Public\Disable-Logger.ps1
function Disable-Logger {
    [CmdletBinding()]
    param()

    Remove-Variable -Name StepManagerLogger -Scope Global -ErrorAction SilentlyContinue
}
# endregion

# region Public\Initialize-LoggerConsole.ps1
function Initialize-LoggerConsole {
    [CmdletBinding()]
    param()

    Initialize-LoggerService -Reset
    Register-LoggerSink -Type Console
}
# endregion

# region Public\Initialize-LoggerFile.ps1
function Initialize-LoggerFile {
    [CmdletBinding()]
    param(
        [Parameter(Mandatory)] [string]$Path,
        [ValidateSet('Size','Daily','NewFile')] [string]$Rotation,
        [ValidateRange(0.001, 1024)] [double]$MaxSizeMB = 5,
        [ValidateRange(1, 100)] [int]$MaxRolls = 3,
        [ValidateSet('UTF8','UTF8BOM','ASCII','Unicode','UTF7','UTF32','Default','OEM')] [string]$Encoding = 'UTF8BOM',
        [ValidateSet('Warn','Continue','Throw')] [string]$OnError = 'Warn'
    )

    Initialize-LoggerService -Reset

    $sinkArgs = @{
        Type       = 'File'
        Path       = $Path
        FileFormat = 'Default'
        MaxSizeMB  = $MaxSizeMB
        MaxRolls   = $MaxRolls
        Encoding   = $Encoding
        OnError    = $OnError
    }
    if ($PSBoundParameters.ContainsKey('Rotation')) { $sinkArgs.Rotation = $Rotation }
    Register-LoggerSink @sinkArgs
}
# endregion

# region Public\Initialize-LoggerService.ps1
function Initialize-LoggerService {
    [CmdletBinding()]
    param(
        [switch]$Reset,
        [scriptblock]$Action
    )

    if ($Reset) { [LoggerService]::Reset() | Out-Null }
    $svc = [LoggerService]::GetInstance()
    $svc.ConfigureService()
    if ($Action) { & $Action -ArgumentList $svc }
}
# endregion

# region Public\Register-LoggerSink.ps1
function Register-LoggerSink {
    [CmdletBinding(DefaultParameterSetName='Console')]
    param(
        [Parameter(Mandatory)][ValidateSet('Console','File','Web')][string]$Type,

        # Console
        [Parameter(ParameterSetName='Console')][ValidateSet('Default')][string]$Format = 'Default',

        # File
        [Parameter(Mandatory, ParameterSetName='File')][string]$Path,
        [Parameter(ParameterSetName='File')][ValidateSet('Default','Cmtrace')][string]$FileFormat = 'Default',
        [Parameter(ParameterSetName='File')][ValidateSet('Size','Daily','NewFile')][string]$Rotation,
        [Parameter(ParameterSetName='File')][ValidateRange(0.001,1024)][double]$MaxSizeMB = 5,
        [Parameter(ParameterSetName='File')][ValidateRange(1,100)][int]$MaxRolls = 3,
        [Parameter(ParameterSetName='File')][ValidateSet('UTF8','UTF8BOM','ASCII','Unicode','UTF7','UTF32','Default','OEM')][string]$Encoding = 'UTF8BOM',
        [Parameter(ParameterSetName='File')]
        [Parameter(ParameterSetName='Web')]
        [ValidateSet('Warn','Continue','Throw')][string]$OnError = 'Warn',

        # Web
        [Parameter(Mandatory, ParameterSetName='Web')][string]$Url,
        [Parameter(ParameterSetName='Web')][string]$APIKey,
        [Parameter(ParameterSetName='Web')][hashtable]$Headers
    )

    $svc = [LoggerService]::GetInstance()
    switch ($Type) {
        'Console' {
            $svc.RegisterSink('Console', @{ Format = $Format })
        }
        'File' {
            $svc.RegisterSink('File', @{ Path = $Path; Format = $FileFormat; Rotation = $Rotation; MaxSizeMB = $MaxSizeMB; MaxRolls = $MaxRolls; Encoding = $Encoding; OnError = $OnError })
        }
        'Web' {
            $svc.RegisterSink('Web', @{ Url = $Url; APIKey = $APIKey; Headers = $Headers; OnError = $OnError })
        }
    }
}
# endregion

# Exports
Export-ModuleMember -Function Initialize-LoggerService,Register-LoggerSink,Initialize-LoggerConsole,Initialize-LoggerFile,Disable-Logger