JyskIT.Automation.psm1

#Region Module Variables
$script:ModuleRoot = $PSScriptRoot
$script:Config = $null
$script:CurrentGraphToken = $null
$script:PartnerCredentials = $null

# Token cache configuration
$script:TokenCache = @{}
$script:TokenCacheConfig = @{
    MaxSize = 100  # Maximum number of tokens to cache
    RefreshBuffer = 5  # Minutes before expiry to trigger refresh
    # PersistencePath should be in a writable location even if the module is installed in Program Files, such as local app data
    PersistencePath = [System.IO.Path]::Combine([System.Environment]::GetFolderPath([System.Environment+SpecialFolder]::LocalApplicationData), 'JyskIT.Automation\tokencache.xml')
}

. (Join-Path $PSScriptRoot 'Config\Baseline\PolicyTypeSettings.ps1')

# Module default configuration
$script:DefaultConfig = @{
    CuranetBaseURL = 'https://reseller.curanet.dk'
    SpamfilterAPIBaseURL = 'https://spamfilter.io/api'
    KeyVaultName = 'KV-M365-SAM'
    SubscriptionName = 'internal-002-mpn'
    PartnerTenantId = 'b6a41db1-6b1a-4833-9b69-f8e363090e45'
    GDAP = Get-Content (Join-Path $PSScriptRoot 'Config\GDAP\default.json') | ConvertFrom-Json -AsHashtable
    PolicyTypeSettings = $PolicyTypeSettings
}

#Region Custom Exceptions
class TokenOperationException : Exception {
    [string]$Operation
    [string]$DetailedMessage
    
    TokenOperationException([string]$operation, [string]$message, [Exception]$innerException) 
        : base($message, $innerException) {
        $this.Operation = $operation
        $this.DetailedMessage = $message
    }
}

#Region Helper Functions
$script:LogConfig = @{
    LogPath = [System.IO.Path]::Combine([System.Environment]::GetFolderPath([System.Environment+SpecialFolder]::LocalApplicationData), 'JyskIT.Automation\logs', "JyskIT.Automation-$(Get-Date -Format 'dd-MM-yyyy').log")
    MaxLogAgeDays = 30
    LogLevel = 'Verbose'  # Default log level: Error, Warning, Info, Debug, Verbose
    WriteToFile = $true
    WriteToHost = $true
}

function Write-ModuleLog {
    [CmdletBinding()]
    param(
        [Parameter(Mandatory)]
        [string]$Message,
        
        [Parameter()]
        [ValidateSet('Error', 'Warning', 'Info', 'Debug', 'Verbose')]
        [string]$Level = 'Info',
        
        [Parameter()]
        [System.Management.Automation.ErrorRecord]$ErrorRecord,
        
        [Parameter()]
        [string]$Component = 'General',
        
        [Parameter()]
        [string]$FunctionName = $MyInvocation.MyCommand.Name,
        
        [Parameter()]
        [switch]$NoOutput,

        [Parameter()]
        [switch]$ThrowError,

        [Parameter()]
        [string]$ErrorOperation,

        [Parameter()]
        [string]$ErrorMessage
    )
    
    # Define log level weights for filtering
    $levelWeights = @{
        Error = 1
        Warning = 2
        Info = 3
        Debug = 4
        Verbose = 5
    }
    
    # Check if we should process this log level
    if ($levelWeights[$Level] -gt $levelWeights[$script:LogConfig.LogLevel]) {
        return
    }
    
    try {
        $timestamp = Get-Date -Format 'dd-MM-yyyy HH:mm:ss'
        $callStack = Get-PSCallStack | Select-Object -Skip 1 -First 1
        $caller = if ($callStack) { $callStack.Command } else { 'Unknown' }
        
        # Build the log entry
        $logEntry = [PSCustomObject]@{
            Timestamp = $timestamp
            Level = $Level.ToUpper()
            Component = $Component
            Function = $FunctionName
            Caller = $caller
            Message = $Message
            ErrorDetails = if ($ErrorRecord) {
                @{
                    Exception = $ErrorRecord.Exception.Message
                    Category = $ErrorRecord.CategoryInfo.Category
                    TargetObject = $ErrorRecord.TargetObject
                    ScriptStackTrace = $ErrorRecord.ScriptStackTrace
                    PositionMessage = $ErrorRecord.InvocationInfo.PositionMessage
                } | ConvertTo-Json -Compress
            } else { $null }
        }
        
        # Format the log message for output
        $consoleMessage = "$($logEntry.Timestamp) [$($logEntry.Level)] [$($logEntry.Component)] $($logEntry.Message)"
        if ($ErrorRecord) {
            $consoleMessage += "`nError: $($ErrorRecord.Exception.Message)"
            $consoleMessage += "`nStack: $($ErrorRecord.ScriptStackTrace)"
        }
        
        # Write to console if enabled
        if ($script:LogConfig.WriteToHost -and -not $NoOutput) {
            switch ($Level) {
                'Error' { 
                    Write-Error $consoleMessage 
                }
                'Warning' { 
                    Write-Warning $consoleMessage 
                }
                'Info' { 
                    Write-Information $consoleMessage -InformationAction Continue 
                }
                'Debug' { 
                    Write-Debug $consoleMessage 
                }
                'Verbose' { 
                    Write-Verbose $consoleMessage 
                }
            }
        }
        
        # Write to file if enabled
        if ($script:LogConfig.WriteToFile) {
            # Ensure log directory exists
            $logDir = Split-Path $script:LogConfig.LogPath -Parent
            if (-not (Test-Path $logDir)) {
                New-Item -Path $logDir -ItemType Directory -Force | Out-Null
            }
            
            # Convert log entry to JSON and append to file
            $logEntry | ConvertTo-Json -Compress | 
                Add-Content -Path $script:LogConfig.LogPath -Encoding UTF8
        }

        # Throw error if requested
        if ($ThrowError) {
            if ($ErrorRecord) {
                throw [TokenOperationException]::new(
                    $ErrorOperation,
                    $ErrorMessage ?? $Message,
                    $ErrorRecord.Exception
                )
            } else {
                throw [TokenOperationException]::new(
                    $ErrorOperation,
                    $ErrorMessage ?? $Message,
                    $null
                )
            }
        }
    }
    catch {
        # Fallback error handling - write directly to console
        $fallbackMessage = "Failed to write to log: $($_.Exception.Message)"
        Write-Error $fallbackMessage
        
        if ($ThrowError) {
            throw
        }
    }
}

