Private/Convert-Timestamp.ps1

# Requires -Version 5.1

# --- Precompile regex for log parsing performance ---
$timestampPatterns = @(
    '\d{4}-\d{2}-\d{2}[ T]\d{2}:\d{2}:\d{2}(?:\.\d{1,7})?(?:Z|[+-]\d{2}:?\d{2})?', # ISO with optional fractional seconds and offset/Z
    '[A-Za-z]{3}\s+\d{1,2}\s+\d{2}:\d{2}:\d{2}',                                  # Syslog (e.g., Jul 14 13:45:30 or Jul 3 04:01:02)
    '\d{2}/\d{2}/\d{4}\s+\d{2}:\d{2}:\d{2}(?:\.\d{1,7})?(?:\s*[AP]M)?',           # MM/DD/YYYY HH:MM:SS with optional fractional seconds and AM/PM
    '\d{4}/\d{2}/\d{2}\s+\d{2}:\d{2}:\d{2}(?:\.\d{1,7})?(?:\s*[AP]M)?',           # YYYY/MM/DD HH:MM:SS with optional fractional seconds and AM/PM
    '\d{2}/\d{2}/\d{4}',                                                          # MM/DD/YYYY (date only)
    '\d{4}/\d{2}/\d{2}'                                                           # YYYY/MM/DD (date only)
)

# Standard pattern: timestamp at beginning
$Script:LogLineRegex = [regex]::new(
    '^(?<Timestamp>(' + ($timestampPatterns -join '|') + '))' +
    '\s*(?:\[(?<Level>[A-Z]+)\])?\s*(?<Message>.*)$', # Added \s* before and after level for more flexibility
    [System.Text.RegularExpressions.RegexOptions]::Compiled
)

# Fallback pattern: timestamp anywhere in line
$Script:LogFallbackRegex = [regex]::new(
    '(?<Timestamp>(' + ($timestampPatterns -join '|') + ')).*?(?:\[(?<Level>[A-Z]+)\])?\s*(?<Message>.*)', # Also added \s* around level
    [System.Text.RegularExpressions.RegexOptions]::Compiled
)

