cliHelper.logger.psm1

#!/usr/bin/env pwsh
using namespace System.IO
using namespace System.Text
using namespace System.Linq
using namespace System.Threading
using namespace System.Collections
using namespace System.Collections.Generic
using namespace System.Management.Automation
using namespace System.Collections.Concurrent
using namespace System.Collections.ObjectModel

#Requires -Psedition Core
#Requires -Modules PsModuleBase

# Enums
enum LogLevel {
  DEBUG = 0    # Detailed diagnostic Info
  INFO = 1     # General operational information
  WARN = 2     # Indicates a potential problem
  ERROR = 3    # A recoverable error occurred
  FATAL = 4    # Critical conditions, system may be unusable (same as Critical/Alert/Emergency)
}

enum LogAppenderType {
  CONSOLE = 0  # writes to console
  JSON = 1     # writes to a .json file
  FILE = 2     # writes to a .log file
  XML = 3      # writes to a .xml file
}


# .EXAMPLE
# New-Object LogEntry # same as: [LogEntry]@{}
class LogEntry {
  [string]$Message
  [Exception]$Exception
  hidden [LogLevel]$Severity
  [datetime]$Timestamp = [datetime]::UtcNow

  static [LogEntry] Create([LogLevel]$severity, [string]$message, [Exception]$exception) {
    $e = [LogEntry]@{
      Message   = $message
      Severity  = $severity
      Exception = $exception
      Timestamp = [datetime]::UtcNow
    }
    $e.PsObject.Properties.Add([PsAliasProperty]::new('Level', 'Severity'))
    return $e
  }
  [Hashtable] ToHashtable() {
    return @{
      Message   = $this.Message
      Severity  = $this.Severity.ToString()
      Timestamp = $this.Timestamp.ToString('o') # ISO 8601 format
      Exception = ($null -ne $this.Exception) ? $this.Exception.ToString() : [string]::Empty
    }
  }
  [string] ToString() {
    return "[{0:u}] [{1,-5}] {2}" -f $this.Timestamp, $this.Severity.ToString().Trim().ToUpper(), $this.Message
  }
}

class LogEntries : PsReadOnlySet {
  LogEntries() : base(@()) {}
  LogEntries([LogEntry[]]$array) : base($array) {}

  [LogEntry[]] SortBy([string]$PropertyName) {
    return $this.SortBy($PropertyName, $true)
  }
  [LogEntry[]] SortBy([string]$PropertyName, [bool]$descending) {
    $validnames = [LogEntry].GetProperties().Name
    if ($PropertyName -notin $validnames) {
      $values_array = '@("{0}")' -f $($validnames -join '", "')
      throw [ArgumentException]::new("Name is invalid. provide one of $values_array and try again.", 'PropertyName')
    }
    return $this.ToSortedList($PropertyName, $descending).Values
  }
  [LogEntry[]] ToArray() {
    return $this.GetEnumerator() | Select-Object
  }
  [string[]] ToString() {
    return $this.GetEnumerator().ForEach({ $_.ToString() })
  }
}

class LogAppender : IDisposable {
  hidden [ValidateNotNullOrWhiteSpace()][string]$_name = $this.PsObject.TypeNames[0]
  [void] Log([LogEntry]$entry) {
    [ValidateNotNull()][LogEntry]$entry = $entry
    throw [PSNotImplementedException]::new("Log method not implemented in $($this.GetType().Name)")
  }
  [string] GetlogLine([LogEntry]$entry) {
    [ValidateNotNull()][LogEntry] $entry = $entry
    $logb = $entry.ToHashtable(); $atype = $this.GetType().Name.Replace("Appender", "").ToUpper()
    $logb.Exception = $logb.Exception -eq "System.Exception" ? [String]::Empty : $logb.Exception
    $line = switch ($true) {
      ($atype -eq "JSON") { ($logb | ConvertTo-Json -Compress -Depth 5) + ','; break }
      ($atype -in ("CONSOLE", "FILE")) {
        $l = "[{0:u}] [{1,-5}] {2}" -f $logb.Timestamp, $logb.Severity.ToString().Trim().ToUpper(), $logb.Message;
        if (![string]::IsNullOrWhiteSpace($logb.Exception)) {
          # Append exception on new lines, indented for readability
          $e = ($entry.Exception.ToString() -split '\r?\n' | ForEach-Object { " $_" }) -join "`n"
          # keep the console log clean :)
          if (!($atype -eq "CONSOLE" -and (Get-Variable ErrorActionPreference).Value -in ("Ignore", "SilentlyContinue"))) {
            $l += "`n$e"
          }
        }
        $l;
        break
      }
      ($atype -eq "XML") { $logb | ConvertTo-CliXml -Depth 5; break }
      default {
        throw [InvalidOperationException]::new("BUG: LogAppenderType of value '$atype' was not expected!")
      }
    }
    return $line
  }
  hidden [bool] IsSafetoLog() {
    return $this.IsSafetoLog($false)
  }
  hidden [bool] IsSafetoLog([bool]$throwonError) {
    $s = $true
    $s = $s -and (($throwonError -and $this.IsDisposed) ? $(throw [ObjectDisposedException]::new("$($this.GetType().Name) is already disposed")) : $false)
    # todo: perform other checks here:
    # ex: $s = $s -and ...
    return $s
  }
  [void] Dispose() {
    if ($this.IsDisposed) { return }
    $this.PsObject.Properties.Add([PSScriptProperty]::new('IsDisposed', { return $true }, { throw [SetValueException]::new("IsDisposed is a ReadOnly Property") }))
  }
  [string] ToString() {
    return "[{0}]" -f $this._name
  }
}