function Initialize-ModuleLogging {
    [CmdletBinding()]
    param()
    
    try {
        # Create logs directory if it doesn't exist
        $logDir = Split-Path $script:LogConfig.LogPath -Parent
        if (-not (Test-Path $logDir)) {
            New-Item -Path $logDir -ItemType Directory -Force | Out-Null
        }
        
        # Clean up old log files
        Get-ChildItem -Path $logDir -Filter "JyskIT.Automation-*.log" | 
            Where-Object { $_.LastWriteTime -lt (Get-Date).AddDays(-$script:LogConfig.MaxLogAgeDays) } | 
            ForEach-Object {
                Write-ModuleLog -Message "Removing old log file: $($_.Name)" -Level Debug -Component 'LogMaintenance'
                Remove-Item $_.FullName -Force
            }
        
        # Initialize new log file with header
        $moduleInfo = Get-Module JyskIT.Automation
        @"
# JyskIT.Automation Module Log
# Module Version: $($moduleInfo.Version)
# Log Started: $(Get-Date -Format 'yyyy-MM-dd HH:mm:ss')
# PowerShell Version: $($PSVersionTable.PSVersion)
# OS: $([System.Environment]::OSVersion.VersionString)
"@
 | Out-File -FilePath $script:LogConfig.LogPath -Encoding UTF8
        
        Write-ModuleLog -Message "Module logging initialized" -Level Verbose -Component 'LogInitialization'
    }
    catch {
        Write-Error "Failed to initialize logging: $_"
    }
}

# Helper function to get log entries
function Get-ModuleLog {
    [CmdletBinding()]
    param(
        [Parameter()]
        [DateTime]$StartTime,
        
        [Parameter()]
        [DateTime]$EndTime,
        
        [Parameter()]
        [ValidateSet('Error', 'Warning', 'Info', 'Debug', 'Verbose')]
        [string[]]$Level,
        
        [Parameter()]
        [string]$Component,
        
        [Parameter()]
        [switch]$Raw
    )
    
    try {
        $logs = Get-Content -Path $script:LogConfig.LogPath | 
            Where-Object { $_ -match '^{' } | 
            ForEach-Object { $_ | ConvertFrom-Json }
        
        # Apply filters
        if ($StartTime) {
            $logs = $logs | Where-Object { [DateTime]$_.Timestamp -ge $StartTime }
        }
        if ($EndTime) {
            $logs = $logs | Where-Object { [DateTime]$_.Timestamp -le $EndTime }
        }
        if ($Level) {
            $logs = $logs | Where-Object { $_.Level -in $Level }
        }
        if ($Component) {
            $logs = $logs | Where-Object { $_.Component -eq $Component }
        }
        
        if ($Raw) {
            return $logs
        }
        else {
            return $logs | Format-Table -Property Timestamp, Level, Component, Message -AutoSize
        }
    }
    catch {
        Write-Error "Failed to retrieve logs: $_"
    }
}

