Utilities/Logging.ps1

# ================================
# Module: GinShell.Logging
# ================================

# Global defaults (can be overridden from calling script/profile)
$global:GsDefaultLogLevel = 'verbose'
$global:GsEnableFileLogging = $true
$global:GsEnableLokiLogging = $false
$global:GsLogDirectory = "$env:ProgramData\Ginesys\CloudAdmin\GinShell\"
$global:GsLokiUri = "http://monitoring.ginesys.cloud:3100"
$global:GsLokiLabels = $null  # Default to null, will be set later

# ----------------------------------------
# Helper: Convert log type to numeric level
# ----------------------------------------
function script:Get-LogLevelValue {
    param ([string]$Level)
    switch ($Level.ToLowerInvariant()) {
        'verbose' { return 0 }
        'debug' { return 1 }
        'action' { return 2 }
        'success' { return 3 }
        'info' { return 4 }
        'warning' { return 5 }
        'error' { return 6 }
        'critical' { return 7 }
        default { return 0 }
    }
}

# ----------------------------------------
# Helper: Color for console log type
# ----------------------------------------
function script:Get-LogColor {
    param ([string]$Type)
    switch ($Type.ToLowerInvariant()) {
        'verbose' { return 'DarkGray' }
        'debug' { return 'Gray' }
        'action' { return 'Cyan' }
        'success' { return 'Green' }
        'info' { return 'White' }
        'warning' { return 'Yellow' }
        'error' { return 'Red' }
        'critical' { return 'Magenta' }
        default { return 'White' }
    }
}

# ----------------------------------------
# Core: Write-Log
# ----------------------------------------
function Write-Log {
    [CmdletBinding()]
    param (
        [Parameter(Mandatory = $true)]
        [string]$Message,

        [Parameter()]
        [ValidateSet('verbose', 'debug', 'action', 'success', 'info', 'warning', 'error', 'critical')]
        [string]$Type = 'Info',

        [Parameter()]
        [ValidateSet('verbose', 'debug', 'action', 'success', 'info', 'warning', 'error', 'critical')]
        [string]$LogLevel = $global:GsDefaultLogLevel,

        [Parameter()]
        [string]$LogFile,

        [Parameter()]
        [bool]$EnableFileLogging = $global:GsEnableFileLogging,

        [Parameter()]
        [bool]$EnableLokiLogging = $global:GsEnableLokiLogging
    )

    $threshold = Get-LogLevelValue -Level $LogLevel
    $current = Get-LogLevelValue -Level $Type
    if ($current -lt $threshold) { return }

    $timestamp = Get-Date -Format "yyyy-MM-dd HH:mm:ss"
    $scriptName = $MyInvocation.PSCommandPath
    if (-not $scriptName) { $scriptName = "<interactive>" }

    $logMessage = "$timestamp [$Type] [$scriptName] - $Message"
    $color = Get-LogColor -Type $Type

    Write-Host $logMessage -ForegroundColor $color

    # --- File Logging ---
    if ($EnableFileLogging) {
        if (-not $LogFile) {
            $baseName = if ($MyInvocation.PSCommandPath) {
                [System.IO.Path]::GetFileNameWithoutExtension($MyInvocation.PSCommandPath)
            }
            else {
                "interactive"
            }
            $LogFile = Join-Path -Path $global:GsLogDirectory -ChildPath "$(Get-Date -Format 'yyyy_MM_dd')__$baseName.log"
        }
        else {
            $baseName = [System.IO.Path]::GetFileNameWithoutExtension($LogFile)
        }

        $logDir = Split-Path -Path $LogFile -Parent
        if (-not (Test-Path -Path $logDir)) {
            New-Item -Path $logDir -ItemType Directory -Force | Out-Null
        }
        Add-Content -Path $LogFile -Value $logMessage
    }

    # --- Loki Logging ---
    if ($EnableLokiLogging) {

        if ($global:GsLokiLabels -is [hashtable]) {
            Write-LokiLog -Message $Message -Type $Type 
        }
        else {
            # If no global labels are set, use default labels
            Write-LokiLog -Message $Message -Type $Type -Labels @{
                app                = if ($MyInvocation.ScriptName) {
                    [System.IO.Path]::GetFileNameWithoutExtension($MyInvocation.ScriptName)
                }
                else {
                    'Interactive_PowerShell'
                } 
                hostname           = $env:COMPUTERNAME
                script_path        = if ($MyInvocation.ScriptName) { $MyInvocation.ScriptName } else { 'Interactive_PowerShell' }
                run_as             = $env:USERNAME
                powershell_version = $PSVersionTable.PSVersion.ToString()
            } 
        }
    }
}