# Appender that writes to the PowerShell console with colors
class ConsoleAppender : LogAppender {
  static [Hashtable]$ColorMap = @{
    DEBUG = [ConsoleColor]::DarkGray
    INFO  = [ConsoleColor]::Green
    WARN  = [ConsoleColor]::Yellow
    ERROR = [ConsoleColor]::Red
    FATAL = [ConsoleColor]::Magenta
  }
  ConsoleAppender() {
    $this.PsObject.Properties.Add([PSScriptProperty]::new('Type', [scriptblock]::Create("return [LogAppenderType]'CONSOLE'"), {
          throw [SetValueException]::new('"Type" is a ReadOnly property')
        }
      )
    )
  }
  [void] Log([LogEntry]$entry) {
    $this.IsSafetoLog($true)
    Write-Host $this.GetlogLine($entry) -f ([ConsoleAppender]::ColorMap[$entry.Severity.ToString()])
  }
  [LogEntries] ReadEntries() {
    Write-Warning "There is no implementation to record or read previous console entries!"
    return [LogEntries]::Empty
  }
}

# Appender that writes formatted text logs to a file
class FileAppender : LogAppender {
  hidden [StreamWriter]$_writer
  hidden [ValidateNotNullOrWhiteSpace()][string]$FilePath
  hidden [ReaderWriterLockSlim]$_lock = @{}
  FileAppender([string]$Path) {
    $p = [Logger]::GetUnResolvedPath($Path); $dir = Split-Path $p -Parent
    if (!(Test-Path $dir)) {
      try {
        New-Item -Path $dir -ItemType Directory -Force -ErrorAction Stop | Out-Null
      } catch {
        throw [RuntimeException]::new("Failed to create directory '$dir'", $_.Exception)
      }
    }
    if (![File]::Exists($p)) { New-Item -ItemType File -Path $p -ea Stop -Verbose:$false }
    $this.FilePath = $p
    # Open file for appending with UTF8 encoding
    $this._writer = [StreamWriter]::new($this.FilePath, $true, [Encoding]::UTF8)
    $this._writer.AutoFlush = $true # Flush after every write
    $this.PsObject.Properties.Add([PSScriptProperty]::new('Type', [scriptblock]::Create("return [LogAppenderType]'File'"), {
          throw [SetValueException]::new('"Type" is a ReadOnly property')
        }
      )
    )
  }
  [void] Log([LogEntry]$entry) {
    [void]$this.IsSafetoLog($true)
    $logLine = $this.GetlogLine($entry)
    # Acquire write lock
    $this._lock.EnterWriteLock()
    try {
      # Re-check disposal after acquiring lock
      if ($null -ne $this._writer) {
        $this._writer.WriteLine($logLine)
      }
      # AutoFlush is true
    } catch {
      throw [RuntimeException]::new("FileAppender failed to write to '$($this.FilePath)'", $_.Exception)
    } finally {
      $this._lock.ExitWriteLock()
    }
  }
  [LogEntries] ReadEntries() {
    return [FileAppender]::ReadEntries($this.FilePath)
  }
  static [LogEntries] ReadEntries([string]$FilePath) {
    throw [PSNotImplementedException]::new("there is no implementation to read a .log files")
  }
  [void] Dispose() {
    if ($this.IsDisposed) { return }
    if ($null -ne $this._writer) {
      try {
        # Prevent new logs trying to acquire lock while disposing
        $this._lock.EnterWriteLock() # Acquire lock to ensure no writes are happening
        $this._writer.Flush() # Final flush
        $this._writer.Dispose()
      } catch {
        throw [RuntimeException]::new("error during dispose of file '$($this.FilePath)'", $_.Exception)
      } finally {
        $this._lock.ExitWriteLock()
      }
    }
    $this.PsObject.Properties.Add([PSScriptProperty]::new('IsDisposed', { return $true }, { throw [SetValueException]::new("IsDisposed is a ReadOnly Property") }))
    $this._lock.Dispose()
  }
}