function Initialize-ModuleDependencies {
    [CmdletBinding()]
    param()
    
    $requiredModules = @(
        @{Name = 'Microsoft.Graph'; MinimumVersion = '2.24.0'},
        @{Name = 'Az.Accounts'; MinimumVersion = '3.0.5'},
        @{Name = 'Az.KeyVault'; MinimumVersion = '6.2.0'},
        @{Name = 'ExchangeOnlineManagement'; MinimumVersion = '3.6.0'},
        @{Name = 'Microsoft.PowerShell.ConsoleGuiTools'; MinimumVersion = '0.7.7'}
    )
    
    foreach ($module in $requiredModules) {
        $installed = Get-Module -ListAvailable -Name $module.Name | 
            Where-Object { $_.Version -ge [version]$module.MinimumVersion }
            
        if (-not $installed) {
            try {
                Write-ModuleLog -Message "Installing $($module.Name) module (minimum version $($module.MinimumVersion))..." -Level Info -Component 'ModuleInitialization'
                Write-ModuleLog -Message "This might take a while, please be patient" -Level Info -Component 'ModuleInitialization'
                Install-Module -Name $module.Name -MinimumVersion $module.MinimumVersion -Force -AllowClobber -Scope CurrentUser
                Write-ModuleLog -Message "$($module.Name) module installed successfully" -Level Info -Component 'ModuleInitialization'
            }
            catch {
                Write-ModuleLog -Message "Failed to install $($module.Name) module: $_" -Level Error -Component 'ModuleInitialization'
                throw
            }
        }
    }
}

#Region Module Initialization

try {
    Initialize-ModuleLogging
}
catch {
    Write-Error "Failed to initialize module logging: $_"
    throw
}

# Initialize required modules
try {
    Initialize-ModuleDependencies
}
catch {
    Write-Error "Failed to initialize module dependencies: $_"
    throw
}

# Create necessary directories
$directories = @(
    (Join-Path $PSScriptRoot 'cache'),
    (Join-Path $PSScriptRoot 'logs')
)

foreach ($dir in $directories) {
    if (!(Test-Path $dir)) {
        New-Item -Path $dir -ItemType Directory -Force | Out-Null
    }
}

# Load config if exists
$configPath = Join-Path $script:ModuleRoot 'Config\config.json'
if (Test-Path $configPath) {
    try {
        $script:Config = Get-Content $configPath | ConvertFrom-Json -AsHashtable
        Write-Verbose "Loaded configuration from $configPath"
    }
    catch {
        Write-Warning "Failed to load configuration from $configPath, using defaults"
        $script:Config = $script:DefaultConfig
    }
}
else {
    $script:Config = $script:DefaultConfig
    Write-Verbose "No configuration file found, using defaults"
}

# Import functions
$Public = @(Get-ChildItem -Path $PSScriptRoot\Public\*.ps1 -Recurse -ErrorAction SilentlyContinue)
$Private = @(Get-ChildItem -Path $PSScriptRoot\Private\*.ps1 -Recurse -ErrorAction SilentlyContinue)

foreach ($import in @($Private + $Public)) {
    try {
        Write-Verbose "Importing $($import.FullName)"
        . $import.FullName
    }
    catch {
        Write-Error -Message "Failed to import function $($import.FullName): $_"
    }
}

# Initialize token cache
try {
    Write-Verbose "Initializing token cache"
    Initialize-TokenCache
}
catch {
    Write-Warning "Failed to initialize token cache: $_"
}

# Clean up on module removal
$MyInvocation.MyCommand.ScriptBlock.Module.OnRemove = {
    Write-Verbose "Cleaning up module resources..."
    try {
        Disconnect-MgGraph -ErrorAction SilentlyContinue
        Save-TokenCache
    }
    catch {
        Write-Warning "Cleanup error: $_"
    }
}

# Export public functions
Export-ModuleMember -Function $Public.BaseName -Alias *