# --- Helper: Try known formats ---
function Invoke-TryParseExactFormats {
    param (
        [string]$TimestampText,
        [System.Globalization.CultureInfo]$Culture
    )

    # Trim the timestamp text once at the beginning of the helper function for cleanliness
    $TimestampText = $TimestampText.Trim()

    $formats = @(
        # ISO 8601 & Roundtrip formats
        @{ Format = "o"; Style = [System.Globalization.DateTimeStyles]::RoundtripKind },
        @{ Format = "yyyy-MM-ddTHH:mm:ss.ffffffZ"; Style = [System.Globalization.DateTimeStyles]::RoundtripKind },
        @{ Format = "yyyy-MM-ddTHH:mm:ss.fffZ"; Style = [System.Globalization.DateTimeStyles]::RoundtripKind },
        @{ Format = "yyyy-MM-ddTHH:mm:ssZ"; Style = [System.Globalization.DateTimeStyles]::RoundtripKind }, # For Z suffix without fractional seconds

        # ISO 8601 without Z or offset, assuming Universal (or local then converting to Universal)
        @{ Format = "yyyy-MM-ddTHH:mm:ss.ffffff"; Style = [System.Globalization.DateTimeStyles]::AssumeUniversal },
        @{ Format = "yyyy-MM-ddTHH:mm:ss.fff"; Style = [System.Globalization.DateTimeStyles]::AssumeUniversal },
        @{ Format = "yyyy-MM-ddTHH:mm:ss"; Style = [System.Globalization.DateTimeStyles]::AssumeUniversal },

        # Common Date & Time formats (e.g., from logs)
        @{ Format = "yyyy-MM-dd HH:mm:ss.ffffff"; Style = [System.Globalization.DateTimeStyles]::None },
        @{ Format = "yyyy-MM-dd HH:mm:ss.fff"; Style = [System.Globalization.DateTimeStyles]::None },
        @{ Format = "yyyy-MM-dd HH:mm:ss"; Style = [System.Globalization.DateTimeStyles]::None },

        @{ Format = "MM/dd/yyyy HH:mm:ss.ffffff"; Style = [System.Globalization.DateTimeStyles]::None },
        @{ Format = "MM/dd/yyyy HH:mm:ss.fff"; Style = [System.Globalization.DateTimeStyles]::None },
        @{ Format = "MM/dd/yyyy HH:mm:ss"; Style = [System.Globalization.DateTimeStyles]::None },

        @{ Format = "dd/MM/yyyy HH:mm:ss.ffffff"; Style = [System.Globalization.DateTimeStyles]::None },
        @{ Format = "dd/MM/yyyy HH:mm:ss.fff"; Style = [System.Globalization.DateTimeStyles]::None },
        @{ Format = "dd/MM/yyyy HH:mm:ss"; Style = [System.Globalization.DateTimeStyles]::None },

        @{ Format = "MM/dd/yyyy hh:mm:ss tt"; Style = [System.Globalization.DateTimeStyles]::None }, # With AM/PM
        @{ Format = "dd/MM/yyyy hh:mm:ss tt"; Style = [System.Globalization.DateTimeStyles]::None }, # With AM/PM

        # Date-only formats
        @{ Format = "MM/dd/yyyy"; Style = [System.Globalization.DateTimeStyles]::None },
        @{ Format = "dd/MM/yyyy"; Style = [System.Globalization.DateTimeStyles]::None },
        @{ Format = "yyyy/MM/dd"; Style = [System.Globalization.DateTimeStyles]::None }
    )

    foreach ($entry in $formats) {
        $parsed = [datetime]::new()
        if ([datetime]::TryParseExact($TimestampText, $entry.Format, $Culture, $entry.Style, [ref]$parsed)) {
            try {
                # Determine DateTimeKind based on format/style
                # RoundtripKind implies the Kind is already set by parsing (Local, Utc, Unspecified)
                # For 'Z' or offset-containing formats, the Kind should be Utc or Local/Offset.
                # For others, if it's not explicitly UTC, assume it's local and convert to UTC.
                if ($entry.Style -band [System.Globalization.DateTimeStyles]::RoundtripKind -or
                    $parsed.Kind -eq [System.DateTimeKind]::Utc -or
                    $parsed.Kind -eq [System.DateTimeKind]::Local)
                {
                    # If it's already Utc or Local, or RoundtripKind handled it, just return it.
                    # The Pester test for 'Z' expecting 'Local' is unusual; standard behavior would be 'Utc'.
                    # We return 'parsed' directly, letting its Kind be whatever TryParseExact determined.
                    return $parsed
                } else {
                    # For formats with Style::None or AssumeUniversal (which are usually parsed as Unspecified),
                    # convert to Universal Time.
                    return $parsed.ToUniversalTime()
                }
            } catch {
                Write-Verbose "⚠️ Invoke-TryParseExactFormats: Unable to process or convert to UTC for '$TimestampText' with format '$($entry.Format)': $($_.Exception.Message)"
            }
        }
    }

    return $null
}