# Appender that writes log entries as JSON objects to a file
class JsonAppender : FileAppender {
  JsonAppender([string]$Path) : base($Path) {
    $this.PsObject.Properties.Add([PSScriptProperty]::new('Type', [scriptblock]::Create("return [LogAppenderType]'JSON'"), {
          throw [SetValueException]::new('"Type" is a ReadOnly property')
        }
      )
    )
  }
  [void] Log([LogEntry]$entry) {
    $this.IsSafetoLog($true)
    try {
      $this._writer.WriteLine($this.GetlogLine($entry))
      # AutoFlush is true, manual flush shouldn't be needed unless guaranteeing write before potential crash
    } catch {
      throw [RuntimeException]::new("JsonAppender failed to write to '$($this.FilePath)'", $_.Exception)
    }
  }
  [LogEntries] ReadEntries() {
    return [JsonAppender]::ReadEntries($this.FilePath)
  }
  static [LogEntries] ReadEntries([string]$FilePath) {
    if ([File]::Exists($FilePath)) {
      $array = '[{0}]' -f ([File]::ReadAllText($FilePath)) | ConvertFrom-Json
      return [LogEntries]::new($array)
    }
    return @{}
  }
}

class XMLAppender : FileAppender {
  XMLAppender([string]$Path) : base($Path) {
    $this.PsObject.Properties.Add([PSScriptProperty]::new('Type', [scriptblock]::Create("return [LogAppenderType]'XML'"), {
          throw [SetValueException]::new('"Type" is a ReadOnly property')
        }
      )
    )
  }
  [LogEntries] ReadEntries() {
    return [XMLAppender]::ReadEntries($this.FilePath)
  }
  static [LogEntries] ReadEntries([string]$FilePath) {
    if ([File]::Exists($FilePath)) {
      # todo: try using [PSSerializer]::Deserialize($text)
      $array = [File]::ReadAllText($FilePath) | ConvertFrom-CliXml
      return [LogEntries]::new($array)
    }
    return @{}
  }
}

class LogFiles : HashSet[FileInfo] {
  LogFiles() {}
  LogFiles([IO.FileInfo[]]$files) {
    $files.ForEach({ $this.Add($_) })
  }
  [FileInfo[]] ToArray() {
    return $this.GetEnumerator() | Select-Object
  }
  [string[]] ToString() {
    return $this.FullName
  }
}

class LogAppenders : PsReadOnlySet {
  LogAppenders() : base(@()) { $this._init_() }
  LogAppenders([LogAppender[]]$array) : base($array) { $this._init_() }
  hidden [void] _init_() {
    $this.PsObject.Properties.Add([psscriptproperty]::new('Name', { return $this.GetEnumerator().ForEach({ $_._name }) }))
  }
  [LogAppender[]] ToArray() {
    return $this.GetEnumerator() | Select-Object
  }
}

class Logsession : IDisposable {
  [ValidateNotNull()][ConfigFile] $File                     # Handles the config file persistence
  hidden [ValidateNotNull()][LogFiles] $_logFiles = @{}     # Paths of associated log files created in this session
  hidden [ValidateNotNull()][Type] $_logType = [LogEntry]   # Runtime type object
  hidden [ValidateNotNull()][LogAppenders] $_logAppenders = @{}
  hidden [ValidateNotNull()][DirectoryInfo] $_logdir
  hidden [bool] $IsDisposed