# ----------------------------------------
# Core: Write-GsLokiLog
# ----------------------------------------
function Write-LokiLog {
    [CmdletBinding()]
    param (
        [Parameter(Mandatory = $true)]
        [string]$Message,

        [Parameter()]
        [ValidateSet('verbose', 'debug', 'action', 'success', 'info', 'warning', 'error', 'critical')]
        [string]$Type = 'info',

        [Parameter()]
        [hashtable]$Labels,

        [Parameter()]
        [string]$LokiUri
    )

    $resolvedUri = $LokiUri
    if ([string]::IsNullOrWhiteSpace($resolvedUri)) { $resolvedUri = $global:GsLokiUri }
    if ([string]::IsNullOrWhiteSpace($resolvedUri)) { $resolvedUri = $env:GS_LOKI_URI }
    if ([string]::IsNullOrWhiteSpace($resolvedUri)) { Write-Error "The loki endpoints is not set yet, Hence unable to send log to loki endpoint" ; return }

    $finalLabels = @{}

    if ($global:GsLokiLabels -is [hashtable]) {
        $global:GsLokiLabels.GetEnumerator() | ForEach-Object { $finalLabels[$_.Key] = $_.Value }
    }


    if (-not [string]::IsNullOrWhiteSpace($env:GS_LOKI_LABELS)) {
        try {
            $envLabels = $env:GS_LOKI_LABELS | ConvertFrom-Json -AsHashtable
            $envLabels.GetEnumerator() | ForEach-Object { $finalLabels[$_.Key] = $_.Value }
        }
        catch {
            Write-Warning "Invalid JSON in GS_LOKI_LABELS environment variable: $($env:GS_LOKI_LABELS)"
        }
    }

    if ($Labels -is [hashtable] ) {
        $Labels.GetEnumerator() | ForEach-Object { $finalLabels[$_.Key] = $_.Value }
    }

    if (-not $finalLabels.ContainsKey('type')) { $finalLabels['type'] = $Type }
    if (-not $finalLabels.ContainsKey('hostname')) { $finalLabels['hostname'] = $env:COMPUTERNAME }
    if (-not $finalLabels.ContainsKey('script_path')) { $finalLabels['script'] = if ($MyInvocation.ScriptName) { $MyInvocation.ScriptName }else { 'Interactive_powershell' } }
    if (-not $finalLabels.ContainsKey('run_as')) { $finalLabels['run_as'] = $env:USERNAME }
    if ( -not $finalLabels.ContainsKey('powershell_version')) { $finalLabels['powershell_version'] = $PSVersionTable.PSVersion.ToString() }
    
    # If 'app' is not already set, derive from script file name or fallback
    if (-not $finalLabels.ContainsKey('app')) {
        if ($MyInvocation.ScriptName) {
            # Extract only the file name from the full path
            $finalLabels['app'] = [System.IO.Path]::GetFileNameWithoutExtension($MyInvocation.ScriptName)
        }
        else {
            $finalLabels['app'] = 'Interactive_PowerShell'
        }
    }

    
    # Getting Time Stamp in Nanoseconds
    # This is compatible with both PowerShell 6+ and Windows PowerShell 5.
    $timestampNs = if ($PSVersionTable.PSVersion.Major -ge 6) {
        # PowerShell 6+ method: More modern and concise.
        # It uses [datetimeoffset]::UnixEpoch which is guaranteed to be available.
        [bigint](([datetimeoffset]::UtcNow) - ([datetimeoffset]::UnixEpoch)).Ticks * 100
    }
    else {
        # Windows PowerShell 5.1 and older method: Maximizes compatibility.
        # Manually define the epoch because [datetimeoffset]::UnixEpoch may be missing.
        $unixEpoch = New-Object DateTime(1970, 1, 1, 0, 0, 0, [System.DateTimeKind]::Utc)
    
        # Get current UTC time and calculate the TimeSpan since epoch.
        $timeSpan = (Get-Date).ToUniversalTime() - $unixEpoch
    
        # Calculate nanoseconds and format as a string to prevent scientific notation.
        ($timeSpan.Ticks * 100).ToString('F0')
    }


    # Ensure 'values' is an array of arrays
    $payload = @{
        streams = @(
            @{
                stream = $finalLabels
                values = @(
                    , @([string]$timestampNs, $Message)  # ← Note the comma to force nested array
                )
            }
        )
    }

    $jsonPayload = $payload | ConvertTo-Json -Depth 5 -Compress
    $pushUrl = "$($resolvedUri.TrimEnd('/'))/loki/api/v1/push"

    try {
        Invoke-RestMethod -Uri $pushUrl -Method Post -Body $jsonPayload -ContentType 'application/json' -ErrorAction Stop 
        Write-Verbose "Loki log sent to $pushUrl"
    }
    catch {
        Write-Error "Loki log failed: $($_.Exception.Message)"
        Write-Debug "Payload: $jsonPayload"
    }
}

# ----------------------------------------
# Export Module Members
# ----------------------------------------
Export-ModuleMember -Function Write-Log, Write-LokiLog