# --- Helper: Syslog style format ---
function Invoke-TryParseSyslog {
    param (
        [string]$TimestampText,
        [System.Globalization.CultureInfo]$Culture,
        [int]$CurrentYear = $(Get-Date).Year # Default to current system year
    )

    # Trim the timestamp text for syslog parsing to handle variable spacing
    $TimestampText = $TimestampText.Trim()

    # The 'd' format specifier handles both single and double digit days automatically
    # when combined with AllowWhiteSpaces, eliminating the need for a separate 'dd' format.
    $syslogFormats = @("MMM d HH:mm:ss") # This format should handle 'Jul 14 13:45:30' and 'Jul 3 04:01:02'

    foreach ($fmt in $syslogFormats) {
        $result = [datetime]::new()
        # Use AllowWhiteSpaces for flexible parsing of single/double digit days with varying spaces
        if ([datetime]::TryParseExact($TimestampText, $fmt, $Culture, [System.Globalization.DateTimeStyles]::AllowWhiteSpaces, [ref]$result)) {
            try {
                # Construct the full date by adding the year.
                $currentDate = Get-Date # Get the current date and time for comparison
                $fullDate = Get-Date -Year $CurrentYear -Month $result.Month -Day $result.Day `
                    -Hour $result.Hour -Minute $result.Minute -Second $result.Second

                # Syslog timestamps often don't include the year. If the parsed date is in the future
                # relative to the current date, it likely means the log was from the previous year
                # (e.g., a December log processed in January of the next year). Adjust the year.
                if ($fullDate -gt $currentDate) {
                    $fullDate = $fullDate.AddYears(-1)
                }

                # Syslog typically implies local time, convert to Universal Time for consistency
                return $fullDate.ToUniversalTime()
            } catch {
                Write-Verbose "⚠️ Invoke-TryParseSyslog: Failed to build full syslog DateTime for '$TimestampText': $($_.Exception.Message)"
            }
        }
    }

    return $null
}

# --- Public: Convert a string timestamp ---
function Convert-Timestamp {
    [CmdletBinding(DefaultParameterSetName = 'Default')] # Added default parameter set name
    param (
        [Parameter(ValueFromPipeline = $true, Mandatory = $true, Position = 0)] # Made mandatory and positional for pipeline
        [string]$TimestampString,

        [Parameter()]
        [System.Globalization.CultureInfo]$Culture = [System.Globalization.CultureInfo]::GetCultureInfo('en-US'),

        [Parameter()]
        [int]$TestYear # For testing syslog year override
    )

    process {
        if ([string]::IsNullOrWhiteSpace($TimestampString)) {
            Write-Verbose "Convert-Timestamp: TimestampString is null or whitespace, returning null."
            return $null
        }

        # Try exact formats first (ISO, common date/time, etc.)
        $result = Invoke-TryParseExactFormats -TimestampText $TimestampString -Culture $Culture
        if ($result) {
            Write-Verbose "Convert-Timestamp: Successfully parsed '$TimestampString' with Invoke-TryParseExactFormats."
            return $result
        }

        # Then try syslog format
        if ($PSBoundParameters.ContainsKey('TestYear')) {
            $result = Invoke-TryParseSyslog -TimestampText $TimestampString -Culture $Culture -CurrentYear $TestYear
        } else {
            $result = Invoke-TryParseSyslog -TimestampText $TimestampString -Culture $Culture
        }
        if ($result) {
            Write-Verbose "Convert-Timestamp: Successfully parsed '$TimestampString' with Invoke-TryParseSyslog."
            return $result
        }

        # Final fallback using generic TryParse for highly flexible formats
        $fallback = [datetime]::new()
        # Trim again, just in case the string somehow picked up extra spaces that weren't handled earlier
        if ([datetime]::TryParse($TimestampString.Trim(), $Culture, [System.Globalization.DateTimeStyles]::AllowWhiteSpaces, [ref]$fallback)) {
            try {
                Write-Verbose "Convert-Timestamp: Successfully parsed '$TimestampString' with generic TryParse."
                # If generic TryParse succeeds, it often defaults to local time if no offset/zone info. Convert to UTC.
                return $fallback.ToUniversalTime()
            } catch {
                Write-Verbose "⚠️ Convert-Timestamp: Failed generic fallback UTC conversion for '$TimestampString': $($_.Exception.Message)"
            }
        }

        Write-Verbose "❌ Convert-Timestamp: Failed to parse timestamp: '$TimestampString' after all attempts."
        return $null
    }
}

# --- Public: Convert log line to structured object ---
function ConvertFrom-LogEntry {
    [CmdletBinding()]
    param (
        [Parameter(Mandatory, ValueFromPipeline)]
        [string]$LogLine
    )

    process {
        try {
            $match = $Script:LogLineRegex.Match($LogLine)

            if (-not $match.Success) {
                # Try fallback regex: timestamp anywhere in the line
                $match = $Script:LogFallbackRegex.Match($LogLine)
                if (-not $match.Success) {
                    Write-Verbose "❌ ConvertFrom-LogEntry: Log line could not be matched by any regex pattern: '$LogLine'"
                    return [PSCustomObject]@{
                        Timestamp = $null
                        Level     = "UNPARSEABLE"
                        Message   = $LogLine
                        RawLine   = $LogLine
                    }
                }
            }

            # Extract matched groups
            $timestampString = $match.Groups["Timestamp"].Value.Trim()
            $level = $match.Groups["Level"].Value
            $message = $match.Groups["Message"].Value.Trim()

            # Convert the extracted timestamp string
            $parsedTimestamp = Convert-Timestamp -TimestampString $timestampString

            # Create and return the structured object
            return [PSCustomObject]@{
                Timestamp = $parsedTimestamp
                Level     = if ([string]::IsNullOrWhiteSpace($level)) { "UNKNOWN" } else { $level }
                Message   = $message
                RawLine   = $LogLine
            }
        }
        catch {
            Write-Warning "❌ ConvertFrom-LogEntry: An exception occurred while processing log line '$LogLine': $($_.Exception.Message)"
            # Return a structured object indicating an error, rather than just $null
            return [PSCustomObject]@{
                Timestamp = $null
                Level     = "ERROR"
                Message   = "Exception occurred during parsing: $($_.Exception.Message)"
                RawLine   = $LogLine
            }
        }
    }
}