  Logsession() {
    [void][Logsession]::From($this.get_instanceId(), $this.get_datapath("config"), [ref]$this)
  }
  Logsession([string]$Id) {
    [void][Logsession]::From($Id, $this.get_datapath("config"), [ref]$this)
  }
  Logsession([PsObject]$object) {
    [void][Logsession]::From($this.get_configdata($object), [ref]$this)
  }
  Logsession([string]$SessionId, [string]$Logdir) {
    [void][Logsession]::From($SessionId, $Logdir, [ref]$this)
  }
  static hidden [Logsession] From([PSCustomObject]$object, [ref]$o) {
    $d = $o.Value.get_configdata($object)
    $f = [ConfigFile]::new($d.Id); $f.SetDirectory($d.Logdir)
    # Other props to check: ("LogType", "LogFiles", "Metadata")
    return [Logsession]::From($f, $o)
  }
  static hidden [Logsession] From([ConfigFile]$configFile, [ref]$o) {
    # ScriptProperties
    $o.Value.PsObject.Properties.Add([PSScriptProperty]::new('Logdir', { return $this.get_logdir() }, { Param($value) $this.set_logdir($value) }))
    $o.Value.PsObject.Properties.Add([PSScriptProperty]::new('LogFiles', { return $this.get_logFiles() }, { Param([string[]]$values) $this.add_logfiles($values) }))
    $o.Value.PsObject.Properties.Add([PSScriptProperty]::new('LogType', { return $this._logType -as [Type] }, { Param([type]$value) $this.set_logType($value) }))
    $o.Value.PsObject.Properties.Add([PSScriptProperty]::new('LogAppenders', { return $this.GetAppenders() }, { throw [SetValueException]::new("LogAppenders is a ReadOnly Property") }))
    # Imports:
    $i = $configFile.Exists ? (ConvertFrom-Json($configFile.ReadAllText())) : [PsObject]::new()
    $o.Value.PSobject.Properties.Add([PSVariableProperty]::new([PSVariable]::new("Metadata", [Hashtable]$($i.Metadata ? $i.Metadata : @{})))) # Extra arbitrary info (hostname, user, script, etc.)
    $Id = $configFile.BaseName; [ValidateNotNullOrWhiteSpace()][string] $Id = $Id
    $o.Value.PsObject.Properties.Add([PSScriptProperty]::new('Id', [scriptblock]::Create("return '$Id'"), { throw [SetValueException]::new('Id is a ReadOnly property') }))
    $o.Value.File = $configFile
    $o.Value.Logdir = $configFile.Directory
    $o.Value.Logdir = $i.Logdir ? $i.Logdir : $o.Value.get_datapath("Logs")
    $o.Value.LogType = $i.LogType ? $i.LogType : [LogEntry]
    $i.LogFiles ? $o.Value.add_logfiles($i.LogFiles) : $null
    return $o.Value
  }
  [LogAppenders] GetAppenders() {
    $array = @(); [Enum]::GetNames[LogAppenderType]().ForEach({
        $a = $this.GetAppenders($_);
        if ($null -ne $a) { $array += $a }
      }
    )
    return [LogAppenders]::new($array)
  }
  [LogAppenders] GetAppenders([LogAppenderType]$type) {
    return $this.GetAppenders($type, -1)
  }
  [LogAppenders] GetAppenders([LogAppenderType]$type, [int]$MinCount) {
    $array = $this._logAppenders.Where({ $_.Type -eq $type })
    if ($MinCount -ge 0 -and $array.count -gt $MinCount) {
      throw [InvalidOperationException]::new("Can not have more than $MinCount '$type' appender type in the same session!")
    }
    return [LogAppenders]::new($array)
  }
  [ArrayList] ListFileAppenders() {
    $list = [ArrayList]::new(); $la = $this.LogAppenders
    if ($null -ne $la) { $la.Where({ $_.PsObject.TypeNames.Contains("FileAppender") }).ForEach({ [void]$list.Add($_) }) }
    return $list
  }
  [void] Save() {
    if ($this.IsDisposed) { throw [ObjectDisposedException]::new($this.GetType().Name) }
    if ($null -eq $this.File) { throw [InvalidOperationException]::new("Cannot save session, ConfigFile property is not set.") }
    Write-Debug "[Logsession '$($this.Id)'] Saving session to '$($this.File.FullName)'..."
    $jsonContent = $this.ToJson()
    try {
      $this.File.Save($jsonContent)
      Write-Debug "[Logsession '$($this.Id)'] Saved successfully."
    } catch {
      throw [IOException]::new("Failed to save session file '$($this.File.FullName)'.", $_.Exception)
    }
  }
  static hidden [Logsession] From([string]$SessionId, [string]$Logdir, [ref]$o) {
    [ValidateNotNullOrWhiteSpace()][string]$Logdir = $Logdir
    $cf = [ConfigFile]::new($SessionId); $cf.SetDirectory($Logdir)
    return [Logsession]::From($cf, $o)
  }
  static [DirectoryInfo] GetDataPath([string]$subdirName) {
    [ValidateNotNullOrWhiteSpace()][string]$subdirName = $subdirName
    return [PsModuleBase]::GetDataPath([PsModuleBase]::ReadModuledata("cliHelper.logger", "AppDataFolderName"), $subdirName)
  }
  hidden [void] add_logfiles([string[]]$files) {
    if ($files.Count -gt 0) {
      $resolved = ($files | Select-Object @{l = 'Path'; e = { [PsModuleBase]::GetUnResolvedPath($_) } }).Path
      $resolved.ForEach({
          $f = [FileInfo]::new($_);
          if ($null -eq $this._logFiles.ToString()) {
            $this._logFiles.Add($f)
          } elseif (!$this._logFiles.ToString().Contains($f.FullName)) {
            $this._logFiles.Add($f)
          }
        }
      )
    }
  }
  hidden [LogFiles] get_logFiles() {
    $this.add_logfiles($this.ListFileAppenders().FilePath)
    return $this._logFiles
  }
  hidden [DirectoryInfo] get_logdir() {
    if ([string]::IsNullOrWhiteSpace($this._logdir)) {
      $this.set_logdir($this.get_datapath("Logs"))
    }
    return $this._logdir
  }
  hidden [void] set_logdir([string]$value) {
    $dir = [PsModuleBase]::GetUnResolvedPath($value)
    if (![Path]::IsPathFullyQualified($dir)) {
      throw [ArgumentException]("Logdir path must be fully qualified: '$value'")
    }
    if (![Directory]::Exists($dir)) {
      try {
        Write-Verbose "[Logsession '$($this.Id)'] created. Saving logs to '$dir'"
        [void][PsModuleBase]::CreateFolder($dir)
      } catch {
        throw [IOException]::new("Failed to create log directory '$dir'.", $_.Exception)
      }
    }
    $this._logdir = $dir
  }
  hidden [void] set_logType([type]$value) {
    if ($value -is [Type] -and ($value -eq [LogEntry] -or $value.IsSubclassOf([LogEntry]))) {
      $this._logType = $value
    } else {
      throw [ArgumentException]::new("LogType must be [LogEntry] or a Type that inherits from LogEntry. Provided: '$($value.FullName)'")
    }
  }
  hidden [string] get_datapath([string]$subdirName) {
    return [Logsession]::GetDataPath($subdirName)
  }
  hidden [Object] get_configdata([PsObject]$object) {
    [ValidateNotNullOrWhiteSpace()][psobject]$object = $object
    # checks for the important properties
    $props = @("Id", "Logdir"); $MissingProps = @()
    # $other_not_important_props = @("LogType", "LogFiles", "Metadata")
    $selected = $object | Select-Object * -ExcludeProperty $props
    $props.ForEach({ $selected.PsObject.Properties.Add([psnoteproperty]::new($_, ($object.$_ ? $object.$_ : $($MissingProps.Add($_); $null)))) })
    if ($MissingProps.Count -gt 0) {
      throw [MetadataException]::new(('$MissingProps = @("{0}")' -f ($MissingProps -join ', "')))
    }
    return $selected
  }
  hidden [string] get_instanceId() {
    # will always be the same if requested in the same host session.
    return (Get-Variable Host).Value.InstanceId.ToString()
  }
  [Hashtable] ToHashtable() {
    # Explicitly select properties for serialization
    return @{
      Id       = [string]$this.Id
      Logdir   = [string]$this.Logdir
      LogType  = [string]$this.LogType
      LogFiles = [string[]]($this.LogFiles ? $this.LogFiles.ToArray() : @())
      Metadata = $this.Metadata
    }
  }
  [string] ToJson() {
    return ConvertTo-Json -InputObject $this.ToHashtable() -Depth 5 # Adjust depth if Metadata gets complex
  }
  [void] Dispose() {
    if ($this.IsDisposed) { throw [ObjectDisposedException]::new($this.GetType().Name) }
    Write-Debug "[Logsession '$($this.Id)'] Disposing..."
    foreach ($appender in $this._logAppenders) {
      if ($appender -is [IDisposable]) {
        try {
          $appender.Dispose()
        } catch {
          throw [RuntimeException]::new("Error disposing appender '$($appender.GetType().Name)'", $_.Exception)
        }
      }
    }
    # overwrite the property:
    $this.PsObject.Properties.Add([PSScriptProperty]::new('IsDisposed', { return $true }, { throw [SetValueException]::new("Its a ReadOnly Property") }))
    [void][GC]::SuppressFinalize($this)
  }
  [string] ToString() {
    $str = "[LogSession]@{0}" -f (ConvertTo-Json(@{
          Id     = [string]$this.Id
          Logdir = [string]$this.Logdir
        }
      )
    )
    return [string]::Join('', $str.Replace(':', '=').Split("`n").Trim()).Replace(',"', '; "')
  }
}

[Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSAvoidInvokingEmptyMembers', '')]
class Logger : PsModuleBase, IDisposable {
  [ValidateNotNull()][LogLevel] $MinLevel
  [ValidateNotNull()][Logsession] $Session = @{}
  static [AllowNull()][Logger] $Default
  Logger() {
    [void][Logger]::From([Logsession]::GetDataPath("Logs"), 0, [ref]$this)
  }
  Logger([LogLevel]$MinLevel) {
    [void][Logger]::From([Logsession]::GetDataPath("Logs"), $MinLevel, [ref]$this)
  }
  Logger([string]$Logdirectory) {
    [void][Logger]::From($Logdirectory, 0, [ref]$this)
  }
  Logger([string]$Logdirectory, [LogLevel]$MinLevel) {
    [void][Logger]::From($Logdirectory, $MinLevel, [ref]$this)
  }
  static [Logger] Create() { return [Logger]::new() }
  static [Logger] Create([LogLevel]$MinLevel) { return [Logger]::new($MinLevel) }
  static [Logger] Create([string]$Logdirectory) { return [Logger]::new($Logdirectory) }
  static [Logger] Create([string]$Logdirectory, [LogLevel]$MinLevel) { return [Logger]::new($Logdirectory, $MinLevel) }

  # Main factory method
  static hidden [Logger] From([string]$Logdirectory, [LogLevel]$MinLevel, [ref]$o) {
    if ($null -eq $o) { throw [ArgumentException]::new("Empty PsReference for Logger object") };
    if ([string]::IsNullOrWhiteSpace($Logdirectory)) { throw [ArgumentnullException]::new("Logdirectory") }
    $o.Value.PsObject.Properties.Add([PSScriptProperty]::new('Logdir', { return $this.Session.Logdir }, { param($value) $this.Session.set_logdir($value) }))
    $o.Value.PsObject.Properties.Add([PSScriptProperty]::new('LogFiles', { return $this.Session.LogFiles }, { throw [SetValueException]::new("LogFiles is a ReadOnly Property") }))
    $o.Value.PsObject.Properties.Add([PSScriptProperty]::new('LogType', { return $this.Session.LogType }, { param($value) $this.Session.LogType = $value }))
    $o.Value.Logdir = $Logdirectory
    $o.Value.MinLevel = $MinLevel
    $o.Value.Session.Save()
    return $o.Value
  }
  static [Logsession[]] Getallsessions() {
    $f = [DirectoryInfo]::new([Logsession]::GetDataPath("config")).GetFiles("*-config.json")
    $i = @(); if ($f.Count -gt 0) {
      $f.ForEach({ $i += ConvertFrom-Json([File]::ReadAllText($_)) })
    }
    return $i
  }
  [FileAppender] GetFileAppender() {
    return $this.Session.GetAppenders('File', 1).ToArray()[0]
  }
  [ConsoleAppender] GetConsoleAppender() {
    return $this.Session.GetAppenders('CONSOLE', 1).ToArray()[0]
  }
  [XMLAppender] GetXMLAppender() {
    return $this.Session.GetAppenders("XML", 1).ToArray()[0]
  }
  [JsonAppender] GetJsonAppender() {
    return $this.Session.GetAppenders("JSON", 1).ToArray()[0]
  }
  [void] AddLogAppender() {
    $this.AddLogAppender([ConsoleAppender]::new())
  }
  [void] AddLogAppender([LogAppender]$LogAppender) {
    if ($this.Session._logAppenders.Count -gt 0) {
      if ($this.Session._logAppenders._name.Contains($LogAppender._name)) {
        Write-Verbose -Message "Skipped invalid Operation: $LogAppender was already added"
        return
      }
    }
    [LogAppender[]]$a = $this.Session._logAppenders.ToArray() + $LogAppender
    $this.Session._logAppenders = [LogAppenders]::new($a)
  }
  [ArrayList] ListFileAppenders() {
    return $this.Session.ListFileAppenders()
  }
  [LogEntry] CreateLogEntry([LogLevel]$severity, [string]$message) {
    return $this.CreateLogEntry($severity, $message, $null)
  }
  [LogEntry] CreateLogEntry([LogLevel]$severity, [string]$message, [Exception]$exception) {
    if ($null -ne ($this.LogType | Get-Member -MemberType Method -Static -Name Create)) {
      return $this.LogType::Create($severity, $message, $exception)
    }
    return $this.LogType::New($severity, $message, $exception)
  }
  [LogEntries] ReadEntries([string]$type) {
    return $this.ReadEntries(@{ type = $type })
  }
  [LogEntries] ReadEntries([FileInfo]$file) {
    return $this.ReadEntries(@{type = $file.Extension.Substring(1).ToUpper() }, $file)
  }
  [LogEntries] ReadEntries([FileAppender]$appender) {
    return $this.ReadEntries($appender.Type)
  }
  [LogEntries] ReadEntries([hashtable]$options) {
    $t = $options["type"]; [ValidateNotNullOrWhiteSpace()][string]$t = $t
    return $this.ReadEntries([LogAppenderType]$t)
    # or
    # if ($this.LogFiles.Count -gt 0) {
    # return $this.LogFiles.Where({ $_.Extension -eq ".$t" }).ForEach({ $this."$('Read' + $t + 'Entries')"($_) })
    # }
  }
  [LogEntries] ReadEntries([hashtable]$options, [FileInfo]$file) {
    $t = $options["type"]; [ValidateNotNullOrWhiteSpace()][string]$t = $t
    return $this.ReadEntries([LogAppenderType]$t, $file)
  }
  [LogEntries] ReadEntries([LogAppenderType]$type) {
    return $this.ReadEntries($type, $false)
  }
  [LogEntries] ReadEntries([LogAppenderType]$type, [bool]$throwonError) {
    $a = $this."$('Get' + $Type + 'Appender')"()
    if ($null -ne $a) { return $a.ReadEntries() }
    if ($throwonError) { throw "no $Type entries were found" }
    return @{}
  }
  [LogEntries] ReadEntries([LogAppenderType]$type, [FileInfo]$file) {
    $n = $type.ToString() + 'Appender'; $a = $this."$('Get' + $n)"()
    return $a ? ([type]$n)::ReadEntries($a.FilePath) : @{}
  }
  [void] ClearLogdir() {
    $files = $this.Logdir.EnumerateFiles()
    $files ? $files.ForEach({ Remove-Item $_.FullName -Force }) : $null
  }
  [void] Log([LogEntry]$entry) {
    if ($this.Session._logAppenders.Count -lt 1) { $this.AddLogAppender([ConsoleAppender]::new()) }
    foreach ($appender in $this.Session._logAppenders) {
      try {
        $appender.Log($entry)
      } catch {
        throw $_.Exception
      }
    }
  }
  [void] Log([LogLevel]$severity, [string]$message) {
    $this.Log($severity, $message, $null)
  }
  [void] Log([LogLevel]$severity, [string]$message, [Exception]$exception) {
    if ($this.should_log($severity)) {
      $this.Log($this.CreateLogEntry($severity, $message, $exception))
      return
    }
    Write-Debug -Message "[Logger] loglevel '$severity' is Skipped. Message : $message"
  }
  hidden [bool] should_log([LogLevel]$level) {
    return $level -ge $this.MinLevel
  }
  hidden [void] set_default() {
    [Logger]::Default = ([ref]$this).Value
  }
  [void] Dispose() {
    if ($this.IsDisposed) { throw [ObjectDisposedException]::new($this.GetType().Name, "Object is already disposed!") }
    [void][GC]::SuppressFinalize($this); $this.Session.Dispose(); [Logger]::Default = $null
    $this.PsObject.Properties.Add([PSScriptProperty]::new('IsDisposed', { return $true }, { throw [SetValueException]::new("Its a ReadOnly Property") }))
  }
  # --- Convenience Methods ---
  [void] LogInfoLine([string]$message) { $this.Log([LogLevel]::INFO, $message) }
  [void] LogDebugLine([string]$message) { $this.Log([LogLevel]::DEBUG, $message) }

  [void] LogWarnLine([string]$message) { $this.Log([LogLevel]::WARN, $message) }

  [void] LogErrorLine([string]$message) { $this.LogErrorLine($message, $null) }
  [void] LogErrorLine([string]$message, [Exception]$exception) { $this.Log([LogLevel]::ERROR, $message, $exception) }

  [void] LogFatalLine([string]$message) { $this.LogFatalLine($message, $null) }
  [void] LogFatalLine([string]$message, [Exception]$exception = $null) { $this.Log([LogLevel]::FATAL, $message, $exception) }

  [hashtable] ToHashtable() {
    return @{
      Session    = $this.Session.ToHashtable()
      Logdir     = [string]$this.Session.Logdir
      LogType    = [string]$this.Session.LogType
      MinLevel   = [string]$this.MinLevel
      LogFiles   = [string[]]$this.Session.LogFiles
      Appenders  = [string[]]($this.Session.LogAppenders ? ($this.Session.LogAppenders.Type) : @())
      IsDisposed = [bool]$this.IsDisposed
    }
  }
  [string] ToJson() {
    return $this.ToHashtable() | ConvertTo-Json -Depth 3
  }
  [string] ToString() {
    $str = "[Logger]@{0}" -f (ConvertTo-Json(@{
          MinLevel = [string]$this.MinLevel
          Logdir   = [string]$this.Logdir
        }
      )
    )
    return [string]::Join('', $str.Replace(':', '=').Split("`n").Trim()).Replace(',"', '; "')
  }
}

# A logger that does nothing. Useful as a default or for disabling logging.
class NullLogger : Logger {
  [LogLevel]$MinLevel = [LogLevel]::FATAL + 1 # Set above highest level to disable all
  hidden static [NullLogger]$Instance = [NullLogger]::new()
  NullLogger() {}
  [void] Log([LogLevel]$severity, [string]$message, [Exception]$exception = $null) { } # No-op
  [void] LogDebugLine([string]$message) { }
  [void] LogInfoLine([string]$message) { }
  [void] LogWarnLine([string]$message) { }
  [void] LogErrorLine([string]$message, [Exception]$exception) { }
  [void] LogFatalLine([string]$message, [Exception]$exception) { }
  [bool] IsEnabled([LogLevel]$level) { return $false }
}

$typestoExport = @(
  [Logger], [LogEntry], [LogAppender], [LogLevel], [ConsoleAppender], [LogAppenders], [Logsession],
  [JsonAppender], [LogFiles], [LogEntries], [XMLAppender], [LogAppenderType], [FileAppender], [NullLogger]
)
# Register Type Accelerators
$TypeAcceleratorsClass = [PsObject].Assembly.GetType('System.Management.Automation.TypeAccelerators')
foreach ($Type in $typestoExport) {
  if ($Type.FullName -in $TypeAcceleratorsClass::Get.Keys) {
    $Message = @(
      "Unable to register type accelerator '$($Type.FullName)'"
      'Accelerator already exists.'
    ) -join ' - '
    "TypeAcceleratorAlreadyExists $Message" | Write-Debug
  }
}
# Add type accelerators for every exportable type.
foreach ($Type in $typestoExport) {
  $TypeAcceleratorsClass::Add($Type.FullName, $Type)
}
# Remove type accelerators when the module is removed.
$MyInvocation.MyCommand.ScriptBlock.Module.OnRemove = {
  foreach ($Type in $typestoExport) {
    $TypeAcceleratorsClass::Remove($Type.FullName)
  }
}.GetNewClosure();

$scripts = @();
$Public = Get-ChildItem "$PSScriptRoot/Public" -Filter "*.ps1" -Recurse -ErrorAction SilentlyContinue
$scripts += Get-ChildItem "$PSScriptRoot/Private" -Filter "*.ps1" -Recurse -ErrorAction SilentlyContinue
$scripts += $Public

foreach ($file in $scripts) {
  Try {
    if ([string]::IsNullOrWhiteSpace($file.fullname)) { continue }
    . "$($file.fullname)"
  } Catch {
    Write-Warning "Failed to import function $($file.BaseName): $_"
    $host.UI.LogErrorLineLine($_)
  }
}

$Param = @{
  Function = $Public.BaseName
  Cmdlet   = '*'
  Alias    = '*'
  Verbose  = $false
}
Export-ModuleMember @Param