Get-IntuneManagementExtensionDiagnostics.ps1

<#PSScriptInfo
 
.VERSION 3.0
 
.GUID e0307766-d5a7-4704-a578-5ff1fb315a26
 
.AUTHOR Petri.Paavola@yodamiitti.fi
 
.COMPANYNAME Yodamiitti Oy
 
.COPYRIGHT Petri.Paavola@yodamiitti.fi
 
.TAGS Intune Windows Autopilot troubleshooting log analyzer
 
.LICENSEURI
 
.PROJECTURI https://github.com/petripaavola/Get-IntuneManagementExtensionDiagnostics
 
.ICONURI
 
.EXTERNALMODULEDEPENDENCIES
 
.REQUIREDSCRIPTS
 
.EXTERNALSCRIPTDEPENDENCIES
 
.RELEASENOTES
Version 1.0: Original published version
Version 1.1: Win32App and WinGetApp Required/Available and Install/Uninstall intent is detected right
              Win32App Supersedence should be recognized (first uninstall and then install)
              Win32App failed (un)install process is detected
              Win32App Download Statistics table added
              Added export to text files
Version 2.0: Huge new feature is to create html report
              Html report is primary reporting and console observed timeline is secondary
              All future development will be done to html report
              Console timeline will be available for example for OOBE troubleshooting scenarios
              Added App detection events to timeline
              Html report entries support HoverOn ToolTips which include more information
Version 2.3: Updated script to use Microsoft.Graph.Authentication module to download data from Graph API
Version 2.4: Fix to process new log files AppWorkload.log
Version 3.0: Support for Intune Device Preparation.
              Shows Remediation and PowerShell platform script contents and script output (if available) in (hover on) ToolTip with -Online option
              Copy any ToolTip data including PowerShell scripts
              -DoNotDownloadClearTextRemediationScriptsToReport - New option not to download Remediation script in clear text
#>


<#
.Synopsis
   This script analyzes Microsoft Intune Management Extension (IME) log(s) and creates timeline report from found actions.
 
.DESCRIPTION
   This script analyzes Microsoft Intune Management Extension (IME) log(s) and creates timeline report from found log events.
    
   Report is saved to HTML file. Events are also shown in Powershell console window.
    
   Timeline report includes information about Intune Win32App, WinGetApp, Powershell scripts, Remedation scripts and custom Compliance Policy scripts events. Windows Autopilot ESP phases are also shown on timeline.
    
   Script also includes really capable Log Viewer UI if scripts is started with parameter -ShowLogViewerUI
 
   LogViewerUI (Out-GridView) looks a lot like cmtrace.exe tool but it is better because all found log actions are added to log for easier debugging.
    
   LogViewerUI has good search and filtering capabilities. Try to filter known log entries in Timeline: Add criteria -> ProcessRunTime -> is not empty.
    
   What really differentiates this LogViewer from other tools is it's capability to convert GUIDs to known names
   try parameter -ConvertAllKnownGuidsToClearText and you can see for example real application names instead of GUIDs on log events.
    
   Selecting last line (RELOAD) and OK will reload log file.
    
   Script can merge multiple log files so especially in LogViewerUI you can see Powershell command outputs from AgentExecutor.log
    
   Powershell command outputs and errors can be also shown in Timeline view with parameters -ShowStdOutInReport and -ShowErrorsInReport
   This shows instantly what is possible problem in Powershell scripts.
 
 
   Possible Microsoft 365 App and MSI Line-of-Business Apps (maybe change to Win32App ;) installations are not seen by this report because they are not installed with Intune Management Agent.
 
 
   Author:
   Petri.Paavola@yodamiitti.fi
   Senior Modern Management Principal
   Microsoft MVP - Windows and Intune
    
   2024-09-17
 
   https://github.com/petripaavola/Get-IntuneManagementExtensionDiagnostics
 
.PARAMETER Online
Download Powershell, Remediation and custom Compliance policy scripts to get displayName to Timeline report
Install Microsoft Graph module with command: Install-Module -Name Microsoft.Graph.Authentication -Scope CurrentUser
 
.PARAMETER LogFile
Specify log file fullpath
 
.PARAMETER LogFilesFolder
Specify folder where to check log files. Will show UI where you can select what logs to process
 
.PARAMETER LogStartDateTime
Specify date and time to start log entries. For example -
 
.PARAMETER LogEndDateTime
Specify date and time to stop log entries
 
.PARAMETER ShowLogViewerUI
Shows graphical LogViewerUI where all log events are easily browsed, searched and filtered in graphical UI
This parameter will always show file selection UI and event selection UI.
 
.PARAMETER LogViewerUI
Shows graphical LogViewerUI where all log events are easily browsed, searched and filtered in graphical UI
This parameter will always show file selection UI and event selection UI.
 
.PARAMETER AllLogEvents
Process all found log events.
Selecting this parameter will disable UI which asks date/time/hour selection for logs (use for silent commands or scripts)
This is default option (aka silent and no selection UI shown)
 
.PARAMETER LogEventsSelectionUI
Selecting this parameter will enable UI which asks date/time/hour selection for logs
 
.PARAMETER AllLogFiles
Process all found supported log file(s) automatically. This includes *AgentExecutor*.log, *IntuneManagementExtension*.log and *AppWorkload*.log
Selecting this parameter will disable UI which asks which log files to process (use for silent commands or scripts)
This is default option (aka silent and no selection UI shown)
 
.PARAMETER LogFilesSelectionUI
Selecting this parameter will enable UI which asks which log files to process
 
.PARAMETER Today
Show log entries from today (from midnight)
 
.PARAMETER ShowAllTimelineEvents
Shows more entries in report. This option will show starting messages for events which are not shown by default
 
.PARAMETER ShowStdOutInReport
Show script StdOut in events. This shows for example what Remediation script will return back to Intune
 
.PARAMETER ShowErrorsInReport
This will show found error messages from Powershell scripts. Note that Powershell script may succeed and still have errors shown here.
 
.PARAMETER ShowErrorsSummary
Show separate all errors summary after Timeline.
 
.PARAMETER ConvertAllKnownGuidsToClearText
This parameter replaces all known GUIDs to cleartext in LogViewerUI. Known GUIDs are Win32Apps and WinGetApps by default.
With -Online option also Powershell scripts, Proactive Remediation scripts and custom Compliance script will get name shown in UI.
Often this parameter helps a lot debugging log entries in LogViewerUI
 
.PARAMETER LongRunningPowershellNotifyThreshold
Threshold (seconds) after Timeline report will show warning message for long running Powershell scripts. Default value is 180 seconds.
 
.PARAMETER ExportTextFileName
Export Timeline information and possible Powershell script error to text file.
This expects either text filename or fullpath to textfile.
 
.PARAMETER FindAllLongRunningPowershellScripts
Poweruser option to try to find all long running Powershell scripts over threshold which default is 180 seconds
This option could find long running scripts which we don't even have event in our report.
 
.PARAMETER DoNotOpenReportAutomatically
Do not open html report file automatically in browser
 
.PARAMETER DoNotDownloadClearTextRemediationScriptsToReport
Do not download Remediation scripts in clear text to the report
This could be used for security measures.
Note that PowerShell script is always shown in report ToolTip in clear text because clear text PowerShell script is in log files
 
 
.EXAMPLE
   .\Get-IntuneManagementExtensionDiagnostics.ps1
.EXAMPLE
   .\Get-IntuneManagementExtensionDiagnostics.ps1 -Online
.EXAMPLE
   .\Get-IntuneManagementExtensionDiagnostics.ps1 -Online -AllLogEvents -AllLogFiles
.EXAMPLE
   .\Get-IntuneManagementExtensionDiagnostics.ps1 -Online -AllLogEvents -ShowAllTimelineEvents
.EXAMPLE
   .\Get-IntuneManagementExtensionDiagnostics.ps1 -Online -ShowLogViewerUI
.EXAMPLE
   .\Get-IntuneManagementExtensionDiagnostics.ps1 -Online -ShowLogViewerUI -ConvertAllKnownGuidsToClearText
.EXAMPLE
   .\Get-IntuneManagementExtensionDiagnostics.ps1 -Online -Today
.EXAMPLE
   .\Get-IntuneManagementExtensionDiagnostics.ps1 -Online -LogStartDateTime "10.3.2023 5.00:00" -LogEndDateTime "11.3.2023 23.00:00"
.EXAMPLE
   .\Get-IntuneManagementExtensionDiagnostics.ps1 "C:\ProgramData\Microsoft\IntuneManagementExtension\Logs\IntuneManagementExtension.log"
.EXAMPLE
    .\Get-IntuneManagementExtensionDiagnostics.ps1 -LogFile "C:\ProgramData\Microsoft\IntuneManagementExtension\Logs\IntuneManagementExtension.log"
.EXAMPLE
    .\Get-IntuneManagementExtensionDiagnostics.ps1 -LogFilesFolder "C:\temp\MDMDiagReport"
.EXAMPLE
   .\Get-IntuneManagementExtensionDiagnostics.ps1 -Online -ShowStdOutInReport
.EXAMPLE
   .\Get-IntuneManagementExtensionDiagnostics.ps1 -Online -ShowErrorsInReport
.EXAMPLE
   .\Get-IntuneManagementExtensionDiagnostics.ps1 -Online -ShowErrorsSummary
.EXAMPLE
   .\Get-IntuneManagementExtensionDiagnostics.ps1 -Online -ExportTextFileName ExportTextFile.txt
.EXAMPLE
   .\Get-IntuneManagementExtensionDiagnostics.ps1 -Online -ExportTextFileName C:\temp\ExportTextFile.txt
.EXAMPLE
   .\Get-IntuneManagementExtensionDiagnostics.ps1 -AllLogEntries -AllLogFiles -ExportTextFileName C:\temp\ExportTextFile.txt
.EXAMPLE
   Get-ChildItem "C:\ProgramData\Microsoft\IntuneManagementExtension\Logs\IntuneManagementExtension.log" | .\Get-IntuneManagementExtensionDiagnostics.ps1 -AllLogEntries -Online
.INPUTS
   Script accepts File object as input. This would be same than specifying Parameter -LogFile
.OUTPUTS
   None
.NOTES
   You can download current version of this script from PowershellGallery with command
 
   Save-Script Get-IntuneManagementExtensionDiagnostics -Path ./
.LINK
   https://github.com/petripaavola/Get-IntuneManagementExtensionDiagnostics
#>


[CmdletBinding()]
Param(
    [Parameter(Mandatory=$false,
                HelpMessage = 'Enter Intune IME log file fullpath',
                ValueFromPipeline=$true,
                ValueFromPipelineByPropertyName=$true)]
    [Alias("FullName")]
    [String]$LogFile = $null,
    [Parameter(Mandatory=$false,
                HelpMessage = 'Enter Intune IME log files folder path',
                ValueFromPipeline=$false,
                ValueFromPipelineByPropertyName=$false)]
    [String]$LogFilesFolder = $null,
    [Parameter(Mandatory=$false)]
    [Switch]$Online,
    [Parameter(Mandatory=$false,
                HelpMessage = 'Enter Start DateTime for log entries (for example "10.3.2023 5:00:00")',
                ValueFromPipeline=$false,
                ValueFromPipelineByPropertyName=$false)]
    $LogStartDateTime = $null,
    [Parameter(Mandatory=$false,
                HelpMessage = 'Enter End DateTime for log entries (for example "11.3.2023 23.00:00")',
                ValueFromPipeline=$false,
                ValueFromPipelineByPropertyName=$false)]
    $LogEndDateTime = $null,
    [Parameter(Mandatory=$false)]
    [Switch]$ShowLogViewerUI,
    [Parameter(Mandatory=$false)]
    [Switch]$LogViewerUI,
    [Parameter(Mandatory=$false)]
    [Switch]$AllLogEntries=$true,
    [Parameter(Mandatory=$false)]
    [Switch]$LogEntriesSelectionUI,
    [Parameter(Mandatory=$false)]
    [Switch]$AllLogFiles=$true,
    [Parameter(Mandatory=$false)]
    [Switch]$LogFilesSelectionUI,
    [Parameter(Mandatory=$false)]
    [Switch]$Today,
    [Parameter(Mandatory=$false)]
    [Switch]$ShowAllTimelineEvents,
    [Parameter(Mandatory=$false)]
    [Switch]$ShowStdOutInReport,
    [Parameter(Mandatory=$false)]
    [Switch]$ShowErrorsInReport,
    [Parameter(Mandatory=$false)]
    [Switch]$ShowErrorsSummary,
    [Parameter(Mandatory=$false)]
    [Switch]$ConvertAllKnownGuidsToClearText,
    [Parameter(Mandatory=$false,
                HelpMessage = 'Threshold seconds to highlight long running Powershell scripts',
                ValueFromPipeline=$true,
                ValueFromPipelineByPropertyName=$true)]
    [int]$LongRunningPowershellNotifyThreshold = 180,
    [Parameter(Mandatory=$false,
                HelpMessage = 'Enter text (.txt) filename to export info to',
                ValueFromPipeline=$false,
                ValueFromPipelineByPropertyName=$false)]
    [String]$ExportTextFileName=$null,
    [Parameter(Mandatory=$false)]
    [String]$ExportHTMLReportPath=$null,
    [Parameter(Mandatory=$false)]
    [Switch]$FindAllLongRunningPowershellScripts,
    [Parameter(Mandatory=$false)]
    [Switch]$DoNotOpenReportAutomatically,
    [Parameter(Mandatory=$false)]
    [Switch]$DoNotDownloadClearTextRemediationScriptsToReport
)


$ScriptVersion = "3.0"
$TimeOutBetweenGraphAPIRequests = 300


Write-Host "Get-IntuneManagementExtensionDiagnostics.ps1 $ScriptVersion" -ForegroundColor Cyan
Write-Host "Author: Petri.Paavola@yodamiitti.fi / Microsoft MVP - Windows and Intune"
Write-Host ""


$ExportHTML=$True

# Make script to not show start selection UIs by default
# This should be fixed in the code
# but this was quicker hack to make script silent by default

$AllLogEntries=$true
$AllLogFiles = $true

if($LogEntriesSelectionUI) {
    $AllLogEntries=$false
}
if($LogFilesSelectionUI) {
    $AllLogFiles = $false
}


# Show selection UIs with LogViewerUI because we would like to
# limit as less as events possible to save memory and speed up Out-GridView

# With LogViewerUI show Events selection UI always
# With LogViewerUI show file selection UI always
if($ShowLogViewerUI -or $LogViewerUI) {
    $LogEntriesSelectionUI = $true
    $LogFilesSelectionUI = $true

    $AllLogEntries=$false
    $AllLogFiles = $false
    
    Write-Host "Parameter -ShowLogViewerUI selected. Script will show log file and event selection UIs."
}



# Set variables automatically if we are in Windows Autopilot ESP (Enrollment Status Page)
# Idea is that user can just run the script without checking Parameters first
if($env:UserName -eq 'defaultUser0') {

    Write-Host "Detected running in Windows Autopilot Enrollment Status Page (ESP)" -ForegroundColor Yellow

    #$LOGFile='C:\ProgramData\Microsoft\IntuneManagementExtension\Logs\IntuneManagementExtension.log'

    # Do not open HTML report to browser
    $DoNotOpenReportAutomatically = $true

    if((-not $LogFilesFolder) -or (-not $LOGFile)) {
        Write-Host "Configuring parameters automatically"
        Write-Host "Selected: All log files from default Intune IME logs folder"
        Write-Host "Selected: Do not open HTML report automatically"
        Write-Host

        $LogFilesFolder = 'C:\ProgramData\Microsoft\IntuneManagementExtension\Logs'

        if(-not (Test-Path $LogFilesFolder)) {
            Write-Host "Log folder does not exist yet: $LogFilesFolder"
            Write-Host "Try again in a moment..."  -ForegroundColor Yellow
            Write-Host ""
            Exit 0
        }
    }

    # Save Computer Name
    $ComputerNameForReport = $env:ComputerName

    # Process all found supported log files
    # This will not show file selection UI
    $AllLogFiles=$True

    # Process all log entries
    # This will not show time selection UI
    $AllLogEntries=$True

    # Show all entries in Timeline
    # This especially useful if some script or application hangs for a long time
    # so then you can see start entry for that script or application and you know what is current running Intune deployment
    $ShowAllTimelineEvents=$True
    
    # Do not download Remediation scripts in clear text to Report
    # Reason is that many may not event know that script did also html report
    # In OOBE usually the console view is enough and html file may be left to the device disk
    $DoNotDownloadClearTextRemediationScriptsToReport=$True
    
    if(-not (Test-Path 'C:\ProgramData\Microsoft\IntuneManagementExtension\Logs\IntuneManagementExtension.log')) {
        Write-Host "Log file does not exist yet: C:\ProgramData\Microsoft\IntuneManagementExtension\Logs\IntuneManagementExtension.log"  -ForegroundColor Yellow
        Write-Host "Try again in a moment..."  -ForegroundColor Yellow
        Write-Host ""
        Exit 0
    }
}


# Hashtable with ids and names
$IdHashtable = @{}

# Save timeline objects to this List
$observedTimeline = [System.Collections.Generic.List[PSObject]]@()

# Save application download statistics to this list
$ApplicationDownloadStatistics = [System.Collections.Generic.List[PSObject]]@()

# Save filtered oud applications to to this list
$ApplicationAssignmentFilterApplied = [System.Collections.Generic.List[PSObject]]@()

# TimeLine entry index
# This might be used in HTML table for sorting entries
$Script:observedTimeLineIndexToHTMLTable=0


################ Functions ################

# This is aligned with Michael Niehaus's Get-AutopilotDiagnostics script just in case
# region Functions
    Function RecordStatusToTimeline {
        param
        (
            [Parameter(Mandatory=$true)] [String] $date,
            [Parameter(Mandatory=$true)] [String] $status,
            [Parameter(Mandatory=$false)] [String] $type,
            [Parameter(Mandatory=$false)] [String] $intent,
            [Parameter(Mandatory=$false)] [String] $detail,
            [Parameter(Mandatory=$false)] $seconds,
            [Parameter(Mandatory=$false)] [String] $logEntry,
            [Parameter(Mandatory=$false)] [String] $color,
            [Parameter(Mandatory=$false)] [String] $DetailToolTip
        )

        $Script:observedTimeLineIndexToHTMLTable++

        # Round seconds to full seconds
        if($seconds) {
            $seconds = [math]::Round($seconds)
        }

        $observedTimeline.add([PSCustomObject]@{
            'Index' = $Script:observedTimeLineIndexToHTMLTable
            'Date' = $date
            'Status' = $status
            'Type' = $type
            'Intent' = $intent
            'Detail' = $detail
            'Seconds' = $seconds
            'LogEntry' = $logEntry
            'Color' = $color
            'DetailToolTip' = $DetailToolTip
            
            })
    }

    Function Get-AppIntent {
        Param(
            $AppId
            )

        $intent = 'Unknown Intent'

        if($AppId) {
            if($IdHashtable.ContainsKey($AppId)) {
                $AppPolicy=$IdHashtable[$AppId]

                if($AppPolicy.Intent) {
                    Switch ($AppPolicy.Intent)
                    {
                        0    { $intent = 'Not Targeted' }
                        1    { $intent = 'Available Install' }
                        3    { $intent = 'Required Install' }
                        4    { $intent = 'Required Uninstall' }
                        default { $intent = 'Unknown Intent' }
                    }
                }                
            }
        }
        
        return $intent
    }

    Function Get-AppIntentNameForNumber {
        Param(
            $IntentNumber
            )

        Switch ($IntentNumber)
        {
            0    { $intent = 'Not Targeted' }
            1    { $intent = 'Available Install' }
            3    { $intent = 'Required Install' }
            4    { $intent = 'Required Uninstall' }
            default { $intent = 'Unknown Intent' }
        }
        
        return $intent
    }

    Function Get-AppDetectionResultForNumber {
        Param(
            $DetectionNumber
            )

        Switch ($DetectionNumber)
        {
            0    { $DetectionState = 'Unknown' }
            1    { $DetectionState = 'Detected' }
            2    { $DetectionState = 'Not Detected' }
            3    { $DetectionState = 'Unknown' }
            4    { $DetectionState = 'Unknown' }
            5    { $DetectionState = 'Unknown' }
            default { $DetectionState = 'Unknown' }
        }
        
        return $DetectionState
    }


    Function Get-AppName {
        Param(
            $AppId
            )

        $AppName = $null

        if($AppId) {
            if($IdHashtable.ContainsKey($AppId)) {
                $AppPolicy=$IdHashtable[$AppId]

                if($AppPolicy.Name) {
                    $AppName = $AppPolicy.Name
                }                
            }
        }
        
        return $AppName
    }


    Function Get-AppType {
        Param(
            $AppId
            )

        $AppType = 'App'

        if($AppId) {
            if($IdHashtable.ContainsKey($AppId)) {
                $AppPolicy=$IdHashtable[$AppId]

                if($AppPolicy.InstallerData) {
                    # This should be New Store App
                    $AppType = 'WinGetApp'
                } else {
                    # This should be Win32App
                    $AppType = 'Win32App'
                }
            }
        }
        
        return $AppType
    }

    Function Convert-AppDetectionValuesToHumanReadable {
        Param(
            $DetectionRulesObject
        )

        # Object has DetectionType and DetectionText objects
        # Object is array of objects
        <#
        [
          {
            "DetectionType": 2,
            "DetectionText": {
                                  "Path": "C:\\Program Files (x86)\\Foo",
                                  "FileOrFolderName": "bar.exe",
                                  "Check32BitOn64System": true,
                                  "DetectionType": 1,
                                  "Operator": 0,
                                  "DetectionValue": null
                              }
          }
        ]
        #>


        foreach($DetectionRule in $DetectionRulesObject) {


            # Change DetectionText properties values to text
            
            # DetectionType: Registry
            if($DetectionRule.DetectionType -eq 0) {
            
                # Registry Detection Type values
                # https://learn.microsoft.com/en-us/graph/api/resources/intune-apps-win32lobappregistrydetectiontype?view=graph-rest-beta
                Switch ($DetectionRule.DetectionText.DetectionType)  {
                    0 { $DetectionRule.DetectionText.DetectionType = 'Not configure' }
                    1 { $DetectionRule.DetectionText.DetectionType = 'Value exists' }
                    2 { $DetectionRule.DetectionText.DetectionType = 'Value does not exist' }
                    3 { $DetectionRule.DetectionText.DetectionType = 'String comparison' }
                    4 { $DetectionRule.DetectionText.DetectionType = 'Integer comparison' }
                    5 { $DetectionRule.DetectionText.DetectionType = 'Version comparison' }
                }
                
                # Registry Detection Operation values
                # https://learn.microsoft.com/en-us/graph/api/resources/intune-apps-win32lobappruleoperator?view=graph-rest-beta
                Switch ($DetectionRule.DetectionText.Operator)  {
                    0 { $DetectionRule.DetectionText.Operator = 'Not configured' }
                    1 { $DetectionRule.DetectionText.Operator = 'Equals' }
                    2 { $DetectionRule.DetectionText.Operator = 'Not equal to' }
                    4 { $DetectionRule.DetectionText.Operator = 'Greater than' }
                    5 { $DetectionRule.DetectionText.Operator = 'Greater than or equal to' }
                    8 { $DetectionRule.DetectionText.Operator = 'Less than' }
                    9 { $DetectionRule.DetectionText.Operator = 'Less than or equal to' }
                }
            }


            # DetectionType: File
            if($DetectionRule.DetectionType -eq 2) {

                # File Detection Type values
                # https://learn.microsoft.com/en-us/graph/api/resources/intune-apps-win32lobappfilesystemdetectiontype?view=graph-rest-beta
                Switch ($DetectionRule.DetectionText.DetectionType)  {
                    0 { $DetectionRule.DetectionText.DetectionType = 'Not configure' }
                    1 { $DetectionRule.DetectionText.DetectionType = 'File or folder exists' }
                    2 { $DetectionRule.DetectionText.DetectionType = 'Date modified' }
                    3 { $DetectionRule.DetectionText.DetectionType = 'Date created' }
                    4 { $DetectionRule.DetectionText.DetectionType = 'String (version)' }
                    5 { $DetectionRule.DetectionText.DetectionType = 'Size in MB' }
                    6 { $DetectionRule.DetectionText.DetectionType = 'File or folder does not exist' }
                }
                
                # File Detection Operator values
                # https://learn.microsoft.com/en-us/graph/api/resources/intune-apps-win32lobappdetectionoperator?view=graph-rest-beta
                Switch ($DetectionRule.DetectionText.Operator)  {
                    0 { $DetectionRule.DetectionText.Operator = 'Not configured' }
                    1 { $DetectionRule.DetectionText.Operator = 'Equals' }
                    2 { $DetectionRule.DetectionText.Operator = 'Not equal to' }
                    4 { $DetectionRule.DetectionText.Operator = 'Greater than' }
                    5 { $DetectionRule.DetectionText.Operator = 'Greater than or equal to' }
                    8 { $DetectionRule.DetectionText.Operator = 'Less than' }
                    9 { $DetectionRule.DetectionText.Operator = 'Less than or equal to' }
                }
            }
            

            # DetectionType: Custom script
            if($DetectionRule.DetectionType -eq 3) {

                # Convert base64 script to clear text
                #$DetectionRule.DetectionText.ScriptBody

                # Decode Base64 content
                $b = [System.Convert]::FromBase64String("$($DetectionRule.DetectionText.ScriptBody)")
                $DetectionRule.DetectionText.ScriptBody = [System.Text.Encoding]::UTF8.GetString($b)

            }
            
            
            <#
            # Change DetectionType value to text
            Switch ($DetectionRule.DetectionType) {
                0 { $DetectionRule.DetectionType = 'Registry' }
                1 { $DetectionRule.DetectionType = 'MSI' }
                2 { $DetectionRule.DetectionType = 'File' }
                3 { $DetectionRule.DetectionType = 'Custom script' }
                default { $DetectionRule.DetectionType = $DetectionRule.DetectionType }
            }
            #>

            
            # Add new property with DetectionType value as text
            Switch ($DetectionRule.DetectionType) {
                0 { $DetectionRule | Add-Member -MemberType noteProperty -Name DetectionTypeAsText -Value 'Registry' }
                1 { $DetectionRule | Add-Member -MemberType noteProperty -Name DetectionTypeAsText -Value 'MSI' }
                2 { $DetectionRule | Add-Member -MemberType noteProperty -Name DetectionTypeAsText -Value 'File' }
                3 { $DetectionRule | Add-Member -MemberType noteProperty -Name DetectionTypeAsText -Value 'Custom script' }
                default { $DetectionRule | Add-Member -MemberType noteProperty -Name DetectionTypeAsText -Value $DetectionRule.DetectionType }
            }            
            
        }            
        
        return $DetectionRulesObject
    }


    function Invoke-MgGraphRequestGetAllPages {
        param (
            [Parameter(Mandatory = $true)]
            [String]$uri
        )

        $MgGraphRequest = $null
        $AllMSGraphRequest = $null

        Start-Sleep -Milliseconds $TimeOutBetweenGraphAPIRequests

        try {

            # Save results to this variable
            $allGraphAPIData = @()

            do {

                $MgGraphRequest = $null
                $MgGraphRequest = Invoke-MgGraphRequest -Uri $uri -Method 'Get' -OutputType PSObject -ContentType "application/json"

                if($MgGraphRequest) {

                    # Test if object has attribute named Value (whether value is null or not)
                    #if((Get-Member -inputobject $MgGraphRequest -name 'Value' -Membertype Properties) -and (Get-Member -inputobject $MgGraphRequest -name '@odata.context' -Membertype Properties)) {
                    if(Get-Member -inputobject $MgGraphRequest -name 'Value' -Membertype Properties) {
                        # Value property exists
                        $allGraphAPIData += $MgGraphRequest.Value

                        # Check if we have value starting https:// in attribute @odate.nextLink
                        # and check that $Top= parameter was NOT used. With $Top= parameter we can limit search results
                        # but that almost always results .nextLink being present if there is more data than specified with top
                        # If we specified $Top= ourselves then we don't want to fetch nextLink values
                        #
                        # So get GraphAllPages if there is valid nextlink and $Top= was NOT used in url originally
                        if (($MgGraphRequest.'@odata.nextLink' -like 'https://*') -and (-not ($uri.Contains('$top=')))) {
                            # Save nextLink url to variable and rerun do-loop
                            $uri = $MgGraphRequest.'@odata.nextLink'
                            Start-Sleep -Milliseconds $TimeOutBetweenGraphAPIRequests

                            # Continue to next round in Do-loop
                            Continue

                        } else {
                            # We dont have nextLink value OR
                            # $top= exists so we return what we got from first round
                            #return $allGraphAPIData
                            $uri = $null
                        }
                        
                    } else {
                        # Sometimes we get results without Value-attribute (eg. getting user details)
                        # We will return all we got as is
                        # because there should not be nextLink page in this case ???
                        return $MgGraphRequest
                    }
                } else {
                    # Invoke-MGGraphRequest failed so we return false
                    return $null
                }
                
            } while ($uri) # Always run once and continue if there is nextLink value


            # We should not end here but just in case
            return $allGraphAPIData

        } catch {
            Write-Error "There was error with MGGraphRequest with url $url!"
            return $null
        }
    }


    function Get-IntunePowershellScriptContentInCleartext {
        Param(
            [Parameter(Mandatory=$true)]
            [String]$PowershellScriptPolicyId
        )

        # Check if we already have value
        if($IdHashtable[$PowershellScriptPolicyId].scriptContentClearText) {
            # Powershell script in clear text already exists in HashTable object
            # So we can return it
            
            return $IdHashtable[$PowershellScriptPolicyId].scriptContentClearText

        } else {
            # Property scriptContentClearText does NOT exist in HashTable object

            # Download Powershell script in cleartext if -Online parameter has been specified
            # PowerShell script is in clear text in IME log files so this is always shown
            # And later version might get PowerShell script from IME log instead from Graph
            if($Online) {

                #Write-Verbose "Downloading Powershell script: $PowershellScriptPolicyId"
                
                $uri = "https://graph.microsoft.com/beta/deviceManagement/deviceManagementScripts/$PowershellScriptPolicyId"
                $IntunePowershellScriptInformation = Invoke-MgGraphRequestGetAllPages -Uri $uri

                if($IntunePowershellScriptInformation) {
                    #Write-Verbose "Done" -ForegroundColor Green
                    
                    if($IntunePowershellScriptInformation.scriptContent) {

                        Try {
                            # Convert Intune Powershell script base64 content to clear text
                            $b = [System.Convert]::FromBase64String("$($IntunePowershellScriptInformation.scriptContent)")
                            $IntuneScriptContentInClearText = [System.Text.Encoding]::UTF8.GetString($b)
                        } catch {
                            # Some fatal error converting base64 to cleartext
                            Write-Error "Error converting Intune Powershell script base64 to cleartext" -ForegroundColor Red
                            return 'N/A'
                        }

                        # Add new property to HashTable object if it doesn't already exist
                        if(-not $IdHashtable[$PowershellScriptPolicyId].scriptContentClearText) {
                            
                            $IdHashtable[$PowershellScriptPolicyId] | Add-Member -MemberType noteProperty -Name scriptContentClearText -Value $IntuneScriptContentInClearText
                        } else {
                            $IdHashtable[$PowershellScriptPolicyId].scriptContentClearText = $IntuneScriptContentInClearText
                        }

                        return $IntuneScriptContentInClearText
                    } else {
                        # Did not get scriptContent information
                        # We should never get here if we got anything successfully from Graph API
                        Write-Verbose "Failed to download Powershell scriptContent property" -ForegroundColor Yellow
                        return 'N/A'
                    }
                } else {
                    # Could not get Intune Powershell information from Intune
                    # Doing nothing
                    Write-Host "Failed to download Powershell script from Intune" -ForegroundColor Yellow
                    return 'N/A'
                }
            } else {
                # -Online not selected and we didn't have value so return $null
                return 'N/A'
            }
        }
        # We should not get here
        return 'N/A'
    }


    function Get-IntuneRemediationDetectionScriptContentInCleartext {
        Param(
            [Parameter(Mandatory=$true)]
                [String]$ScriptPolicyId
        )


        # Check if we already have value
        if($IdHashtable[$ScriptPolicyId].detectionScriptContentClearText) {
            # Powershell script in clear text already exists in HashTable object
            # So we can return it
            
            return $IdHashtable[$ScriptPolicyId].detectionScriptContentClearText

        } else {
            # Property detectionScriptContent does NOT exist in HashTable object

            # Download Remediation Detection script in cleartext if -Online parameter has been specified
            # and -DoNotDownloadClearTextRemediationScriptsToReport is NOT specified
            if($Online -and (-not $DoNotDownloadClearTextRemediationScriptsToReport)) {

                #Write-Verbose "Downloading Remediation Detection script: $ScriptPolicyId"
                
                $uri = "https://graph.microsoft.com/beta/deviceManagement/deviceHealthScripts/$ScriptPolicyId"
                #Write-Verbose "URI: $uri"
                $IntuneRemediationScriptInformation = Invoke-MgGraphRequestGetAllPages -Uri $uri

                if($IntuneRemediationScriptInformation) {
                    #Write-Verbose "Done" -ForegroundColor Green
                    
                    if($IntuneRemediationScriptInformation.detectionScriptContent) {

                        Try {
                            # Convert Intune Remediation Detection script base64 content to clear text
                            $b = [System.Convert]::FromBase64String("$($IntuneRemediationScriptInformation.detectionScriptContent)")
                            $IntuneScriptContentInClearText = [System.Text.Encoding]::UTF8.GetString($b)
                        } catch {
                            # Some fatal error converting base64 to cleartext
                            Write-Error "Error converting Intune Remediation Detection script base64 to cleartext" -ForegroundColor Red
                            return 'N/A'
                        }

                        # Add new property to HashTable object if it doesn't already exist
                        if(-not $IdHashtable[$ScriptPolicyId].detectionScriptContentClearText) {
                            
                            $IdHashtable[$ScriptPolicyId] | Add-Member -MemberType noteProperty -Name detectionScriptContentClearText -Value $IntuneScriptContentInClearText
                        } else {
                            $IdHashtable[$PowershellScriptPolicyId].detectionScriptContentClearText = $IntuneScriptContentInClearText
                        }

                        return $IntuneScriptContentInClearText
                    } else {
                        # Did not get detectionScriptContent information
                        # We should never get here if we got anything successfully from Graph API
                        Write-Verbose "Failed to download Remediation Detect detectionScriptContent property" -ForegroundColor Yellow
                        return 'N/A'
                    }
                } else {
                    # Could not get Intune Remediation Detect information from Intune
                    # Doing nothing
                    Write-Host "Failed to download Remediation Detect script from Intune" -ForegroundColor Yellow
                    return 'N/A'
                }
            } else {
                # -Online not selected and we didn't have value so return $null
                return 'N/A'
            }
        }
        # We should not get here
        return 'N/A'
    }


    function Get-IntuneRemediationRemediateScriptContentInCleartext {
        Param(
            [Parameter(Mandatory=$true)]
                [String]$ScriptPolicyId
        )


        # Check if we already have value
        if($IdHashtable[$ScriptPolicyId].RemediateScriptContentClearText) {
            # Powershell script in clear text already exists in HashTable object
            # So we can return it
            
            return $IdHashtable[$ScriptPolicyId].RemediateScriptContentClearText

        } else {
            # Property remediationScriptContent does NOT exist in HashTable object

            # Download Remediation Remediate script in cleartext if -Online parameter has been specified
            # and -DoNotDownloadClearTextRemediationScriptsToReport is NOT specified
            if($Online -and (-not $DoNotDownloadClearTextRemediationScriptsToReport)) {

                #Write-Verbose "Downloading Remediation Remediate script: $ScriptPolicyId"
                
                $uri = "https://graph.microsoft.com/beta/deviceManagement/deviceHealthScripts/$ScriptPolicyId"
                $IntuneRemediationScriptInformation = Invoke-MgGraphRequestGetAllPages -Uri $uri

                if($IntuneRemediationScriptInformation) {
                    #Write-Verbose "Done" -ForegroundColor Green
                    
                    if($IntuneRemediationScriptInformation.remediationScriptContent) {

                        Try {
                            # Convert Intune Remediation Detection script base64 content to clear text
                            $b = [System.Convert]::FromBase64String("$($IntuneRemediationScriptInformation.remediationScriptContent)")
                            $IntuneScriptContentInClearText = [System.Text.Encoding]::UTF8.GetString($b)
                        } catch {
                            # Some fatal error converting base64 to cleartext
                            Write-Error "Error converting Intune Remediation Detection script base64 to cleartext" -ForegroundColor Red
                            return 'N/A'
                        }

                        # Add new property to HashTable object if it doesn't already exist
                        if(-not $IdHashtable[$ScriptPolicyId].RemediateScriptContentClearText) {
                            
                            $IdHashtable[$ScriptPolicyId] | Add-Member -MemberType noteProperty -Name RemediateScriptContentClearText -Value $IntuneScriptContentInClearText
                        } else {
                            $IdHashtable[$PowershellScriptPolicyId].RemediateScriptContentClearText = $IntuneScriptContentInClearText
                        }

                        return $IntuneScriptContentInClearText
                    } else {
                        # Did not get remediationScriptContent information
                        # We should never get here if we got anything successfully from Graph API
                        Write-Verbose "Failed to download Powershell remediationScriptContent property" -ForegroundColor Yellow
                        return 'N/A'
                    }
                } else {
                    # Could not get Intune Remediation script information from Intune
                    # Doing nothing
                    Write-Host "Failed to download Remediation Remediate script from Intune" -ForegroundColor Yellow
                    return 'N/A'
                }
            } else {
                # -Online not selected and we didn't have value so return $null
                return 'N/A'
            }
        }
        # We should not get here
        return 'N/A'
    }


    function Get-IntuneCustomComplianceScriptContentInCleartext {
        Param(
            [Parameter(Mandatory=$true)]
                [String]$ScriptPolicyId
        )


        # Check if we already have value
        if($IdHashtable[$ScriptPolicyId].detectionScriptContentClearText) {
            # Powershell script in clear text already exists in HashTable object
            # So we can return it
            
            return $IdHashtable[$ScriptPolicyId].detectionScriptContentClearText

        } else {
            # Property detectionScriptContent does NOT exist in HashTable object

            # Download Custom Compliance script in cleartext if -Online parameter has been specified
            # and -DoNotDownloadClearTextRemediationScriptsToReport is NOT specified
            if($Online -and (-not $DoNotDownloadClearTextRemediationScriptsToReport)) {

                #Write-Verbose "Downloading Custom Compliance script: $ScriptPolicyId"
                
                $uri = "https://graph.microsoft.com/beta/deviceManagement/deviceComplianceScripts/$ScriptPolicyId"
                $IntuneRemediationScriptInformation = Invoke-MgGraphRequestGetAllPages -Uri $uri

                if($IntuneRemediationScriptInformation) {
                    #Write-Verbose "Done" -ForegroundColor Green
                    
                    if($IntuneRemediationScriptInformation.detectionScriptContent) {

                        Try {
                            # Convert Intune Custom Compliance script base64 content to clear text
                            $b = [System.Convert]::FromBase64String("$($IntuneRemediationScriptInformation.detectionScriptContent)")
                            $IntuneScriptContentInClearText = [System.Text.Encoding]::UTF8.GetString($b)
                        } catch {
                            # Some fatal error converting base64 to cleartext
                            Write-Error "Error converting Intune Custom Compliance script base64 to cleartext" -ForegroundColor Red
                            return 'N/A'
                        }

                        # Add new property to HashTable object if it doesn't already exist
                        if(-not $IdHashtable[$ScriptPolicyId].detectionScriptContentClearText) {
                            
                            $IdHashtable[$ScriptPolicyId] | Add-Member -MemberType noteProperty -Name detectionScriptContentClearText -Value $IntuneScriptContentInClearText
                        } else {
                            $IdHashtable[$PowershellScriptPolicyId].detectionScriptContentClearText = $IntuneScriptContentInClearText
                        }

                        return $IntuneScriptContentInClearText
                    } else {
                        # Did not get detectionScriptContent information
                        # We should never get here if we got anything successfully from Graph API
                        Write-Verbose "Failed to download Custom Compliance detectionScriptContent property" -ForegroundColor Yellow
                        return 'N/A'
                    }
                } else {
                    # Could not get Intune Custom Compliance information from Intune
                    # Doing nothing
                    Write-Host "Failed to download Custom Compliance script from Intune" -ForegroundColor Yellow
                    return 'N/A'
                }
            } else {
                # -Online not selected and we didn't have value so return $null
                return 'N/A'
            }
        }
        # We should not get here
        return 'N/A'
    }



    ### HTML Report helper functions ###
    
    function Fix-HTMLSyntax {
        Param(
            $html
        )

        $html = $html.Replace('&lt;', '<')
        $html = $html.Replace('&gt;', '>')
        $html = $html.Replace('&quot;', '"')

        return $html
    }

    function Fix-HTMLColumns {
        Param(
            $html
        )

        # Rename column headers
        $html = $html -replace '<th>@odata.type</th>','<th>App type</th>'
        $html = $html -replace '<th>displayname</th>','<th>App name</th>'
        $html = $html -replace '<th>assignmentIntent</th>','<th>Assignment Intent</th>'
        $html = $html -replace '<th>assignmentTargetGroupDisplayName</th>','<th>Target Group</th>'
        $html = $html -replace '<th>assignmentFilterDisplayName</th>','<th>Filter name</th>'
        $html = $html -replace '<th>FilterIncludeExclude</th>','<th>Filter Intent</th>'
        $html = $html -replace '<th>publisher</th>','<th>Publisher</th>'
        $html = $html -replace '<th>productVersion</th>','<th>Version</th>'
        $html = $html -replace '<th>filename</th>','<th>Filename</th>'
        $html = $html -replace '<th>createdDateTime</th>','<th>Created</th>'
        $html = $html -replace '<th>lastModifiedDateTime</th>','<th>Modified</th>'

        return $html
    }


    function return-ObjectPropertiesAsAlignedString {
        Param(
            $object
        )
        # Calculate the maximum length of property names for alignment
        $maxWidth = ($object.PSObject.Properties.Name | Measure-Object -Maximum -Property Length).Maximum

        # Build the formatted string
        $result = $object.PSObject.Properties | ForEach-Object {
            "{0,-$maxWidth} : {1}" -f $_.Name, $_.Value
        }

        # Convert array to single string
        $output = $result -join "`n"
        return $output
    }

# endregion Functions

################ Functions ################

Write-Host "Starting Get-IntuneManagementExtensionDiagnostics`n"

# If LogFilePath is not specified then show log files in Out-GridView
# from folder C:\ProgramData\Microsoft\intunemanagementextension\Logs
if($LOGFile) {

    if(-not (Test-Path $LOGFile)) {
        Write-Host "Log file does not exist: $LOGFile" -ForegroundColor Yellow
        Write-Host "Script will exit" -ForegroundColor Yellow
        Write-Host ""
        Exit 0
    }
    $SelectedLogFiles = Get-ChildItem -Path $LOGFile
    
} else {

    if($LogFilesFolder) {

        if(-not (Test-Path $LogFilesFolder)) {
            Write-Host "LogFilesFolder: $LogFilesFolder does not exist" -ForegroundColor Yellow
            Write-Host "Script will exit" -ForegroundColor Yellow
            exit 0
        }

        # Sort files: new files first and IntuneManagementExtension before AgentExecutor
        
        # ORIGINAL
        #$LogFiles = Get-ChildItem -Path $LogFilesFolder -Filter *.log | Where-Object { ($_.Name -like '*IntuneManagementExtension*.log') -or ($_.Name -like '*AgentExecutor*.log') -or ($_.Name -like '*AppWorkload*.log') } | Sort-Object -Property Name -Descending | Sort-Object -Property LastWriteTime -Descending

        # Added -Depth 2 so you can point to Intune Diagnostics package root folder
        $LogFiles = Get-ChildItem -Path $LogFilesFolder -Filter *.log -Depth 2 | Where-Object { ($_.Name -like '*IntuneManagementExtension*.log') -or ($_.Name -like '*AgentExecutor*.log') -or ($_.Name -like '*AppWorkload*.log') } | Sort-Object -Property Name -Descending | Sort-Object -Property LastWriteTime -Descending


    } else {
        # Running report using local computer's Intune log files

        # Save Computer Name
        $ComputerNameForReport = $env:ComputerName
        
        # Sort files: new files first and IntuneManagementExtension before AgentExecutor
        $LogFiles = Get-ChildItem -Path 'C:\ProgramData\Microsoft\intunemanagementextension\Logs' -Filter *.log | Where-Object { ($_.Name -like '*IntuneManagementExtension*.log') -or ($_.Name -like '*AgentExecutor*.log') -or ($_.Name -like '*AppWorkload*.log') } | Sort-Object -Property Name -Descending | Sort-Object -Property LastWriteTime -Descending

    }
    
    # Show log files in Out-GridView
    # This variable is automatically configured in ESP
    if(-not $SelectedLogFiles) {
        if($AllLogFiles) {
            $SelectedLogFiles = $LogFiles
        } else {
            $SelectedLogFiles = $LogFiles | Out-GridView -Title 'Select log file to show in Out-GridView from path C:\ProgramData\Microsoft\intunemanagementextension\Logs' -OutputMode Multiple
        }
    }
    
    if(-not $SelectedLogFiles) {
        Write-Host "No log file(s) selected. Script will exit!`n" -ForegroundColor Yellow
        Exit 0
    }
}


# Check whether we show time selection on Out-GridView or not
if(((-not $LogStartDateTime) -and (-not $LogEndDateTime)) -and (-not $AllLogEntries) -and (-not $Today)) {
    
    $LogStartEndTimeOutGridviewEntries = [System.Collections.Generic.List[PSObject]]@()
    
    $LogStartEndTimeOutGridviewEntries.add([PSCustomObject]@{
        'Log Start and End time' = 'All log entries';
        'LogStartDateTimeObject' = Get-Date 1/1/1900;
        'LogEndDateTimeObject' = (Get-Date).AddYears(1000);
        })

    $LogStartEndTimeOutGridviewEntries.add([PSCustomObject]@{
        'Log Start and End time' = 'Current day from midnight';
        'LogStartDateTimeObject' = Get-Date -Hour 0 -Minute 0 -Second 0;
        'LogEndDateTimeObject' = (Get-Date).AddYears(1000);
        })
        
    $LogStartEndTimeOutGridviewEntries.add([PSCustomObject]@{
        'Log Start and End time' = 'Last 5 minutes';
        'LogStartDateTimeObject' = (Get-Date).AddMinutes(-5);
        'LogEndDateTimeObject' = (Get-Date).AddYears(1000);
        })

    $LogStartEndTimeOutGridviewEntries.add([PSCustomObject]@{
        'Log Start and End time' = 'Last 15 minutes';
        'LogStartDateTimeObject' = (Get-Date).AddMinutes(-15);
        'LogEndDateTimeObject' = (Get-Date).AddYears(1000);
        })

    $LogStartEndTimeOutGridviewEntries.add([PSCustomObject]@{
        'Log Start and End time' = 'Last 30 minutes';
        'LogStartDateTimeObject' = (Get-Date).AddMinutes(-30);
        'LogEndDateTimeObject' = (Get-Date).AddYears(1000);
        })

    $LogStartEndTimeOutGridviewEntries.add([PSCustomObject]@{
        'Log Start and End time' = 'Last 1 hour';
        'LogStartDateTimeObject' = (Get-Date).AddHours(-1);
        'LogEndDateTimeObject' = (Get-Date).AddYears(1000);
        })

    $LogStartEndTimeOutGridviewEntries.add([PSCustomObject]@{
        'Log Start and End time' = 'Last 2 hours';
        'LogStartDateTimeObject' = (Get-Date).AddHours(-2);
        'LogEndDateTimeObject' = (Get-Date).AddYears(1000);
        })

    $LogStartEndTimeOutGridviewEntries.add([PSCustomObject]@{
        'Log Start and End time' = 'Last 6 hours';
        'LogStartDateTimeObject' = (Get-Date).AddHours(-6);
        'LogEndDateTimeObject' = (Get-Date).AddYears(1000);
        })

    $LogStartEndTimeOutGridviewEntries.add([PSCustomObject]@{
        'Log Start and End time' = 'Last 24 hours';
        'LogStartDateTimeObject' = (Get-Date).AddHours(-24);
        'LogEndDateTimeObject' = (Get-Date).AddYears(1000);
        })

    $LogStartEndTimeOutGridviewEntries.add([PSCustomObject]@{
        'Log Start and End time' = 'Last 7 days';
        'LogStartDateTimeObject' = (Get-Date).AddDays(-7);
        'LogEndDateTimeObject' = (Get-Date).AddYears(1000);
        })

    $LogStartEndTimeOutGridviewEntries.add([PSCustomObject]@{
        'Log Start and End time' = 'First 30 minutes';
        'LogStartDateTimeObject' = Get-Date 1/1/1900;
        'LogEndDateTimeObject' = 'Analyzing DateTime later';
        })

    $LogStartEndTimeOutGridviewEntries.add([PSCustomObject]@{
        'Log Start and End time' = 'First 1 hour';
        'LogStartDateTimeObject' = Get-Date 1/1/1900;
        'LogEndDateTimeObject' = 'Analyzing DateTime later';
        })

    $LogStartEndTimeOutGridviewEntries.add([PSCustomObject]@{
        'Log Start and End time' = 'First 2 hours';
        'LogStartDateTimeObject' = Get-Date 1/1/1900;
        'LogEndDateTimeObject' = 'Analyzing DateTime later';
        })

    $LogStartEndTimeOutGridviewEntries.add([PSCustomObject]@{
        'Log Start and End time' = 'First 6 hours';
        'LogStartDateTimeObject' = Get-Date 1/1/1900;
        'LogEndDateTimeObject' = 'Analyzing DateTime later';
        })

    $LogStartEndTimeOutGridviewEntries.add([PSCustomObject]@{
        'Log Start and End time' = 'First 24 hours';
        'LogStartDateTimeObject' = Get-Date 1/1/1900;
        'LogEndDateTimeObject' = 'Analyzing DateTime later';
        })

    # Show predefined values in Out-GridView
    $SelectedTimeFrameObject = $LogStartEndTimeOutGridviewEntries | Out-GridView -Title 'Select timeframe to show log entries' -OutputMode Single
    
    if($SelectedTimeFrameObject) {
    $LogStartDateTimeObject = $SelectedTimeFrameObject.LogStartDateTimeObject
    $LogEndDateTimeObject = $SelectedTimeFrameObject.LogEndDateTimeObject
        
    } else {
        Write-Host "No timeframe selected." -ForegroundColor Red
        Write-Host "Script will exit"
        Exit 0        
    }
    

} else {

    # Set default time which can be changed depenging on -Parameters

    # Use StartTime from year 1900
    $LogStartDateTimeObject = Get-Date 1/1/1900

    # Use EndTime +1000 years from today
    $LogEndDateTimeObject = (Get-Date).AddYears(1000)

    if($Today) {
        $LogStartDateTimeObject = Get-Date -Hour 0 -Minute 0 -Second 0
        $LogEndDateTimeObject = (Get-Date).AddYears(1000)
    }

    if($AllLogEntries) {
        # Use StartTime from year 1900
        $LogStartDateTimeObject = Get-Date 1/1/1900
        
        # Use EndTime +1000 years from today
        $LogEndDateTimeObject = (Get-Date).AddYears(1000)
    }

    # Check $LogStartDateTime can be converted to Powershell DateTime object
    if($LogStartDateTime) {
        # Try converting given parameter to Powershell DateTime object
        
        Try {
            $LogStartDateTimeObject = Get-Date $LogStartDateTime
        } catch {
            Write-Host "Given parameter LogStartDateTime is not in valid DateTime format." -ForegroundColor Red
            Write-Host "Script will exit"
            Exit 0
        }
    }

    # Check $LogEndDateTime can be converted to Powershell DateTime object
    if($LogEndDateTime) {
        # Try converting given parameter to Powershell DateTime object
        
        Try {
            $LogEndDateTimeObject = Get-Date $LogEndDateTime
        } catch {
            Write-Host "Given parameter LogEndDateTime is not in valid DateTime format." -ForegroundColor Red
            Write-Host "Script will exit"
            Exit 0
        }
    }
}


# Download Powershell scripts, Proactive Remediation scripts and custom Compliance Policy scripts
if ($Online) {

    $GraphAuthenticationModule = $null
    $MgContext = $null

    # Test if we are in Enrollment Status Page (ESP) phase
    # Detect defaultuser0 loggedon
    if($env:UserName -eq 'defaultUser0') {

        # Make sure we can connect
        $GraphAuthenticationModule = Import-Module Microsoft.Graph.Authentication -PassThru -ErrorAction Ignore
        if (-not $GraphAuthenticationModule) {

            Write-Host "Installing module Microsoft.Graph.Authentication"
            Install-Module Microsoft.Graph.Authentication -Force
            $Success = $?
            
            if($Success) {
                Write-Host "Success`n" -ForegroundColor Green

                Write-Host "Import module Microsoft.Graph.Authentication"
                $GraphAuthenticationModule = Import-Module Microsoft.Graph.Authentication -PassThru -ErrorAction Ignore

                if($GraphAuthenticationModule) {
                    # Module imported successfully
                    Write-Host "Success`n" -ForegroundColor Green                    
                } else {
                    # Failed to import module
                    Write-Host "Failed to import module! Skip downloading script names...`n" -ForegroundColor Red
                }
            } else {
                Write-Host "Failed to install module! Skip downloading script names...`n" -ForegroundColor Red
            }
        }
        
        if($GraphAuthenticationModule) {
            
            Write-Host "Connect to Microsoft Graph API"
            
            $scopes = "DeviceManagementConfiguration.Read.All"
            $MgGraph = Connect-MgGraph -scopes $scopes
            $Success = $?

            if ($Success -and $MgGraph) {
                Write-Host "Success`n" -ForegroundColor Green

                # Get MgGraph session details
                $MgContext = Get-MgContext
                
                if($MgContext) {
                
                    $TenantId = $MgContext.TenantId
                    $AdminUserUPN = $MgContext.Account

                    Write-Host "Connected to Intune tenant:`n$TenantId`n$AdminUserUPN`n"

                } else {
                    Write-Host "Error getting MgContext information! Skip downloading script names..." -ForegroundColor Red
                }
                
            } else {
                Write-Host "Could not connect to Graph API! Skip downloading script names..." -ForegroundColor Red
            }
            
        } else {
            Write-Host "Could not connect to Graph API! Skip downloading script names..."  -ForegroundColor Red
        }
    
    } else {
        Write-Host "Connecting to Intune using module Microsoft.Graph.Authentication"

        Write-Host "Import module Microsoft.Graph.Authentication"
        Import-Module Microsoft.Graph.Authentication
        $Success = $?

        if($Success) {
            # Module imported successfully
            Write-Host "Success`n" -ForegroundColor Green
        } else {
            Write-Host "Failed"  -ForegroundColor Red
            Write-Host "Make sure you have installed module Microsoft.Graph.Authentication"
            Write-Host "You can install module without admin rights to your user account with command:`n`nInstall-Module -Name Microsoft.Graph.Authentication -Scope CurrentUser" -ForegroundColor Yellow
            Write-Host "`nor you can install machine-wide module with with admin rights using command:`nInstall-Module -Name Microsoft.Graph.Authentication"
            Write-Host ""
            Exit 1
        }


        Write-Host "Connect to Microsoft Graph API"

        $scopes = "DeviceManagementConfiguration.Read.All"
        $MgGraph = Connect-MgGraph -scopes $scopes
        $Success = $?

        if ($Success -and $MgGraph) {
            Write-Host "Success`n" -ForegroundColor Green

            # Get MgGraph session details
            $MgContext = Get-MgContext
            
            if($MgContext) {
            
                $TenantId = $MgContext.TenantId
                $AdminUserUPN = $MgContext.Account

                Write-Host "Connected to Intune tenant:`n$TenantId`n$AdminUserUPN`n"

            } else {
                Write-Host "Error getting MgContext information!`nScript will exit!" -ForegroundColor Red
                Exit 1
            }
            
        } else {
            Write-Host "Could not connect to Graph API!" -ForegroundColor Red
            Exit 1
        }
    }

    # Download Intune scripts information if we have connection to Microsoft Graph API
    if($MgContext) {

        Write-Host "Download Intune Powershell scripts"
        # Get PowerShell Scripts
        $uri = 'https://graph.microsoft.com/beta/deviceManagement/deviceManagementScripts'
        $AllIntunePowershellScripts = Invoke-MgGraphRequestGetAllPages -Uri $uri

        if($AllIntunePowershellScripts) {
            Write-Host "Done" -ForegroundColor Green
            
            # Add Name Property to object
            $AllIntunePowershellScripts | Foreach-Object { $_ | Add-Member -MemberType noteProperty -Name name -Value $_.displayName }
            
            # Add all PowershellScripts to Hashtable
            $AllIntunePowershellScripts | Foreach-Object { $id = $_.id; $value=$_; $IdHashtable["$id"] = $value }
            
            # Save locally for debugging
            #$AllIntunePowershellScripts | ConvertTo-Json -Depth 4 | Out-File -FilePath "$PSScriptRoot\AllIntunePowershellScripts.json" -Force
            
        } else {
            Write-Error "Did not find Intune Powershell scripts"
        }

        Start-Sleep -MilliSeconds 500
        
        Write-Host "Download Intune Remediations Scripts"
        # Get Proactive Remediations Scripts
        $uri = 'https://graph.microsoft.com/beta/deviceManagement/deviceHealthScripts'
        $AllIntuneProactiveRemediationsScripts = Invoke-MgGraphRequestGetAllPages -Uri $uri

        if($AllIntuneProactiveRemediationsScripts) {
            Write-Host "Done" -ForegroundColor Green
            
            # Add Name Property to object
            $AllIntuneProactiveRemediationsScripts | Foreach-Object { $_ | Add-Member -MemberType noteProperty -Name name -Value $_.displayName }
            
            # Add policyType 6
            # Which is Remediation script type
            $AllIntuneProactiveRemediationsScripts | Foreach-Object { $_ | Add-Member -MemberType noteProperty -Name policyType -Value 6 }
            
            # Add all PowershellScripts to Hashtable
            $AllIntuneProactiveRemediationsScripts | Foreach-Object { $id = $_.id; $value=$_; $IdHashtable["$id"] = $value }
                
            # Save locally for debugging
            #$AllIntuneProactiveRemediationsScripts | ConvertTo-Json -Depth 4 | Out-File -FilePath "$PSScriptRoot\AllIntuneProactiveRemediationsScripts.json" -Force
                
        } else {
            Write-Error "Did not find Intune Remediation scripts"
        }

        Start-Sleep -MilliSeconds 500

        Write-Host "Download Intune Windows Device Compliance custom Scripts"
        # Get Windows Device Compliance custom Scripts
        $uri = 'https://graph.microsoft.com/beta/deviceManagement/deviceComplianceScripts'
        $AllIntuneCustomComplianceScripts = Invoke-MgGraphRequestGetAllPages -Uri $uri

        if($AllIntuneCustomComplianceScripts) {
            Write-Host "Done" -ForegroundColor Green
            
            # Add Name Property to object
            $AllIntuneCustomComplianceScripts | Foreach-Object { $_ | Add-Member -MemberType noteProperty -Name name -Value $_.displayName }

            # Add policyType 8
            # Which is Custom Compliance script type
            $AllIntuneCustomComplianceScripts | Foreach-Object { $_ | Add-Member -MemberType noteProperty -Name policyType -Value 8 }
            
            # Add all PowershellScripts to Hashtable
            $AllIntuneCustomComplianceScripts | Foreach-Object { $id = $_.id; $value=$_; $IdHashtable["$id"] = $value }
            
            # Save locally for debugging
            #$AllIntuneCustomComplianceScripts | ConvertTo-Json -Depth 4 | Out-File -FilePath "$PSScriptRoot\AllIntuneCustomComplianceScripts.json" -Force
            
        } else {
            Write-Error "Did not find Intune Windows custom Compliance scripts"
        }
        
        Start-Sleep -MilliSeconds 500
        
        Write-Host "Download Intune Filters"
        $uri = 'https://graph.microsoft.com/beta/deviceManagement/assignmentFilters?$select=*'
        $AllIntuneFilters = Invoke-MgGraphRequestGetAllPages -Uri $uri

        if($AllIntuneFilters) {
            Write-Host "Done" -ForegroundColor Green
            
            # Add all Filters to Hashtable
            $AllIntuneFilters | Foreach-Object { $id = $_.id; $value=$_; $IdHashtable["$id"] = $value }
        } else {
            Write-Error "Did not find Intune filters"
        }
        
    } else {
        Write-Host "Not connected to Microsoft Intune, skip downloading script names...." -ForegroundColor Yellow
    }
}
Write-Host ""


# Run Report and/or Out-GridView in loop as long as user selects last line which will reload log file
# Otherwise Out-GridView will exit if something else is selected than last line (reload line)
do {

    # Create Generic list where log entry custom objects are added
    $LogEntryList = [System.Collections.Generic.List[PSObject]]@()
    
    # Go through all selected log file(s)
    Foreach ($SelectedLogFile in $SelectedLogFiles) {

        $LogFilePath = $SelectedLogFile.FullName
        $LogFileName = $SelectedLogFile.Name

        Write-Host "Processing file $LogFileName"

        # Initialize variables
        $LineNumber=1
        $MultilineLogEntryStartsArrayIndex=0
        $MultilineLogEntryStartFound=$False

        $Log = Get-Content -Path $LogFilePath

        # This matches for cmtrace type logs
        # Test with https://regex101.com
        # String: <![LOG[ExecutorLog AgentExecutor gets invoked]LOG]!><time="21:38:06.3814532" date="2-14-2023" component="AgentExecutor" context="" type="1" thread="1" file="">

        # This matches single line full log entry
        $SingleLineRegex = '^\<\!\[LOG\[(.*)]LOG\].*\<time="([0-9]{1,2}):([0-9]{1,2}):([0-9]{1,2}).([0-9]{1,})".*date="([0-9]{1,2})-([0-9]{1,2})-([0-9]{4})" component="(.*?)" context="(.*?)" type="(.*?)" thread="(.*?)" file="(.*?)">$'

        # Start of multiline log entry
        $FirstLineOfMultiLineLogRegex = '^\<\!\[LOG\[(.*)$'

        # End of multiline log entry
        $LastLineOfMultiLineLogRegex = '^(.*)\]LOG\]\!>\<time="([0-9]{1,2}):([0-9]{1,2}):([0-9]{1,2}).([0-9]{1,})".*date="([0-9]{1,2})-([0-9]{1,2})-([0-9]{4})" component="(.*?)" context="(.*?)" type="(.*?)" thread="(.*?)" file="(.*?)">$'


        # Process each log file line one by one
        Foreach ($CurrentLogEntry in $Log) {

            # Get data from CurrentLogEntry
            if($CurrentLogEntry -Match $SingleLineRegex) {
                # This matches single line log entry
                
                # Regex found match
                $LogMessage = $Matches[1].Trim()

                $Hour = $Matches[2]
                $Minute = $Matches[3]
                $Second = $Matches[4]
                
                $MilliSecondFull = $Matches[5]
                # Cut milliseconds to 0-999
                # Time unit is so small that we don't even bother to round the value
                $MilliSecond = $MilliSecondFull.Substring(0,3)

                $Month = $Matches[6]
                $Day = $Matches[7]
                $Year = $Matches[8]
                
                $Component = $Matches[9]
                $Context = $Matches[10]
                $Type = $Matches[11]
                $Thread = $Matches[12]
                $File = $Matches[13]

                $Param = @{
                    Hour=$Hour
                    Minute=$Minute
                    Second=$Second
                    MilliSecond=$MilliSecond
                    Year=$Year
                    Month=$Month
                    Day=$Day
                }

                $LogEntryDateTime = Get-Date @Param
                #Write-Host "DEBUG `$LogEntryDateTime: $LogEntryDateTime" -ForegroundColor Yellow
                
                # This works for humans but does not sort
                #$DateTimeToLogFile = "$($Hour):$($Minute):$($Second).$MilliSecondFull $Day/$Month/$Year"

                # Add leading 0 so sorting works right
                if($Month -like "?") { $Month = "0$Month" }

                # Add leading 0 so sorting works right
                if($Day -like "?") { $Day = "0$Day" }

                # This does sorting right way
                $DateTimeToLogFile = "$Year-$Month-$Day $($Hour):$($Minute):$($Second).$MilliSecondFull"

                # Create Powershell custom object and add it to list
                $LogEntryList.add([PSCustomObject]@{
                    'Index' = $null;
                    'LogEntryDateTime' = $LogEntryDateTime;
                    'FileName' = $LogFileName;
                    'Line' = $LineNumber;
                    'DateTime' = $DateTimeToLogFile;
                    'Multiline' = '';
                    'ProcessRunTime' = $ProcessRunTime;
                    'Message' = $LogMessage;
                    'Component' = $Component;
                    'Context' = $Context;
                    'Type' = $Type;
                    'Thread' = $Thread;
                    'File' = $File
                    })

            } elseif ($CurrentLogEntry -Match $FirstLineOfMultiLineLogRegex) {
                # This is start of multiline log entry
                # Single line regex did not get results so we are dealing multiline case separately here

                #Write-Host "DEBUG Start of multiline regex: $CurrentLogEntry" -ForegroundColor Yellow

                $MultilineLogEntryStartFound=$True

                # Regex found match
                $LogMessage = $Matches[1].Trim()
                
                $LogEntryDateTime = ''
                $DateTimeToLogFile = ''
                $Component = ''
                $Context = ''
                $Type = ''
                $Thread = ''
                $File = ''

                # Create Powershell custom object and add it to list
                $LogEntryList.add([PSCustomObject]@{
                    'Index' = $null;
                    'LogEntryDateTime' = $LogEntryDateTime;
                    'FileName' = $LogFileName;
                    'Line' = $LineNumber;
                    'DateTime' = $DateTimeToLogFile;
                    'Multiline' = '';
                    'ProcessRunTime' = '';
                    'Message' = $LogMessage;
                    'Component' = $Component;
                    'Context' = $Context;
                    'Type' = $Type;
                    'Thread' = $Thread;
                    'File' = $File
                    })

                $MultilineLogEntryStartsArrayIndex = $LogEntryList.Count - 1

            } elseif ($CurrentLogEntry -Match $LastLineOfMultiLineLogRegex) {
                # This is end of multiline log entry
                # Single line regex did not get results so we are dealing multiline case separately here

                # Regex found match
                $LogMessage = $Matches[1].Trim()

                $Hour = $Matches[2]
                $Minute = $Matches[3]
                $Second = $Matches[4]
                
                $MilliSecondFull = $Matches[5]
                # Cut milliseconds to 0-999
                # Time unit is so small that we don't even bother to round the value
                $MilliSecond = $MilliSecondFull.Substring(0,3)
                
                $Month = $Matches[6]
                $Day = $Matches[7]
                $Year = $Matches[8]
                
                $Component = $Matches[9]
                $Context = $Matches[10]
                $Type = $Matches[11]
                $Thread = $Matches[12]
                $File = $Matches[13]

                $Param = @{
                    Hour=$Hour
                    Minute=$Minute
                    Second=$Second
                    MilliSecond=$MilliSecond
                    Year=$Year
                    Month=$Month
                    Day=$Day
                }

                $LogEntryDateTime = Get-Date @Param
                #Write-Host "DEBUG `$LogEntryDateTime: $LogEntryDateTime" -ForegroundColor Yellow

                # This works for humans but does not sort
                #$DateTimeToLogFile = "$($Hour):$($Minute):$($Second).$MilliSecondFull $Day/$Month/$Year"

                # Add leading 0 so sorting works right
                if($Month -like "?") { $Month = "0$Month" }

                # Add leading 0 so sorting works right
                if($Day -like "?") { $Day = "0$Day" }

                # This does sorting right way
                $DateTimeToLogFile = "$Year-$Month-$Day $($Hour):$($Minute):$($Second).$MilliSecondFull"

                if($MultilineLogEntryStartFound) {
                    $Multiline = '---->'
                } else {
                    $Multiline = ''
                }

                # Create Powershell custom object and add it to list
                $LogEntryList.add([PSCustomObject]@{
                    'Index' = $null;
                    'LogEntryDateTime' = $LogEntryDateTime;
                    'FileName' = $LogFileName;
                    'Line' = $LineNumber;
                    'DateTime' = $DateTimeToLogFile;
                    'Multiline' = $Multiline;
                    'ProcessRunTime' = '';
                    'Message' = $LogMessage;
                    'Component' = $Component;
                    'Context' = $Context;
                    'Type' = $Type;
                    'Thread' = $Thread;
                    'File' = $File
                    })

                # Process previous lines and add DateTime, Multiline, Component, Context, Type, Thread and File
                # There can be corrupted log entries so there can be multiline entries without proper multiline start log entry
                # So we will add information until we find log entry which has DateTime value

                $MultilineLogEntryEndsArrayIndex = $LogEntryList.Count - 1
                
                # Add data to starting multiline log entry object
                # DateTime, Component, Context, Type, Thread, File information
                $LogEntryList[$MultilineLogEntryStartsArrayIndex].LogEntryDateTime = $LogEntryDateTime
                $LogEntryList[$MultilineLogEntryStartsArrayIndex].DateTime = $DateTimeToLogFile
                $LogEntryList[$MultilineLogEntryStartsArrayIndex].Multiline = '<----'
                $LogEntryList[$MultilineLogEntryStartsArrayIndex].Component = $Component
                $LogEntryList[$MultilineLogEntryStartsArrayIndex].Context = $Context
                $LogEntryList[$MultilineLogEntryStartsArrayIndex].Type = $Type
                $LogEntryList[$MultilineLogEntryStartsArrayIndex].Thread = $Thread
                $LogEntryList[$MultilineLogEntryStartsArrayIndex].File = $File
                
                # Add data to multiline entries which are not end or start entries
                #if($MultilineLogEntryEndsArrayIndex - $MultilineLogEntryStartsArrayIndex -gt 1) {
                    # Multiline entry is more than 2 lines

                    # Add DateTime, Component, Context, Type, Thread, File information
                    # to Multiline log entry from last to first
                    #for($i=$MultilineLogEntryEndsArrayIndex - 1; $i -gt $MultilineLogEntryStartsArrayIndex ; $i--) {
                    
                    # Add information to previous log entries if they don't have DateTime-value
                    # Stop when we find DateTime-value
                    $i = $MultilineLogEntryEndsArrayIndex -1
                    While($LogEntryList[$i].DateTime -eq $null) {
                        #Write-Host "DEBUG: Multiline add data: `$MultilineLogEntryStartsArrayIndex=$MultilineLogEntryStartsArrayIndex `$MultilineLogEntryEndsArrayIndex=$MultilineLogEntryEndsArrayIndex `$i=$i `$Message=$($LogEntryList[$i].Message)"
                        
                        $LogEntryList[$i].LogEntryDateTime = $LogEntryDateTime
                        $LogEntryList[$i].DateTime = $DateTimeToLogFile
                        $LogEntryList[$i].Multiline = ' --- '
                        $LogEntryList[$i].Component = $Component
                        $LogEntryList[$i].Context = $Context
                        $LogEntryList[$i].Type = $Type
                        $LogEntryList[$i].Thread = $Thread
                        $LogEntryList[$i].File = $File
                        
                        $i--
                    }
                    
            } else {
                # We didn't catch log entry with our regex
                # This should be multiline log entry but not first or last line in that log entry
                # This can also be some line that should be matched with (other) regex
                
                #Write-Host "DEBUG: $CurrentLogEntry" -ForegroundColor Yellow
                
                # Create Powershell custom object and add it to list
                $LogEntryList.add([PSCustomObject]@{
                    'Index' = $null;
                    'LogEntryDateTime' = $null;
                    'FileName' = $LogFileName;
                    'Line' = $LineNumber;
                    'DateTime' = $null;
                    'Multiline' = '';
                    'ProcessRunTime' = $null;
                    'Message' = $CurrentLogEntry;
                    'Component' = '';
                    'Context' = '';
                    'Type' = '';
                    'Thread' = '';
                    'File' = ''
                    })
            }

            $LineNumber++

        } # Foreach end single log file line by line foreach
    
    } # Foreach end all log files
    Write-Host ""

    # Check if there was log entries/lines which were not detected
    $DroppedLines = $LogEntryList | Where-Object DateTime -eq $null
    if($DroppedLines) {
        Write-Host "These lines were detected not to have right syntax so they were dropped" -ForegroundColor Yellow
        Foreach($Line in $DroppedLines) {
            Write-Host "$($Line.FileName) Line $($Line.Line) $($Line.Message)"
            
            # FIXME Copy timestamp from previous log entry
            
        }
        Write-Host ""
    }


    # Take last $LogEntryDateTime and add 1 seconds to it
    $ReloadDate = (Get-Date).AddHours(1)
    $ReloadDateTime = "$($ReloadDate.Year)-$($ReloadDate.Month)-$($ReloadDate.Day) $($ReloadDate.TimeOfDay)"

    # Add last entry which will reload log file if selected
    $LogEntryList.add([PSCustomObject]@{
                'Index' = $null;
                'LogEntryDateTime' = $ReloadDate;
                'FileName' = '';
                'Line' = '';
                'DateTime' = $ReloadDateTime;
                'Multiline' = '';
                'ProcessRunTime' = '';
                'Message' = 'RELOAD LOG FILE(S). Select this line and OK button from right botton corner';
                'Component' = '';
                'Context' = '';
                'Type' = '';
                'Thread' = '';
                'File' = ''
                })



    # Check if First x minutes/hours was selected
    if($LogEndDateTimeObject -eq 'Analyzing DateTime later') {
        # Sort log entries by DateTime and secondary by Line number
        # because with multiple files we are not organized by default
        # Find first log entry with LogEntryDateTime value
        # There is some rare case there could be log entry with empty value
        Foreach ($LogEntryObject in $LogEntryList | Sort-Object -Property DateTime, Line) {
            Try {
                $FirstLogEntryTimeDate = Get-Date $LogEntryObject.LogEntryDateTime
                Break
            } catch {
                
            }
        }

        # And figure out LogEndDateTime based on first log entry timestamp
        if($SelectedTimeFrameObject.'Log Start and End time' -eq 'First 30 minutes') {
            $LogEndDateTimeObject = ($FirstLogEntryTimeDate).AddMinutes(30)
        }

        if($SelectedTimeFrameObject.'Log Start and End time' -eq 'First 1 hour') {
            $LogEndDateTimeObject = ($FirstLogEntryTimeDate).AddHours(1)
        }

        if($SelectedTimeFrameObject.'Log Start and End time' -eq 'First 2 hours') {
            $LogEndDateTimeObject = ($FirstLogEntryTimeDate).AddHours(2)
        }

        if($SelectedTimeFrameObject.'Log Start and End time' -eq 'First 6 hours') {
            $LogEndDateTimeObject = ($FirstLogEntryTimeDate).AddHours(6)
        }
        
        if($SelectedTimeFrameObject.'Log Start and End time' -eq 'First 24 hours') {
            $LogEndDateTimeObject = ($FirstLogEntryTimeDate).AddHours(24)
        }

        Write-Host "Log entries End time: $LogEndDateTimeObject`n"
    }

    Write-Host "Creating report"
    Write-Host " Start time: $LogStartDateTimeObject"
    Write-Host " End time: $LogEndDateTimeObject`n"


    # Filter and Sort objects
    # We need to sort with DateTime AND Line
    # because there can are multiple log entries with same timestamp
    #$LogEntryList = $LogEntryList | Sort-Object -Property DateTime, Line
    $LogEntryList = $LogEntryList | Where-Object { ($_.LogEntryDateTime -gt $LogStartDateTimeObject) -and ($_.LogEntryDateTime -lt $LogEndDateTimeObject) } | Sort-Object -Property DateTime, Line

    # Add Index property value
    # This is used for sorting when doing analyzing in Out-GridView
    $index = 1
    Foreach($LogEntry in $LogEntryList) {
        
        # Use this to find out if we did catch all lines
        # Look for the first object index and line numbers that if they match
        #Write-host "$index $($LogEntry.Line) $($LogEntry.Message)"

        # DateTime is empty if there was corrupted log entries
        # We should set last known timestamp to those but this is just in case we still missed something
        if($LogEntry.DateTime) {
            $LogEntry.Index = $index
            $index++
        }
    }


    # region analyze log entries
    ##########################
    # Analyze log entries
    #
    # Try to find end and start log entries for Powershell, Win32App and WinGetApp installations
    # We go backwards because if there is process end log entry
    # then there has to be start log entry (going upwards) for the same process (same thread in this case)
    #
    # Start process could be "orphan" if process is stopped without end log entry
    # so it is hard to detect these cases (going from up to down)

    $DetectedInEspPhase=$false
    $skipDeviceStatusPage='NotFoundYet'
    $skipUserStatusPage='NotFoundYet'
    $skipDeviceStatusPageIndex=$null
    $skipUserStatusPageIndex=$null
    $NotInEspInSessionFound=$false
    $UserESPFinished=$false
    $UserESPFinishedNotFoundWarningMessageShown=$false
    
    # Device Preparation "APv2"
    $DetectedInAPv2Mode=$false
    $DetectedOutFromAPv2Mode=$false
    $APv2ModeStartTime = $null
    $APv2ModeEndTime = $null
    
    
    $PreviousLoggedOnUser='NotInitializedYet'
    $LoggedOnUserDefaultUser0Shown=$false

    $PowershellScriptStartLogEntryFound=$false
    $PowershellScriptStartLogEntryIndex=$null

    # Array
    $PowershellScriptErrorLogs = @()
    $IntuneAppPolicies = $null
    
    # This is index of colon character in timeline
    # This uses -f formatting syntax
    $ColonIndentInTimeLine = -22

    # Info message about Windows 365 Apps and LOB MSI -apps not showing
    $Detail = 'Possible Microsoft 365 Apps and Intune LOB MSI Apps are not shown in this report'
    $DetailToolTip = "Microsoft 365 Apps and `"legacy`" Line-of-business apps (MSI) are installed by Windows MDM channel`nand not installed by Intune Management Extension agent.`n`nCurrent report version does not track those installation.`n`nFuture version might track mentioned app types in Windows Autopilot Enrollment Status Page (ESP) phase.`n`nThis could be a good idea so you would see all Windows Autopilot enrollment process app installations in one report."
    
    RecordStatusToTimeline -date "1900-01-01 23:59:59.0000000" -status 'Info' -detail $Detail -DetailToolTip $DetailToolTip -logEntry "0"

    
    $i = 0
    While ($i -lt $LogEntryList.Count) {
        # Clear variables in every loop round
        $LoggedOnUser=$false

        # DEBUG manually line by line
        #Write-Host "$($LogEntryList[$i].Message) - `$i=$($i)"
        #Pause


        # region Find Username and UserSID
        if($LogEntryList[$i].Message -Like '`[Win32App`] Expected usersid for session *') {
            $Matches=$null
            if($LogEntryList[$i].Message -Match '^\[Win32App\] Expected usersid for session (.*) with name (.*) is (.*)$') {
                $UserSession = $Matches[1]
                $UserName = $Matches[2]
                $UserSID = $Matches[3]

                #Write-Host "DEBUG: $($LogEntryList[$i].Message)"

                # Check if we have UserSID object in Hashtable UserId object
                if($IdHashtable.ContainsKey($UserSId)) {
                    $IdHashtable[$UserSId].userSID = $UserSID
                    $IdHashtable[$UserSId].name = $UserName
                    $IdHashtable[$UserSId].displayName = $UserName
                    $IdHashtable[$UserSId].session = $UserSession

                    $userId = $IdHashtable[$UserSId].userId
                    if($userId) {
                        # Check if we have UserID object in Hashtable
                        if($IdHashtable.ContainsKey($UserId)) {
                                # Add Name-value. We don't even check existing value
                                # because we are already here.
                                $IdHashtable[$UserId].userSID = $UserSID
                                $IdHashtable[$UserId].name = $UserName
                                $IdHashtable[$UserId].displayName = $UserName
                                $IdHashtable[$UserId].session = $UserSession
                        }
                    }
                } else {
                    # UserSID object does not exist in Hashtable
                    # Create object which we will add to Hashtable
                    $UserSIDCustomObjectProperties = @{
                        id = $null
                        userId = $null
                        userSID = $UserSID
                        name = $UserName
                        displayName = $UserName
                        session = $UserSession
                    }
                    $UserSIDCustomObject = New-Object -TypeName PSObject -Prop $UserSIDCustomObjectProperties

                    # Create new UserId hashtable entry
                    $IdHashtable.Add($UserSId , $UserSIDCustomObject)
                }
                
                #Write-Host "DEBUG: We should have name Property now`n$($IdHashtable | ConvertTo-Json)"
            }

            # Cleanup variables
            $UserSession = $null
            $UserName = $null
            $UserSID= $null
            $UserSIDCustomObject = $null
        }
        # endregion Find UserName and UserSID



        # region EMS Agent Started/Stopped/received shutdown signal
        # Excluded -or ($LogEntryList[$i].Message -eq 'EMS Agent received shutdown signal')
        if(($LogEntryList[$i].Message -eq 'EMS Agent Started') -or ($LogEntryList[$i].Message -eq 'EMS Agent Stopped')) {

                $LogEntryList[$i].ProcessRunTime = "$($LogEntryList[$i].Message)"
                
                RecordStatusToTimeline -date $LogEntryList[$i].DateTime -status 'Info' -detail $LogEntryList[$i].Message -logEntry "Line $($LogEntryList[$i].Line)"
                
                # Continue to next line in Do-while -loop
                $i++
                Continue
        }
        # endregion EMS Agent Started/Stopped/received shutdown signal
        
        # region Find ESP SkipESPStatusPage(s)
        if($skipDeviceStatusPage -eq 'NotFoundYet') {
            $Matches=$null
            if($LogEntryList[$i].Message -Match '^\[Win32App\] CheckDeviceOnlyFromFirstSyncReg set skipDeviceStatusPage: (.*)$') {
                $skipDeviceStatusPage = $Matches[1]
                $LogEntryList[$i].ProcessRunTime = "Skip Device ESP: $skipDeviceStatusPage"
                
                $skipDeviceStatusPageIndex=$i
                
                # Continue to next line in Do-while -loop
                $i++
                Continue
            }
        }

        # region Find ESP SkipESPStatusPage(s)
        if($skipUserStatusPage -eq 'NotFoundYet') {
            $Matches=$null
            if($LogEntryList[$i].Message -Match '^\[Win32App\] CheckDeviceOnlyFromFirstSyncReg set skipUserStatusPage: (.*)$') {
                $skipUserStatusPage = $Matches[1]
                $LogEntryList[$i].ProcessRunTime = "Skip User ESP : $skipUserStatusPage"
                
                $skipUserStatusPageIndex=$i
                
                # Continue to next line in Do-while -loop
                $i++
                Continue
            }
        }
        # endregion Find ESP SkipESPStatusPage(s)
        
        
        # region Find ESP (Enrollment Status Page) phases
        if(-not $DetectedInEspPhase) {
            if($LogEntryList[$i].Message -eq '[Win32App] In EspPhase: DeviceSetup') {
                $DetectedInEspPhase = $True

                # This is here because these lines exists later after ESP
                # So only showing this information if we are in ESP
                if($skipDeviceStatusPage -eq 'True') {
                    
                    RecordStatusToTimeline -date $LogEntryList[$skipDeviceStatusPageIndex].DateTime -status "Info" -detail "Skip Device ESP: $skipDeviceStatusPage" -logEntry "Line $($LogEntryList[$skipDeviceStatusPageIndex].Line)" -color 'Yellow'
                    
                } else {
                    
                    RecordStatusToTimeline -date $LogEntryList[$skipDeviceStatusPageIndex].DateTime -status "Info" -detail "Skip Device ESP: $skipDeviceStatusPage" -logEntry "Line $($LogEntryList[$skipDeviceStatusPageIndex].Line)"
                }
                
                # This is here because these lines exists later after ESP
                # So only showing this information if we are in ESP
                if($skipUserStatusPage -eq 'True') {
                    
                    RecordStatusToTimeline -date $LogEntryList[$skipUserStatusPageIndex].DateTime -status "Info" -detail "Skip User ESP: $skipUserStatusPage" -logEntry "Line $($LogEntryList[$skipUserStatusPageIndex].Line)" -color 'Yellow'
                } else {
                    
                    RecordStatusToTimeline -date $LogEntryList[$skipUserStatusPageIndex].DateTime -status "Info" -detail "Skip User ESP: $skipUserStatusPage" -logEntry "Line $($LogEntryList[$skipUserStatusPageIndex].Line)"
                }

                $LogEntryList[$i].ProcessRunTime = "[Win32App] In Device ESP phase detected"
                
                RecordStatusToTimeline -date $LogEntryList[$i].DateTime -status "Info" -detail "######################## Device ESP phase ########################" -logEntry "Line $($LogEntryList[$i].Line)"
                
                # Continue to next line in Do-while -loop
                $i++
                Continue
            }
        }
        # endregion Find ESP (Enrollment Status Page) phases

        # region User ESP finished
        if(-not $UserESPFinished) {
            if($LogEntryList[$i].Message -like '`[Win32App`] In EspPhase: AccountSetup. All Win32App has been added. Set SidecarReady: True*') {
                $UserESPFinished = $True

                $LogEntryList[$i].ProcessRunTime = "User ESP finished successfully"
                
                RecordStatusToTimeline -date $LogEntryList[$i].DateTime -status "Info" -detail "######################## User ESP finished ########################" -logEntry "Line $($LogEntryList[$i].Line)"
                
                # Continue to next line in Do-while -loop
                $i++
                Continue
            }
        }
        # endregion User ESP finished


        # region NotInEsp in session
        if((-not $NotInEspInSessionFound) -and (-not $DetectedInAPv2Mode)) {
            if($LogEntryList[$i].Message -eq '[Win32App] The EspPhase: NotInEsp in session') {
                $NotInEspInSessionFound = $True

                $LogEntryList[$i].ProcessRunTime = "[Win32App] The EspPhase: NotInEsp in session"
                
                RecordStatusToTimeline -date $LogEntryList[$i].DateTime -status "Info" -detail "######################## Not in ESP detected ########################" -logEntry "Line $($LogEntryList[$i].Line)"
                
                # Continue to next line in Do-while -loop
                $i++
                Continue
            }
        }
        # endregion NotInEsp in session

        # region NotInESP found but did not find User ESP Finished
        # Show warning on this case. Did User ESP fail ???
        if((-not $UserESPFinishedNotFoundWarningMessageShown) -and ($DetectedInEspPhase) -and ((-not $UserESPFinished) -and ($NotInEspInSessionFound))) {
            $UserESPFinishedNotFoundWarningMessageShown=$True
        
            RecordStatusToTimeline -date $LogEntryList[$i].DateTime -status "Warning" -detail "Did not detect User ESP finished. This is usually falsepositive warning." -logEntry "Line $($LogEntryList[$i].Line)" -Color 'Yellow'
            
        }
        # endregion NotInESP found but did not find User ESP Finished


        # region APv2

        # Device Preparation mode detected
        if($DetectedInAPv2Mode -eq $false) {
            # Works also
            #if($LogEntryList[$i].Message -match "\[APv2\] Device is in APv2 mode: True.") {

            if($LogEntryList[$i].Message -eq '[APv2] Device is in APv2 mode: True.') {

                $LogEntryList[$i].ProcessRunTime = "Device Preparation mode (APv2) detected"
                
                $DetectedInAPv2Mode = $True
                
                $APv2ModeStartTime = $LogEntryList[$i].DateTime
                
                RecordStatusToTimeline -date $LogEntryList[$i].DateTime -status "Info" -detail "######################## Device Preparation mode (APv2) detected ########################" -logEntry "Line $($LogEntryList[$i].Line)" -Color 'Yellow'
                
                # Continue to next line in Do-while -loop
                $i++
                Continue
            }
        }        
        

        # Out from Device Preparation mode detected
        if(($DetectedInAPv2Mode -eq $True) -and ($DetectedOutFromAPv2Mode -eq $False)) {
            if($LogEntryList[$i].Message -eq '[APv2] Successfully disabled APv2 mode.') {
                
                $DetectedOutFromAPv2Mode = $True
                
                $APv2ModeEndTime = $LogEntryList[$i].DateTime
                
                # Get Deployment time in minutes for event message
                $APv2DeploymentTimeMinutes = $null
                $APv2DeploymentTimeSeconds = $null
                
                if($APv2ModeStartTime -and $APv2ModeEndTime) {
                    $APv2DeploymentDurationDateTimeObject = New-Timespan (Get-Date $APv2ModeStartTime) (Get-Date $APv2ModeEndTime)

                    # Get totalminutes
                    $APv2DeploymentTimeTotalMinutes = $APv2DeploymentDurationDateTimeObject.TotalMinutes
                    $APv2DeploymentTimeTotalMinutes = [math]::Round($APv2DeploymentTimeTotalMinutes,0)
                    
                    # Get seconds
                    $APv2DeploymentTimeSeconds = $APv2DeploymentDurationDateTimeObject.Seconds
                }

                $DeploymentTimeDetail = $null
                if($APv2DeploymentTimeTotalMinutes -and $APv2DeploymentTimeSeconds) {
                    $DeploymentTimeDetail = "Deployment time $APv2DeploymentTimeTotalMinutes Minutes $APv2DeploymentTimeSeconds Seconds"
                }
            
                $LogEntryList[$i].ProcessRunTime = "$Detail $DeploymentTimeDetail"
            
                RecordStatusToTimeline -date $LogEntryList[$i].DateTime -status "Info" -detail "######################## Device Preparation mode (APv2) disabled. ########################" -logEntry "Line $($LogEntryList[$i].Line)" -Color 'Yellow'
                
                if($DeploymentTimeDetail) {
                    RecordStatusToTimeline -date $LogEntryList[$i].DateTime -status "Info" -detail "######################## $DeploymentTimeDetail ########################" -logEntry "Line $($LogEntryList[$i].Line)" -Color 'Yellow'
                }
                
                # Continue to next line in Do-while -loop
                $i++
                Continue
            }
        }        


        
        # endregion APv2

        
        # region Find Logged on user
        if($LogEntryList[$i].Message -Like 'After impersonation: *') {
            $Matches=$null
            if($LogEntryList[$i].Message -Match '^After impersonation: (.*)$') {
                $LoggedOnUser = $Matches[1]
                
                #Write-Host "DEBUG: `$FirstLoggedOnUser`=$FirstLoggedOnUser"
                # Skip if defaultuser0 found (we are in device ESP phase still)
                if(-not $LoggedOnUserDefaultUser0Shown) {
                    if($LoggedOnUser -like '*\defaultuser0') {
                        $LoggedOnUserDefaultUser0Shown = $True
                        
                        RecordStatusToTimeline -date $LogEntryList[$i].DateTime -status "Info" -type "User" -Intent "Logon" -detail "$LoggedOnUser" -logEntry "Line $($LogEntryList[$i].Line)" -Color 'Yellow'
            
                        # Set LoggedOnUser information to ProcessRuntime property
                        $LogEntryList[$i].ProcessRunTime = "Logged on user: $LoggedOnUser"

                        # Extract computer name
                        $ComputerNameForReport = $LoggedOnUser.Split('\')[0]

                        $i++
                        Continue
                    }
                } elseif (($LoggedOnUser -notlike '*\defaultuser0') -and ($LoggedOnUser -ne $PreviousLoggedOnUser)) {
                    $PreviousLoggedOnUser=$LoggedOnUser

                    RecordStatusToTimeline -date $LogEntryList[$i].DateTime -status "Info" -type "User" -Intent "Logon" -detail "$LoggedOnUser" -logEntry "Line $($LogEntryList[$i].Line)" -Color 'Yellow'
                    
                    $LogEntryList[$i].ProcessRunTime = "Logged on user: $LoggedOnUser"

                }


                # Not sure if we want to run this everytime or not
                # Now running it every time we find this log entry
                if($True) {
                    # Try to find user session from previous log entry
                    if($LogEntryList[$i-1].Message -Match '^Starting impersonation, session id = (.*)$') {
                        $ImpersonationSessionId = $Matches[1]

                        #Write-Host "Found ImpersonationSessionId $ImpersonationSessionId"

                        # Try to find UserId from either of following lines
                        # [PowerShell] Processing user session 3, user id = cdc3177c-df70-45e3-a469-4ef1be2b8345
                        # [PowerShell] Get 3 policies for user cdc3177c-df70-45e3-a469-4ef1be2b8345 in session 3
                        $b = $i + 1
                        While(($LogEntryList[$b].Message -notlike 'After impersonation: *') -and ($b -lt $LogEntryList.Count)) {
                            #Write-Host "DEBUG: Processing line: $($LogEntryList[$b].Message)"
                            
                            if(($LogEntryList[$i].Thread -eq $LogEntryList[$b].Thread) -and (($LogEntryList[$b].Message -Match '^\[PowerShell\] Processing user session (.*), user id = (.*)$') -or ($LogEntryList[$b].Message -Match '^\[PowerShell\] Get (.*) policies for user (.*) in session (.*)$') -or ($LogEntryList[$b].Message -Match '^\[Win32App\] Got (.*) Win32App(s) for user (.*) in session (.*)$'))) {
                                #Write-Host "DEBUG: Found matching line: $($LogEntryList[$b].Message)"
                                
                                # Save to temporary variables because next -Match will overwrite existing $Matches -variable
                                $Matches1 = $Matches[1]
                                $Matches2 = $Matches[2]
                                
                                # Either one is GUID so we need to test which $Matches variable is valid GUID and which is sessionId
                                if($Matches1 -Match '[[0-9a-fA-F]{8}-([0-9a-fA-F]{4}-){3}[0-9a-fA-F]{12}') {
                                    $UserId = $Matches1
                                    $UserSession = $Matches2
                                } elseif ($Matches2 -Match '[[0-9a-fA-F]{8}-([0-9a-fA-F]{4}-){3}[0-9a-fA-F]{12}') {
                                    $UserSession = $Matches1
                                    $UserId = $Matches2
                                } else {
                                    # Something strange happened and we did not get GUID
                                    # We should not be here
                                    $UserSession = $null
                                    $UserId = $null
                                }

                                #Write-Host "`$UserId=$UserId"
                                #Write-Host "`$UserSession=$UserSession"
                                #Write-Host "`$ImpersonationSessionId=$ImpersonationSessionId"
                                
                                if($ImpersonationSessionId -eq $UserSession) {
                                    # We are quite sure we have right id for UserName
                                    
                                    # UserSID object does not exist in Hashtable
                                    # Create object which we will add to Hashtable
                                    if($IdHashtable.ContainsKey($UserId)) {
                                        $IdHashtable[$UserId].id = $UserId
                                        $IdHashtable[$UserId].userId = $UserId
                                        $IdHashtable[$UserId].name = $LoggedOnUser
                                        $IdHashtable[$UserId].displayName = $LoggedOnUser
                                        $IdHashtable[$UserId].session = $UserSession

                                    } else {
                                        $UserIdCustomObjectProperties = @{
                                            id = $UserId
                                            userId = $UserId
                                            userSID = $null
                                            name = $LoggedOnUser
                                            displayName = $LoggedOnUser
                                            session = $UserSession
                                        }
                                        $UserIdCustomObject = New-Object -TypeName PSObject -Prop $UserIdCustomObjectProperties

                                        # Create new UserId hashtable entry
                                        $IdHashtable.Add($UserId , $UserIdCustomObject)                                    
                                    }
                                    
                                    #Write-Host "DEBUG: We should have UserID and Username`n$($IdHashtable | ConvertTo-Json)"
                                }
                                
                                # Break out from While-loop
                                Break
                            }
                            $b++
                        }
                    }
                }

                
                # Continue to next line in Do-while -loop
                $i++
                Continue
            }
        }
        # endregion Find Logged on user


        ###############################################
        # Powershell scripts
        
        
        # region Find Powershell Script Start log entry
        if($LogEntryList[$i].Message -like '`[PowerShell`] Processing policy with id = *') {
            if($LogEntryList[$i].Message -Match '^\[PowerShell\] Processing policy with id = (.*) for user (.*)$') {
                $PowershellScriptPolicyIdStart = $Matches[1]
                $PowershellScriptForUserIdStart = $Matches[2]
                
                $PowershellScriptStartLogEntryIndex=$i

                $IntuneScriptContentInClearText = $null
                $DetailToolTip = $null

                # This variables is probably not needed because
                # we can detect end and start log entries separately
                $PowershellScriptStartLogEntryFound=$True
                
                if($PowershellScriptForUserIdStart -eq '00000000-0000-0000-0000-000000000000') {
                    $PowershellScriptForUserNameStart = 'System'
                } else {

                    # Get real name if found from Hashtable
                    if($IdHashtable.ContainsKey($PowershellScriptForUserIdStart)) {
                        $Name = $IdHashtable[$PowershellScriptForUserIdStart].name
                        if($Name) {
                            $PowershellScriptForUserNameStart = $IdHashtable[$PowershellScriptForUserIdStart].name
                        } else {
                            $PowershellScriptForUserNameStart = $PowershellScriptForUserIdStart
                        }
                    } else {
                        $PowershellScriptForUserNameStart = $PowershellScriptForUserIdStart
                    }
                }
                
                # Get Powershell script real name if parameter -Online was specified
                # and we have downloaded Powershell script information
                if($IdHashtable.ContainsKey($PowershellScriptPolicyIdStart)) {
                    # Powershell script information was downloaded earlier
                    $PowershellScriptPolicyNameStart = $IdHashtable[$PowershellScriptPolicyIdStart].name

                } else {
                    # We set Powershell script name to be the Id which we found from log file
                    $PowershellScriptPolicyNameStart = $PowershellScriptPolicyIdStart
                }

                # Get Powershell script in clear text if possible
                $IntuneScriptContentInClearText = Get-IntunePowershellScriptContentInCleartext $PowershellScriptPolicyIdStart
                $detailToolTip = "##########################################`n# Script:`n`n$IntuneScriptContentInClearText"
                
                if($ShowAllTimelineEvents) {

                    if($DetailToolTip) {
                        # We have $DetailToolTip which includes the Powershell script in clear text
                        
                        RecordStatusToTimeline -date $LogEntryList[$i].DateTime -status "Start" -Type "Powershell script" -Intent "Execute" -detail "`t$PowershellScriptPolicyNameStart for user $PowershellScriptForUserNameStart" -detailToolTip $DetailToolTip -logEntry "Line $($LogEntryList[$i].Line)"
                    } else {
                        # We do NOT have $DetailToolTip which includes the Powershell script in clear text
                        
                        RecordStatusToTimeline -date $LogEntryList[$i].DateTime -status "Start" -Type "Powershell script" -Intent "Execute" -detail "`t$PowershellScriptPolicyNameStart for user $PowershellScriptForUserNameStart" -logEntry "Line $($LogEntryList[$i].Line)"
                    }
                }
                        
                # Set Powershell Script Start information to ProcessRuntime property
                $LogEntryList[$i].ProcessRunTime = "Start Powershell script ($PowershellScriptPolicyNameStart) for user $PowershellScriptForUserNameStart"

                # Continue to next line in Do-while -loop
                $i++
                Continue
            }
        }
        # endregion Find Powershell Script Start log entry

        # region Find Powershell Script End log entry
        if($LogEntryList[$i].Message -like '`[PowerShell`] User Id = *') {
            if($LogEntryList[$i].Message -Match '^\[PowerShell\] User Id = (.*), Policy id = (.*), policy result = (.*)$') {
                $PowershellScriptForUserIdEndProcess = $Matches[1]
                $PowershellScriptPolicyIdEndProcess = $Matches[2]
                $PowershellScriptPolicyResultEndProcess = $Matches[3]

                $IntuneScriptContentInClearText = $null
                $DetailToolTip = $null

                $scriptOutputStringArray = @()
                $OutputFound = $false

                # This variables is probably not needed because
                # we can detect end and start log entries separately
                $PowershellScriptEndLogEntryFound=$True

                $FoundPowershellScriptEndMessageIndex = $i
                
                $b = $i - 1

                # Go up and try to find script output message
                # Only go here if we found Powershell script start message earlier
                # and only if Powershell start PolicyId equals Powershell end PolicyId
                
                # DEBUG
                #Write-Host "b: $b, PowershellScriptStartLogEntryIndex: $PowershellScriptStartLogEntryIndex, StartLogEntryFound: $PowershellScriptStartLogEntryFound, PolicyIdStart: $PowershellScriptPolicyIdStart, PolicyIdEnd: $PowershellScriptPolicyIdEndProcess"
                #Write-Host ""
                
                # This works, somewhat at least
                # It may not find all outputs because sometimes AgentExecutor is out of sync with Intune Log
                # But this is acceptable solution for now
                # Make it better later :)
                While (($b -ge 0) -and ($b -gt $PowershellScriptStartLogEntryIndex) -and ($PowershellScriptStartLogEntryFound) -and ($PowershellScriptPolicyIdStart -eq $PowershellScriptPolicyIdEndProcess)) {

                    # Save output message to variable $scriptOutputStringArray
                    if(($LogEntryList[$b].Message -Match '^write output done. (output =.*)$') -and ($LogEntryList[$b].Component -eq 'AgentExecutor')) {
                        $FirstOutputLine = $Matches[1]
                        $scriptOutputStringArray += $FirstOutputLine
                        
                        $c = $b + 1
                        # Loop through next lines until we find multiline log entry ----> end character from Multiline property
                        While(($LogEntryList[$c].Multiline -ne '---->') -and ($LogEntryList[$c].Component -eq 'AgentExecutor') -and ($c -lt $b)) {
                            $scriptOutputStringArray += $LogEntryList[$c].Message
                            
                            $c++
                        }
                        
                        $OutputFound = $true
                        
                        # Break out from the While-loop
                        Break
                    }

                    $b--
                }

                
                if($PowershellScriptForUserIdEndProcess -eq '00000000-0000-0000-0000-000000000000') {
                    $PowershellScriptForUserNameEndProcess = 'System'
                } else {
                    $PowershellScriptForUserNameEndProcess = $PowershellScriptForUserIdEndProcess
                    
                    # Get real name if found from Hashtable
                    if($IdHashtable.ContainsKey($PowershellScriptForUserIdEndProcess)) {
                        $Name = $IdHashtable[$PowershellScriptForUserIdEndProcess].name
                        if($Name) {
                            $PowershellScriptForUserNameEndProcess = $IdHashtable[$PowershellScriptForUserIdEndProcess].name
                        }
                    }
                }
                
                # Get Powershell script name if parameter -Online was specified
                if($IdHashtable.ContainsKey($PowershellScriptPolicyIdEndProcess)) {
                    $PowershellScriptPolicyNameEnd = $IdHashtable[$PowershellScriptPolicyIdEndProcess].name
                    
                    # Get Powershell in cleartext if property exists
                    # Property should exist if starting event was found earlier and -Online parameter is specified
                    if($IdHashtable[$PowershellScriptPolicyIdEndProcess].scriptContentClearText) {
                        # Property scriptContentClearText exists
                        $IntuneScriptContentInClearText = $IdHashtable[$PowershellScriptPolicyIdEndProcess].scriptContentClearText
                    } else {
                        # Property scriptContentClearText does NOT exist
                    }
                } else {
                    $PowershellScriptPolicyNameEnd = $PowershellScriptPolicyIdEndProcess
                }

                # Get Powershell script in clear text if possible
                $IntuneScriptContentInClearText = Get-IntunePowershellScriptContentInCleartext $PowershellScriptPolicyIdEndProcess
                $DetailToolTip = $IntuneScriptContentInClearText
                
                # Get Process runtime
                if($PowershellScriptStartLogEntryIndex) {
                    $PowershellScriptStartTime = $LogEntryList[$PowershellScriptStartLogEntryIndex].LogEntryDateTime
                    $PowershellScriptEndTime = $LogEntryList[$i].LogEntryDateTime

                    $PowershellScriptRunTime = New-Timespan $PowershellScriptStartTime $PowershellScriptEndTime
                    $PowershellScriptRunTimeTotalSeconds = $PowershellScriptRunTime.TotalSeconds


                } else {
                    $PowershellScriptRunTimeTotalSeconds = 'N/A'
                }


                if($scriptOutputStringArray) {
                    $detailToolTip = "##########################################`n# Output:`n`n"
                    $detailToolTip += $scriptOutputStringArray | Out-String
                    $detailToolTip += "`n`n##########################################`n# Script:`n`n$IntuneScriptContentInClearText"
                } else {
                    $detailToolTip = "##########################################`n# Output:`n`n"
                    $detailToolTip += "Runtime information not available"
                    $detailToolTip += "`n`n##########################################`n# Script:`n`n$IntuneScriptContentInClearText"
                }


                if($PowershellScriptPolicyResultEndProcess -eq 'Success') {
                    
                    $Detail = "$PowershellScriptPolicyNameEnd for user $PowershellScriptForUserNameEndProcess"
                    #$DetailToolTip = "$IntuneScriptContentInClearText"
                    
                    RecordStatusToTimeline -date $LogEntryList[$i].DateTime -status "Success" -Type "Powershell script" -Intent "Execute" -detail $Detail -DetailToolTip $DetailToolTip -Seconds $PowershellScriptRunTimeTotalSeconds -logEntry "Line $($LogEntryList[$i].Line)" -Color 'Green'

                    # Check if Powershell script runtime is longer than configured (default 180 seconds)
                    if($PowershellScriptRunTimeTotalSeconds -gt $LongRunningPowershellNotifyThreshold) {
                        
                        $PowershellScriptRunTimeTotalSecondsRounded = [math]::Round($PowershellScriptRunTimeTotalSeconds, 0)
                        
                        RecordStatusToTimeline -date $LogEntryList[$i].DateTime -status "Warning" -Type "Powershell script" -Intent "Execute" -detail " Long Powershell script runtime found ($PowershellScriptRunTimeTotalSecondsRounded seconds)" -detailToolTip "Script runtime was $PowershellScriptRunTimeTotalSecondsRounded seconds" -Seconds $PowershellScriptRunTimeTotalSeconds -logEntry "Line $($LogEntryList[$i].Line)" -Color 'Red'
                    }
                    
                    # Set Powershell Script End information to ProcessRuntime property
                    $LogEntryList[$i].ProcessRunTime = "Succeeded Powershell script ($PowershellScriptPolicyNameEnd) for user $PowershellScriptForUserNameEndProcess (Runtime: $PowershellScriptRunTimeTotalSeconds seconds)"
                    
                } elseif ($PowershellScriptPolicyResultEndProcess -eq 'Failed') {

                    $Detail = "$PowershellScriptPolicyNameEnd for user $PowershellScriptForUserNameEndProcess"
                    #$DetailToolTip = "$IntuneScriptContentInClearText"

                    RecordStatusToTimeline -date $LogEntryList[$i].DateTime -status "Failed" -Type "Powershell script" -Intent "Execute" -detail $Detail -DetailToolTip $DetailToolTip -Seconds $PowershellScriptRunTimeTotalSeconds -logEntry "Line $($LogEntryList[$i].Line)" -Color 'Red'

                    # Set Powershell Script End information to ProcessRuntime property
                    $LogEntryList[$i].ProcessRunTime = "Failed Powershell script ($PowershellScriptPolicyNameEnd) for user $PowershellScriptForUserNameEndProcess (Runtime: $PowershellScriptRunTimeTotalSeconds seconds)"

                    # Check if there is error message for failed Powershell script
                    
                    # Check if next log entry exists
                    if($i + 1 -le $LogEntryList.Count - 1) {
                        if($LogEntryList[$i+1].Message -Match '^\[PowerShell\] Fail, the details are (.*)$') {
                            $PowershellFailJsonString = $Matches[1] 

                            # Try to convert JsonString
                            Try {
                                $PowershellFailFromJson=$null
                                $PowershellFailFromJson = $PowershellFailJsonString | ConvertFrom-Json
                                if($ExecutionMsg=$PowershellFailFromJson.ExecutionMsg) {
                                    if($ExecutionMsg) {
                                        # This makes time line harder to read so moved Error message after timeline

                                        $PowershellScriptErrorLogs += "$($LogEntryList[$i].DateTime) End Powershell script ($PowershellScriptPolicyNameEnd) for user $PowershellScriptForUserNameEndProcess Success: $PowershellScriptPolicyResultEndProcess (Runtime: $PowershellScriptRunTimeTotalSeconds seconds) ($($LogEntryList[$i].FileName) line $($LogEntryList[$i].Line))"
                                        $PowershellScriptErrorLogs += "$($LogEntryList[$i+1].DateTime) Powershell script error message ($($LogEntryList[$i].FileName) line $($LogEntryList[$i+1].Line))"

                                        

                                        if($ShowErrorsInReport) {
                                            
                                            # Show Powershell error message lines as new entries
                                            # This was not that good idea???
                                            <#
                                             
                                            # Split Error Message by newline and enter error message line by line
                                            $ExecutionMsg.Split("`n") | Foreach-Object {
                                                $ErrorMessageLine = $_
                                                #$PowershellScriptErrorLogs += "`t`t`t $ErrorMessageLine"
                                                $PowershellScriptErrorLogs += "$ErrorMessageLine"
                                            }
                                             
                                            $ExecutionMsg.Split("`n") | Foreach-Object {
                                                $ErrorMessageLine = $_
 
                                                RecordStatusToTimeline -date $LogEntryList[$i].DateTime -status "ErrorLog" -Type "Powershell script" -Intent "Run" -detail "$ErrorMessageLine" -logEntry "$($LogEntryList[$i].FileName) line $($LogEntryList[$i].Line)" -Color 'Red'
                                            }
                                            #>

                                            
                                            # Show Powershell error message as one log entry (one string)
                                            $ExecutionMsgAsString = $ExecutionMsg #| Out-String
                                            
                                            RecordStatusToTimeline -date $LogEntryList[$i].DateTime -status "ErrorLog" -Type "Powershell script" -Intent "Execute" -detail "$ExecutionMsgAsString" -logEntry "$($LogEntryList[$i].FileName) line $($LogEntryList[$i].Line)" -Color 'Red'
                                        } else {
                                            
                                            RecordStatusToTimeline -date $LogEntryList[$i].DateTime -status "Failed" -Type "Powershell script" -Intent "Execute" -detail "See Powershell error message. Hover on with mouse to see details." -detailToolTip $ExecutionMsg -logEntry "Line $($LogEntryList[$i].Line)" -Color 'Red'
                                            
                                        }
                                    }
                                }
                            } catch {
                                # Did not find Powershell error message
                            }
                        }
                    }

                } else {
                    
                    $Detail = "Powershell script $PowershellScriptPolicyNameEnd for user $PowershellScriptForUserNameEndProcess ($PowershellScriptRunTimeTotalSeconds seconds)"
                    $DetailToolTip = "$IntuneScriptContentInClearText"
                    
                    RecordStatusToTimeline -date $LogEntryList[$i].DateTime -status "Script end" -Type "Powershell script" -Intent "Execute" -detail $Detail -DetailToolTip $DetailToolTip -logEntry "Line $($LogEntryList[$i].Line)" -Color 'Yellow'
                }
                    
                $PowershellScriptStartLogEntryIndex=$null
                $PowershellScriptStartLogEntryFound = $false
                
                $FoundPowershellScriptEndMessageIndex = $null
                $PowershellScriptEndLogEntryFound = $false

                # Continue to next line in Do-while -loop
                $i++
                Continue
            }
        }
        # endregion Find Powershell Script End log entry


        if($FindAllLongRunningPowershellScripts) {
            # Remove me from Production
            # Left here for possible future purposes
            # This catches most Powershell script processes and we could for example check Process runtime
            # and alert for long running processes

            # region Find Powershell processess
            # Try to find Powershell process End log entry
            if($LogEntryList[$i].Message -like '*execution is done*') {
                $ProcessStartTime = $null
                $ProcessEndTime = $LogEntryList[$i].LogEntryDateTime
                $Thread = $LogEntryList[$i].Thread
                Write-Verbose "Found Powershell process Ending message (Line $($LogEntryList[$i].Line) Thread $Thread): $($LogEntryList[$i].Message)"

                # Try to find Powershell process Start log entry (upwards)
                $b = $i - 1
                for($b = $i - 1; $b -ge 0; $b-- ) {
                    if(($LogEntryList[$b].Message -like '*process id =*') -and ($LogEntryList[$b].Thread -eq $Thread)) {
                        Write-Verbose "Found Powershell process Starting message (Line $($LogEntryList[$b].Line) Thread $($LogEntryList[$b].Thread)): $($LogEntryList[$b].Message)"
                        $ProcessStartTime = $LogEntryList[$b].LogEntryDateTime
                        $ProcessRunTime = New-Timespan $ProcessStartTime $ProcessEndTime
                        $ProcessRunTimeTotalSeconds = $ProcessRunTime.TotalSeconds
                        $ProcessRunTimeText = "{0,-16} {1,8:n1}" -f "End Powershell", $ProcessRunTimeTotalSeconds
                        
                        # Set RunTime to Powershell script Install Start log entry
                        $LogEntryList[$b].ProcessRunTime = "{0,-16} {1,8:n1}" -f "Start Powershell", $ProcessRunTimeTotalSeconds
                        
                        # Set RunTime to Powershell script Install End log entry
                        $LogEntryList[$i].ProcessRunTime = "{0,-16} {1,8:n1}" -f "End Powershell", $ProcessRunTimeTotalSeconds


                        # Show warning for long running Powershell script
                        if($ProcessRunTimeTotalSeconds -ge $LongRunningPowershellNotifyThreshold) {
                            Write-Host "$($LogEntryList[$b].DateTime) Long running ($ProcessRunTimeTotalSeconds seconds) Powershell script ($LogFileName line $($LogEntryList[$b].Line)): $($LogEntryList[$b].Message)" -ForegroundColor Yellow

                            RecordStatusToTimeline -date $LogEntryList[$b].DateTime -status 'Warning' -Type "Powershell script" -Intent 'Execute' -detail "Long running ($ProcessRunTimeTotalSeconds seconds) Powershell script" -detailToolTip "" -Seconds $ProcessRunTimeTotalSeconds -Color 'Red' -logEntry "Line $($LogEntryList[$b].Line)"

                        }


                        # Break out from for-loop
                        Break
                    }
                } # For-loop end
                Write-Verbose ""
                
                # Cleanup variables
                $ProcessStartTime = $null
                $ProcessEndTime = $null
                $Thread = $null
                $ProcessRunTimeTotalSeconds = $null
                $ProcessRunTimeText = $null
            }
            # endregion Find Powershell processess
            # Remove me from Production
        }

        ###############################################

        # region Find Remediation script type - Proactive Remediation or custom Compliance
        # 20230920 Update. Log entries had changed so this is new approach to find all remediations


        # Find Remediation Detect and Remediate tasks
        if($LogEntryList[$i].Message -like '"C:\Program Files (x86)\Microsoft Intune Management Extension\agentexecutor.exe" -remediationScript ""C:\Windows\IMECache\HealthScripts\*\*.ps1""*') {
            # Proactive Remediation script = policyType 6
            # custom Compliance script = policyType 8

            #Write-Host "Remediation Detect script found" -ForegroundColor Red
            $Matches=$null
            #if($LogEntryList[$i].Message -Match '^\"C:\\Program Files \(x86\)\\Microsoft Intune Management Extension\\agentexecutor.exe\" -remediationScript \"\"C:\\Windows\\IMECache\\HealthScripts\\(.*)_?\\detect.ps1\"\".*$') {
                
            if($LogEntryList[$i].Message -Match '^"C:\\Program Files \(x86\)\\Microsoft Intune Management Extension\\agentexecutor.exe" -remediationScript ""C:\\Windows\\IMECache\\HealthScripts\\(.*)_[0-9]+\\(.*)\.ps1"".*$') {
                $PolicyId = $Matches[1]
                
                # detect
                # remediate
                $RemediationAction = $Matches[2]

                $ScriptInClearText = $null
                $intent = $null

                # Get Detection or Remediate action
                if($RemediationAction -eq 'detect') {
                    $intent = 'Detect'
                } elseif($RemediationAction -eq 'remediate') {
                    $intent = 'Remediate'
                } else {
                    $intent = 'unknown'
                    $ScriptInClearText = 'N/A'
                }
                

                $Thread = $LogEntryList[$i].Thread
                
                $RemediationScriptStartedDateTime = $LogEntryList[$i].DateTime
                $RemediationScriptEndDateTime = $null
                $RemediationDurationDateTimeObject = $null
                $ProcessRunTimeTotalSeconds = 'N/A'

                $RemediationExitCode = $null
                $RemediationScriptName = $null
                $RemediationScriptPowershellScript = $null

                # Try to find displayName
                if($IdHashtable.ContainsKey($PolicyId)) {
                    if($IdHashtable[$PolicyId].displayName) {
                        $RemediationScriptName = $IdHashtable[$PolicyId].displayName
                    } else {
                        $RemediationScriptName = $PolicyId
                    }
                } else {
                    $RemediationScriptName = $PolicyId
                }

                
                # Try to find policyType
                if($IdHashtable.ContainsKey($PolicyId)) {
                    $RemediationScriptPolicyType = $IdHashtable[$PolicyId].policyType
                }

                if($RemediationScriptPolicyType -eq 6) {
                    $RemediationScriptPolicyTypeName = 'Remediation'
                    
                    # Get Detect script in cleartext
                    if($intent -eq 'Detect') {
                        $ScriptInClearText = Get-IntuneRemediationDetectionScriptContentInCleartext $PolicyId
                    }
                    
                    # Get Remediate script in cleartext
                    if($intent -eq 'Remediate') {
                        $ScriptInClearText = Get-IntuneRemediationRemediateScriptContentInCleartext $PolicyId
                    }
                } elseif ($RemediationScriptPolicyType -eq 8) {
                    $RemediationScriptPolicyTypeName = 'Custom Compliance'
                    $ScriptInClearText = Get-IntuneCustomComplianceScriptContentInCleartext $PolicyId
                } else {
                    # We should never get here because we have seen polityType log entry before this log entry
                    # Proactive Remediation script type is unknown
                    #$RemediationScriptPolicyTypeName = 'Remediation script (unknown type)'
                    $RemediationScriptPolicyTypeName = 'Remediation'
                }


                $LogEntryList[$i].ProcessRunTime = "Start Remediation $RemediationScriptName $intent script"


                #Write-Host "Found Remediation or CompliancePolicy policyId $PolicyId" -ForegroundColor Red


                # Find stdOut, error and end message
                # Find next Remediations log entries

                # These are used to detect if some lines where found
                # If same is found again then something went wrong
                $PowershellCmdLineFound = $False
                
                $AgentExecutorPowershellExitCode = $null
                $AgentExecutorPowershellExitCodeFound = $False

                $AgentExecutorPowershellSuccessMessage = $null
                $AgentExecutorPowershellSuccessMessageFound = $False
                
                $scriptOutputStringArray = $null
                
                # Now we go down (to end)
                for($b = $i + 1; $b -lt $LogEntryList.Count; $b++ ) {

                    # Use this to debug Remediations log entries
                    #Write-Host "DEBUG: $b - $($LogEntryList[$b].Message)"


                    # Try to find stdOut, errorOut and other information
                    # before remediation process end log entry
                    # This is somewhat tricky as we need to find information from AgentExecutor log
                    # And in that log threads are always 1
                    # and sometimes for example remediation script and Win32App detection script run can overlap
                    # so we can NOT rely that information in AgentExecutor is always for our specific process
                    # This is more like best guess and we provide additional information for example in ToolTips


                    if(($LogEntryList[$b].Message -Like "cmd line for running powershell is -NoProfile -executionPolicy bypass -file `"C:\Windows\IMECache\HealthScripts\$($PolicyId)*\*.ps1`"") -and ($LogEntryList[$b].Component -eq 'AgentExecutor')) {
                        #Write-Host "Found Remediation powershell command AgentExecutor. Line: $($LogEntryList[$b].Line)" -ForegroundColor Red
                        
                        $PowershellCmdLineFound = $True
                        
                        # Continue to next For-loop round
                        Continue
                    }


                    # We are looking for these next lines
                    <#
                        Powershell exit code is 1 AgentExecutor
                        lenth of out=24 AgentExecutor
                        lenth of error=2 AgentExecutor
                    <---- error from script = AgentExecutor
                    ----> AgentExecutor
                        Powershell script is failed to execute or Powershell script is successfully executed.
                    <---- write output done. output = We need to remediate AgentExecutor
                     --- AgentExecutor
                     --- , error = AgentExecutor
                    ----> AgentExecutor
                    #>




                    if(($LogEntryList[$b].Message -Match '^Powershell exit code is (.*)$') -and ($LogEntryList[$b].Component -eq 'AgentExecutor') -and ($PowershellCmdLineFound)) {
                        $AgentExecutorPowershellExitCode = $Matches[1]
                        $AgentExecutorPowershellExitCodeFound = $True
                        
                        #Write-Host "Found Remediation powershell command AgentExecutor exit code: $($AgentExecutorPowershellExitCode). Line: $($LogEntryList[$b].Line)" -ForegroundColor Red

                        # Continue to next For-loop round
                        Continue
                        
                    }


                    # Powershell script is failed to execute
                    # Powershell script is successfully executed
                    if(($LogEntryList[$b].Message -Match '^Powershell script is (.*)$') -and ($LogEntryList[$b].Component -eq 'AgentExecutor') -and ($AgentExecutorPowershellExitCodeFound)) {
                        $AgentExecutorPowershellSuccessMessage = $Matches[1]
                        $AgentExecutorPowershellSuccessMessageFound = $True
                        
                        #Write-Host "Found Remediation powershell command AgentExecutor success message: $($AgentExecutorPowershellSuccessMessage). Line: $($LogEntryList[$b].Line)" -ForegroundColor Red
                        
                        # Next multiline log entry should be possible output and error
                        
                        # "goto" next log line
                        $b++
                        
                        $scriptOutputStringArray = @()
                        if(($LogEntryList[$b].Message -Match '^write output done. (output =.*)$') -and ($LogEntryList[$b].Component -eq 'AgentExecutor') -and ($AgentExecutorPowershellSuccessMessageFound)) {
                            $FirstOutputLine = $Matches[1]
                            $scriptOutputStringArray += $FirstOutputLine
                            
                            $b++
                            
                            # Loop through next lines until we find multiline log entry ----> end character from Multiline property
                            While(($LogEntryList[$b].Multiline -ne '---->') -and ($LogEntryList[$b].Component -eq 'AgentExecutor')) {
                                $scriptOutputStringArray += $LogEntryList[$b].Message
                                
                                $b++
                            }
                        }
                    }




                    # Remediation script script is done
                    # This is our end message we are looking for
                    if(($LogEntryList[$b].Message -Match '^Powershell execution is done, exitCode = (.*)$') -and ($LogEntryList[$b].Thread -eq $Thread)) {
                        $RemediationExitCode = $Matches[1]
                        
                        $RemediationScriptEndDateTime = $LogEntryList[$b].DateTime
                        
                        # Calculate runtime
                        if($RemediationScriptStartedDateTime -and $RemediationScriptEndDateTime) {
                            $RemediationDurationDateTimeObject = New-Timespan (Get-Date $RemediationScriptStartedDateTime) (Get-Date $RemediationScriptEndDateTime)
                            $ProcessRunTimeTotalSeconds = $RemediationDurationDateTimeObject.TotalSeconds
                            $ProcessRunTimeTotalSeconds = [math]::Round($ProcessRunTimeTotalSeconds,0)
                        }
                        #Write-Host "DEBUG: Found Remediation end with exitCode $RemediationExitCode"
                        

                        $detail = "$RemediationScriptName"
                        
                        
                        if($scriptOutputStringArray) {
                            $detailToolTip = "##########################################`n# Output:`n`n"
                            $detailToolTip += $scriptOutputStringArray | Out-String
                            $detailToolTip += "`n`n##########################################`n# Script:`n`n$ScriptInClearText"
                        } else {
                            $detailToolTip = "##########################################`n# Output:`n`n"
                            $detailToolTip += "Runtime information not available"
                            $detailToolTip += "`n`n##########################################`n# Script:`n`n$ScriptInClearText"
                        }
                        
                        
                        
                        if($RemediationExitCode -eq 0) {
                            # Remediation Detect Successful
                            
                            RecordStatusToTimeline -date $LogEntryList[$i].DateTime -status "Success" -Type "$RemediationScriptPolicyTypeName" -Intent $intent -detail $detail -detailToolTip $detailToolTip -Seconds $ProcessRunTimeTotalSeconds -Color 'Green' -logEntry "Line $($LogEntryList[$i].Line)"
                            
                            $LogEntryList[$b].ProcessRunTime = "Remediation Detected with exitCode 0 $($RemediationDurationDateTimeObject.Seconds) seconds"
                        } else {
                            # Remediation Detect was NOT successful
                        
                            if($RemediationScriptPolicyTypeName -eq 'remediate') {
                                $status = 'Failed'
                                $color = 'Red'
                            } else {
                                # This should be Detection with Not Detected status
                                $status = 'Not Detected'
                                $color = 'Yellow'
                            }
                        
                            RecordStatusToTimeline -date $LogEntryList[$i].DateTime -status $status -Type "$RemediationScriptPolicyTypeName" -Intent $intent -detail $detail -detailToolTip $detailToolTip -Seconds $ProcessRunTimeTotalSeconds -Color $color -logEntry "Line $($LogEntryList[$i].Line)"
                            
                            $LogEntryList[$b].ProcessRunTime = "Remediation Not Detected with exitCode $RemediationExitCode $($RemediationDurationDateTimeObject.Seconds) seconds"
                        }
                        
                        # Break for loop
                        Break
                        
                    }


                    # We didn't catch Remediation task end log entry previously
                    # because this will catch next remediation script start
                    #
                    # We should never get here
                    if($LogEntryList[$b].Message -like '"C:\Program Files (x86)\Microsoft Intune Management Extension\agentexecutor.exe" -remediationScript ""C:\Windows\IMECache\HealthScripts\*\*.ps1""*') {
                    
                        $status = 'Start'
                        $detail = "$RemediationScriptName"
                        $detailToolTip = "##########################################`nScript:`n`n$ScriptInClearText"
                        
                        # Print Remediation started but not ended
                        RecordStatusToTimeline -date $LogEntryList[$i].DateTime -status $status -Type "$RemediationScriptPolicyTypeName" -Intent $intent -detail $detail -detailToolTip $detailToolTip -Seconds 0 -Color 'Red' -logEntry "Line $($LogEntryList[$i].Line)"
                        
                        $status = 'Warning'
                        $detail = "`tRemediation started but we did not detect process end"
                        RecordStatusToTimeline -date $LogEntryList[$i].DateTime -status $status -Type "$RemediationScriptPolicyTypeName" -Intent $intent -detail $detail -detailToolTip $detailToolTip -Seconds 0 -Color 'Red' -logEntry "Line $($LogEntryList[$i].Line)"
                        
                        # Break for loop
                        Break
                    }


                } # For-loop end

            }
            
            # Continue to next log entry in main While-loop
            $i++
            Continue
        }


        ###############################################
        # Applications
        # Win32App
        # WinGetApp

        # Note! Application log entries have moved to file AppWorkload.log (2024-08-29)

        # Check for application policies
        if($LogEntryList[$i].Message -like 'Get policies = *') {
            if($LogEntryList[$i].Message -Match '^Get policies = \[(.*)\]$') {
                $IntuneAppPolicies = "[$($Matches[1])]" | ConvertFrom-Json
                
                # Add policies to hash. Remove existing hash object if exist already
                Foreach($AppPolicy in $IntuneAppPolicies) {
                    
                    # Check if App exists in Hashtable
                    if($IdHashtable.ContainsKey($AppPolicy.Id)) {
                        
                        # Remove existing object
                        $IdHashtable.Remove($AppPolicy.Id)
                    }
                    
                    # Add object to Hashtable
                    $IdHashtable.Add($AppPolicy.Id , $AppPolicy)
                    

                    #####################################
                    # Save App Assignments with filters applied
                    # We will show this in own table in report
                    #
                    # "AppApplicabilityStateDueToAssginmentFilters": null
                    # no filters
                    #
                    # "AppApplicabilityStateDueToAssginmentFilters": 0
                    # filters are applied but not preventing app installation
                    #
                    # "AppApplicabilityStateDueToAssginmentFilters": 1010
                    # filters are applied and preventing app installation
                    #
                    # "AppApplicabilityStateDueToAssginmentFilters": 1010,
                    # "AssignmentFilterIds": [
                    # "149021e6-c4cf-4f1b-ba76-b98d792a4a4a"
                    # ],
                    
                    if($AppPolicy.AssignmentFilterIds) {
                        if(-not ($ApplicationAssignmentFilterApplied | Where-Object AppId -eq $AppPolicy.Id)) {
                            # App was not in list before
                            
                            if($AppPolicy.InstallerData) {
                                # This should be New Store App
                                $AppType = 'WinGetApp'
                            } else {
                                # This should be Win32App
                                $AppType = 'Win32App'
                            }

                            if($AppPolicy.AppApplicabilityStateDueToAssginmentFilters -eq 1010) {
                                # This should be notApplicable
                                $Applicable = 'Not Applicable'
                            } else {
                                # This should be Applicable
                                $Applicable = 'Applicable'
                            }
                        
                            # Get AssignmentFilterNames
                            $AssignmentFilterNames = $null
                            Foreach($FilterId in $AppPolicy.AssignmentFilterIds) {
                                if($IdHashtable.ContainsKey($FilterId)) {
                                    $FilterName = $IdHashtable[$FilterId].displayName
                                } else {
                                    $FilterName = "$FilterId"
                                }
                                if($AssignmentFilterNames) {
                                    # There was existing FilterName so we have multiple Filters applied
                                    $AssignmentFilterNames = "$AssignmentFilterNames, $FilterName"
                                } else {
                                    # There was no previous FilterName
                                    $AssignmentFilterNames = "$FilterName"
                                }
                            }
                            
                            # Add Assignment Filter information to list
                            $ApplicationAssignmentFilterApplied.add([PSCustomObject]@{
                                'AppType' = $AppType
                                'Applicable' = $Applicable
                                'AppName' = $AppPolicy.Name
                                'AssignmentFilterNames' = $AssignmentFilterNames
                                'AssignmentFilterIds' = $AppPolicy.AssignmentFilterIds
                                'AppPolicy' = $AppPolicy
                                'AppId' = $AppPolicy.Id
                                })
                        }
                    }
                }
            }
        }

        # Get App Name from AppDownload log entry
        if($LogEntryList[$i].Message -Like '`[StatusService`] Downloading app (id = (.*), name (.*)\).*$') {
            if($LogEntryList[$i].Message -Match '^\[StatusService\] Downloading app \(id = (.*), name (.*)\).*') {
                
                # Message matches Win32App Name message
                $Win32AppId = $Matches[1]
                $Win32AppName = $Matches[2]
                
                Write-Verbose "Found Win32App Name $Win32AppName ($Win32AppId)"

                # Add AppId and name to Hashtable
                if($IdHashtable.ContainsKey($Win32AppId)) {
                    #$IdHashtable[$Win32AppId].id = $Win32AppId
                    
                    if($IdHashtable[$Win32AppId].name -ne $Win32AppName) {
                        Write-Host "Win32App name mismatch found. We should never get this warning" -ForegroundColor Yellow
                        Write-Host "($($IdHashtable[$Win32AppId].name)) ($Win32AppName) ($Win32AppId)" -ForegroundColor Yellow
                    }

                    #$IdHashtable[$Win32AppId].displayName = $Win32AppName

                } else {
                    $AppIdCustomObjectProperties = @{
                        id = $Win32AppId
                        name = $Win32AppName
                        displayName = $Win32AppName
                        intent = $null
                    }
                    $AppIdCustomObject = New-Object -TypeName PSObject -Prop $AppIdCustomObjectProperties

                    # Create new UserId hashtable entry
                    $IdHashtable.Add($Win32AppId, $AppIdCustomObject)
                }
            }
        }

        # Check for Applications to be installed in ESP phase (Device ESP and User ESP has it's own log entries)
        if($LogEntryList[$i].Message -like '`[Win32App`]`[ESPAppLockInProcessor`] Found * apps which need to be installed for current phase of ESP. AppIds: *') {
            if($LogEntryList[$i].Message -Match '^\[Win32App\]\[ESPAppLockInProcessor\] Found (.*) apps which need to be installed for current phase of ESP. AppIds: (.*)$') {
                $AppCountToInstallInESPPhase = $Matches[1]                
                $AppIdsToInstallInESPPhase = $Matches[2]

                if($AppIdsToInstallInESPPhase) {

                    RecordStatusToTimeline -date $LogEntryList[$i].DateTime -status "Info" -Type "Apps" -Intent "Required in ESP" -detail "$AppCountToInstallInESPPhase Apps to install at ESP phase" -logEntry "Line $($LogEntryList[$i].Line)"


                    # Several apps are separated by ,
                    $AppIdsToInstallInESPPhaseArray = $AppIdsToInstallInESPPhase.Split(',').Trim()

                    Foreach($AppId in $AppIdsToInstallInESPPhaseArray) {
                        
                        # Get name for App
                        if($IdHashtable.ContainsKey($AppId)) {
                            $AppName = $IdHashtable[$AppId].name
                            $AppType = Get-AppType $AppId
                            #$DetailToolTip = $IdHashtable[$AppId] | ConvertFrom-Json | ConvertTo-Json -Depth 4
                            
                            $DetailToolTip = $null
                            #$DetailToolTip = $IdHashtable[$AppId] | Select-Object -Property * -ExcludeProperty AppApplicabilityStateDueToAssginmentFilters, ToastState, FlatDepedencies, MetadataVersion, RelationVersion, RebootEx, StartDeadlineEx, RemoveUserData, DOPriority, newFlatDependencies, FlatDependencies, ContentCacheDuration, ReevaluationInterval, SupportState, AssignmentFilterIdToEvalStateMap | Out-String
                            
                            $DetailToolTip = $IdHashtable[$AppId] | Select-Object -Property Name, InstallCommandLine, UninstallCommandLine, id | Format-List -Property * | Out-String
                            
                            # DEBUG App Data
                            #$IdHashtable[$AppId] | ConvertTo-Json -Depth 5 | Set-Clipboard
                            
                            RecordStatusToTimeline -date $LogEntryList[$i].DateTime -status "Info" -Type "$AppType" -Intent "Required in ESP" -detail "`t$AppName" -DetailToolTip $DetailToolTip -logEntry "Line $($LogEntryList[$i].Line)"
                            
                            $DetailToolTip = $null
                        }

                    }

                }

                #$LogEntryList[$i].ProcessRunTime = "User ESP finished successfully"

                # Continue to next line in Do-while -loop
                $i++
                Continue

            }
        }

        # Case where different version of App of detected after Uninstall than expected
        # Usually in this case either Uninstall failed or there is wrong detection check in older app (eg. version -gt used)
        if($LogEntryList[$i].Message -like '`[Win32App`]`[ActionProcessor`] Encountered unexpected state for app with id:*') {
            # Set RunTime message
            $LogEntryList[$i].ProcessRunTime = "Warning: Encountered unexpected state for app with id"
        }

        
        # region Find Win32App installations
        # Check if this is ending message for Win32 application installation
        if(($LogEntryList[$i].Message -like '`[Win32App`] Installation is done, collecting result') -or ($LogEntryList[$i].Message -like '`[Win32App`] Failed to create installer process. Error code = *')) {
            $FailedToCreateInstallerProcess = $False


            # We will follow this Thread
            $Thread = $LogEntryList[$i].Thread

            if($LogEntryList[$i].Message -Match '^\[Win32App\] Failed to create installer process. Error code = (.*)$') {
                $ErrorMessage = $Matches[0]
                $ErrorCode = $Matches[1]
                $FailedToCreateInstallerProcess = $True

                $Win32AppInstallStartFailedIndex = $i

                # Set RunTime to Win32App Install Start Failed log entry
                $LogEntryList[$Win32AppInstallStartFailedIndex].ProcessRunTime = "$ErrorMessage"

                #Write-Host "DEBUG: Found Win32 App error: $ErrorMessage"
                Write-Verbose "Found Win32 App error: $ErrorMessage"

                # Set index so our search continues in next steps
                # Because we skip Loop below
                $b = $i - 1

            } else {

                $Win32AppInstallStartIndex = $null
                $Win32AppInstallEndIndex = $i
                
                $ProcessStartTime = $null
                $ProcessEndTime = $LogEntryList[$i].LogEntryDateTime

                Write-Verbose "Found Win32 App install end process message (Line $($LogEntryList[$i].Line) Thread $Thread): $($LogEntryList[$i].Message)"

                $Win32AppExitCode='N/A'
                $Win32AppInstallSuccess='N/A'

                # We don't know App Name or Intent yet
                $LogEntryList[$Win32AppInstallEndIndex].ProcessRunTime = "End Win32App"

                # Check if we can find exitCode and Success/failed information in next 20 lines
                for($b = $Win32AppInstallEndIndex + 1; $b -lt $Win32AppInstallEndIndex + 20; $b++ ) {
                    $Matches=$null
                    
                    # Run this if first because it is much faster than run -Match for every line
                    if($Win32AppExitCode -eq 'N/A') {
                        if(($LogEntryList[$b].Message -Match '^\[Win32App\] lpExitCode (.*)$') -and ($LogEntryList[$b].Thread -eq $Thread)) {
                            $Win32AppExitCode = $Matches[1]
                            Write-Verbose "Found Win32App install exitCode: $Win32AppExitCode"

                            # We don't know App Name or Intent yet
                            $LogEntryList[$i].ProcessRunTime = "(ExitCode $Win32AppExitCode"

                            # Continue For-loop to next log entry
                            Continue
                        }
                    }
                    
                    # Run this if-clause first because it is much faster than run -Match for every line
                    if($Win32AppInstallSuccess -eq 'N/A') {
                        if(($LogEntryList[$b].Message -Match '^\[Win32App\] lpExitCode is defined as (.*)$') -and ($LogEntryList[$b].Thread -eq $Thread)) {
                            $Win32AppInstallSuccess = $Matches[1]
                            Write-Verbose "Found Win32App install success: $Win32AppInstallSuccess"

                            # We don't know App Name or Intent yet
                            $LogEntryList[$i].ProcessRunTime = "$($LogEntryList[$i].ProcessRunTime) $Win32AppInstallSuccess)"

                            # Break out from For-loop
                            Break
                        }
                    }
                } # For-loop end
                
                # Check if we can find previous LogEntry that matches same process thread start message
                $b = $Win32AppInstallEndIndex - 1
                While ($b -ge 0) {
                    if(($LogEntryList[$b].Message -like '`[Win32App`] process id =*') -and ($LogEntryList[$b].Thread -eq $Thread)) {
                        $Win32AppInstallStartIndex = $b

                        # Message matches Win32App install start message
                        $ProcessStartTime = $LogEntryList[$b].LogEntryDateTime
                        $ProcessRunTime = New-Timespan $ProcessStartTime $ProcessEndTime
                        $ProcessRunTimeTotalSeconds = $ProcessRunTime.TotalSeconds
                        #$ProcessRunTime = "{0,-16} {1,8:n1}" -f "End Win32App", $ProcessRunTimeTotalSeconds
                        
                        Write-Verbose "Found Win32 App install Start process message (Line $($LogEntryList[$b].Line)): $($LogEntryList[$b].Message)"

                        # FIXME
                        # Set Win32App Name to Win32App Install Start entry
                        #$LogEntryList[$Win32AppInstallStartIndex].ProcessRunTime = "$($LogEntryList[$Win32AppInstallStartIndex].ProcessRunTime) $Win32AppName"
                        
                        # Set RunTime to Win32App Install Start log entry
                        $LogEntryList[$Win32AppInstallStartIndex].ProcessRunTime = "{0,-16} {1,8:n1}" -f "Start Win32App", $ProcessRunTimeTotalSeconds

                        # Set RunTime to Powershell script Install End log entry
                        #$LogEntryList[$i].ProcessRunTime = "{0,-16} {1,8:n1}" -f "End Win32App", $ProcessRunTimeTotalSeconds
                        $LogEntryList[$Win32AppInstallEndIndex].ProcessRunTime = "$($LogEntryList[$i].ProcessRunTime) $($ProcessRunTimeTotalSeconds)sec"
                        
                        Write-Verbose "Runtime: $ProcessRunTimeTotalSeconds"
                        
                        Break
                    } else {
                        # Message does not match Win32App install start message
                    }                            
                    $b--
                }
            }

            # Try to find Win32App name if we have start and end time
            $Win32AppId = $null
            $Win32AppName = $null
            $Intent = 'Unknown Intent'
            $TempAppId = $null
            $BackupIntent = $null
            $intentDoubleCheck = $null
            $AppIntentNumberClosestToProcess = $null
            $AppSupersededUninstall = $null
            $AppDeliveryOptimization = $null

            $DetailToolTip = $null

            if($ProcessStartTime -or $ProcessEndTime -or $FailedToCreateInstallerProcess) {
                
                # We are going upwards now
                While ($b -ge 0) {
                    #Write-Host "$b $($LogEntryList[$b].Message)"

                    # Find Win32App Id
                    if(-not $Win32AppId) {
                        if(($LogEntryList[$b].Message -Match '^\[Win32App\] SetCurrentDirectory: C:\\Windows\\IMECache\\(.*)_.*$') -and ($LogEntryList[$b].Thread -eq $Thread)) {
                            $Win32AppId = $Matches[1]

                            #Write-Host "DEBUG: Found Win32AppId: $Win32AppId"

                            # Find name from Hashtable
                            if($IdHashtable.ContainsKey($Win32AppId)) {
                                if($IdHashtable[$Win32AppId].name) {
                                    $Win32AppName = $IdHashtable[$Win32AppId].name
                                    #Write-Host "DEBUG: Found existing Win32AppName: $Win32AppName`n"
                                }
                                
                                $DetailToolTip = $IdHashtable[$Win32AppId] | Select-Object -Property Name, InstallCommandLine, UninstallCommandLine, id | Format-List -Property * | Out-String
                            }

                            $b--
                            Continue
                        }
                    }

                    # Double check intent
                    # If old application is Superceded then in policy intent is not targeted
                    # But if old app version is detected then uninstall is triggered and intent
                    # Need to be checked from this log entry
                    if(-not $AppIntentNumberClosestToProcess) {
                        if(($LogEntryList[$b].Message -Match '^\[Win32App\] ===Step=== InstallBehavior RegularWin32App, Intent (.*), UninstallCommandLine .*$') -and ($LogEntryList[$b].Thread -eq $Thread)) {
                            $AppIntentNumberClosestToProcess = $Matches[1]

                            #Write-Host "DEBUG: Found InstallBehavior: $AppIntentNumberClosestToProcess"
                            
                            $b--
                            Continue
                        }
                    }

                    
                    # Get Delivery Optimization information
                    if(-not $AppDeliveryOptimization -and $Win32AppId) {
                        # ? is any character because escape `[ and `] didn't seem to work with doublequotes " ???
                        if(($LogEntryList[$b].Message -Like "?DO TEL? = {*$($Win32AppId)*}") -and ($LogEntryList[$b].Thread -eq $Thread)) {
                            if(($LogEntryList[$b].Message -Match '^\[DO TEL\] = \{(.*)\}$') -and ($LogEntryList[$b].Thread -eq $Thread)) {
                                $AppDeliveryOptimization = "{$($Matches[1])}" | ConvertFrom-Json
                                
                                # Disabled now because we don't have Win32App Processsing Start and End entries
                                #$LogEntryList[$b].ProcessRunTime = " Download and Delivery Optimization summary"

                                #Write-Host "DEBUG: Found Delivery Optimization information:`n$($AppDeliveryOptimization | ConvertTo-Json)"
                                
                                $b--
                                Continue
                            }
                        }
                    }
                    

<#
                    # Get intent: RequiredInstall, RequiredUninstall, AvailableInstall, AvailableUninstall?
                    # We will catch AvailableInstall here which are apps installed from Intune Company Portal
                    #
                    # We will also catch NotTargeted which is superceded application
                    # If we find NotTargeted then we should try to find actual app install after previous version uninstall
                    if(($LogEntryList[$b].Message -Match '^\[Win32App\]\[ActionProcessor\] App with id: (.*), targeted intent: (.*),.*$') -and ($LogEntryList[$b].Thread -eq $Thread)) {
 
                        $TempAppId = $Matches[1]
                        if($TempAppId -eq $Win32AppId) {
                            $BackupIntent = $Matches[2]
                            #Write-Host "Found backupIntent: $Win32AppName $BackupIntent ($TempAppId) ($Win32AppId)"
                        } else {
                            #Write-Host "Found backupIntent for wrong id: $Win32AppName $BackupIntent ($TempAppId) ($Win32AppId)"
                        }
 
                         
                        # End loop
                        Break
                    }
#>

                    

                    # Check if we reached start of this round
                    if($LogEntryList[$b].Message -Like '^`[Win32App`]`[V3Processor`] Processing * subgraphs.$') {

                        #Write-Host "We reached start Win32App processing phase"
                        #Write-Verbose "We reached start Win32App processing phase"
                        #Write-Host "$($LogEntryList[$b].Message)"
                        
                        # End loop
                        Break
                    }

                    # Check if we reached start of this round
                    # In some rare cases this does not catch Win32App installs in ESP phase
                    # Look for ExecMaanger below
                    if($LogEntryList[$b].Message -Match '^\[Win32App\]\[V3Processor\] Processing subgraph with app ids: (.*)$') {
                        $TempAppId = $Matches[1]

                        #Write-Host "We reached start Win32App processing phase: $TempAppId"
                        #Write-Verbose "We reached start Win32App processing phase: $TempAppId"
                        #Write-Host "$($LogEntryList[$b].Message)"
                        
                        # End loop
                        Break
                    }

                    # This is start entry for Win32App processing
                    # Only shown in ESP phase for rare cases or not shown at all?
                    if($LogEntryList[$b].Message -Like '`[Win32App`] ExecManager: processing targeted app*') {

                        #Write-Host "We reached start Win32App processing phase"
                        #Write-Verbose "We reached start Win32App processing phase"
                        #Write-Host "$($LogEntryList[$b].Message)"
                        
                        # End loop
                        Break
                    }
                    
                    # This is start entry for Win32App processing during ESP insome cases
                    # We should never get here
                    if($LogEntryList[$b].Message -Like '`[Win32App`]ExeManager: start processing app policies with count = *') {

                        #Write-Host "We reached start Win32App processing phase"
                        #Write-Verbose "We reached start Win32App processing phase"
                        #Write-Host "$($LogEntryList[$b].Message)"
                        
                        # End loop
                        Break
                    }



                    # This is one possible end of subgraph
                    if($LogEntryList[$b].Message -Match '^\[Win32App\]\[V3Processor\] Done processing subgraph.$') {

                        #Write-Host "We reached start Win32App processing phase: $TempAppId"
                        #Write-Verbose "We reached start Win32App processing phase: $TempAppId"
                        #Write-Host "$($LogEntryList[$b].Message)"
                        
                        # End loop
                        Break
                    }

                    # This is one possible end of subgraph
                    if($LogEntryList[$b].Message -Match '^\[Win32App\]\[V3Processor\] All apps in the subgraph are not applicable due to assignment filters. Processing will be skipped.$') {

                        #Write-Host "We reached start Win32App processing phase: $TempAppId"
                        #Write-Verbose "We reached start Win32App processing phase: $TempAppId"
                        #Write-Host "$($LogEntryList[$b].Message)"
                        
                        # End loop
                        Break
                    }

                    $b--
                }

<#
                # DEBUG Intents
                if($FailedToCreateInstallerProcess) {
                    Write-Host "DEBUG: $Win32AppName (Line: $($LogEntryList[$Win32AppInstallStartFailedIndex].Line)) AppIntentNameClosestToProcess : $(Get-AppIntentNameForNumber $AppIntentNumberClosestToProcess)"
                    Write-Host "DEBUG: $Win32AppName (Line: $($LogEntryList[$Win32AppInstallStartFailedIndex].Line)) Last known Intent from App policy: $(Get-AppIntentNameForNumber $IdHashtable[$Win32AppId].Intent)`n"
                } else {
                    Write-Host "DEBUG: $Win32AppName (Line: $($LogEntryList[$Win32AppInstallEndIndex].Line)) AppIntentNameClosestToProcess : $(Get-AppIntentNameForNumber $AppIntentNumberClosestToProcess)"
                    Write-Host "DEBUG: $Win32AppName (Line: $($LogEntryList[$Win32AppInstallEndIndex].Line)) Last known Intent from App policy: $(Get-AppIntentNameForNumber $IdHashtable[$Win32AppId].Intent)`n"
                }
#>


                # Set Intent placeholder value which is closest Intent found from process start (going upwards)
                if($AppIntentNumberClosestToProcess) {
                    $intent = Get-AppIntentNameForNumber $AppIntentNumberClosestToProcess
                } else {
                    # Set fallback Inten value from App Policy
                    $intent = Get-AppIntent $Win32AppId
                }

                # Get App Intent Name from policy
                $AppIntentNameFromPolicy = Get-AppIntent $Win32AppId

                # Get App Intent Name from closest Intent found from process start (going upwards)
                if($AppIntentNumberClosestToProcess) {
                    $AppIntentNameClosestToProcess = Get-AppIntentNameForNumber $AppIntentNumberClosestToProcess
                }

                # In case of Available Install we have different values
                # but we will use value from policy if action is RequiredInstall
                if(($AppIntentNameFromPolicy -eq 'Available Install') -and ($AppIntentNameClosestToProcess -eq 'Required Install')) {
                    $intent = 'Available Install'
                }

                # In case of supercedence in policy we usually have NotTargeted but action is RequiredUninstall
                # but can have previous value of Available Install if we have installed old App version just previously (maybe not normal case)
                #if(($AppIntentNameFromPolicy -eq 'Not Targeted') -and ($AppIntentNameClosestToProcess -eq 'Required Uninstall')) {
                if(($AppIntentNameFromPolicy -ne 'Required Uninstall') -and ($AppIntentNameClosestToProcess -eq 'Required Uninstall')) {
                    $intent = 'Required Uninstall'
                    $AppSupersededUninstall = $True
                }


                if($AppDeliveryOptimization) {
                    #$DownloadDuration = $AppDeliveryOptimization.DownloadDuration
                    $DownloadDurationDateTimeObject = Get-Date $AppDeliveryOptimization.DownloadDuration
                    
                    # Full seconds
                    $DownloadDuration = New-Timespan -Hours $DownloadDurationDateTimeObject.Hour -Minutes $DownloadDurationDateTimeObject.Minute -Seconds $DownloadDurationDateTimeObject.Second
                    $DownloadDurationSeconds = $DownloadDuration.TotalSeconds
                    
                    $BytesFromPeers = $AppDeliveryOptimization.BytesFromPeers
                    $BytesFromPeersMBRounded = [math]::Round($BytesFromPeers /1MB, 0)
                    
                    $TotalBytesDownloaded = $AppDeliveryOptimization.TotalBytesDownloaded
                    $TotalBytesDownloadedMBExact = $TotalBytesDownloaded /1MB
                    $TotalBytesDownloadedMBRounded = [math]::Round($TotalBytesDownloaded /1MB, 0)
                    
                    $DownloadMBps = $TotalBytesDownloadedMBExact / $DownloadDurationSeconds
                    $DownloadMBps = [math]::Round($DownloadMBps, 1)
                    if($DownloadMBps -eq 0) {
                        $DownloadMBps = 'N/A'
                    }


                    if($BytesFromPeers -gt 0) {
                        $BytesFromPeersPercentage = ($BytesFromPeers / $TotalBytesDownloaded).ToString('#%')
                    } else {
                        $BytesFromPeersPercentage = '0%'
                    }

                    # Add download statistics object to list
                    $ApplicationDownloadStatistics.add([PSCustomObject]@{
                        'AppType' = 'Win32App'
                        'AppName' = $Win32AppName
                        'DL Sec' = $DownloadDurationSeconds
                        'Size (MB)' = $TotalBytesDownloadedMBRounded
                        'MB/s' = $DownloadMBps
                        'Delivery Optimization %' = $BytesFromPeersPercentage
                        })

                    #$ApplicationDownloadStatistics.Add($DownloadStats)
                    #$DownloadStats = "DL:$($DownloadDurationSeconds)s,$($TotalBytesDownloadedMBRounded)MB,$($DownloadMBps)MB/s,DO:$($BytesFromPeersPercentage)"
                    #Write-Host "$($Win32AppName): $DownloadStats"
                }
                
                if($FailedToCreateInstallerProcess) {
                    # Failed to create process

                    if($AppSupersededUninstall) {
                        #$detail = "{0,$ColonIndentInTimeLine}{1} {2}" -f "$Intent", ':', "$Win32AppName (Superseded) $(($LogEntryList[$Win32AppInstallStartFailedIndex].Message).Replace('[Win32App]','').Trim())"
                        $detail = "$Win32AppName (Superseded) $(($LogEntryList[$Win32AppInstallStartFailedIndex].Message).Replace('[Win32App]','').Trim())"

                    } else {
                        #$detail = "{0,$ColonIndentInTimeLine}{1} {2}" -f "$Intent", ':', "$Win32AppName $(($LogEntryList[$Win32AppInstallStartFailedIndex].Message).Replace('[Win32App]','').Trim())"
                        $detail = "$Win32AppName $(($LogEntryList[$Win32AppInstallStartFailedIndex].Message).Replace('[Win32App]','').Trim())"
                    }

                    # Set RunTime to Win32App Install Start Failed log entry
                    $LogEntryList[$Win32AppInstallStartFailedIndex].ProcessRunTime = "Failed $Win32AppName $intent ($ErrorMessage)"

                    RecordStatusToTimeline -date $LogEntryList[$Win32AppInstallStartFailedIndex].DateTime -status "Failed" -Type "Win32App" -Intent $Intent -detail $detail -DetailToolTip $DetailToolTip -logEntry "Line $($LogEntryList[$Win32AppInstallStartFailedIndex].Line)" -Seconds '' -Color 'Red'

                    # Set Info message about possible missing uninstall executable
                    # First check that uninstall command is correct.
                    #
                    # Another case could be that there was earlier uninstall process started while application was running
                    # Uninstaller then could not delete main executable because it was locked but it deleted uninstall.exe
                    # And if Detection Check is for main executable then partially uninstalled App is detected for old version
                    # So Intune always detects old App version and is trying to uninstall it before installing new App version but uninstaller is missing

                    #$detail = "{0,$ColonIndentInTimeLine}{1} {2}" -f "", ':', " Incorrect uninstall command line or uninstall .exe missing?"
                    $detail = " Incorrect uninstall command line or uninstall .exe missing?"

                    RecordStatusToTimeline -date $LogEntryList[$Win32AppInstallStartFailedIndex].DateTime -status "Info" -Intent $intent -Type "Win32App" -detail $detail -DetailToolTip $DetailToolTip -logEntry "Line $($LogEntryList[$Win32AppInstallStartFailedIndex].Line)" -Seconds '' -Color 'Yellow'

                    if($IdHashtable.ContainsKey($Win32AppId)) {
                        if($IdHashtable[$Win32AppId].UninstallCommandLine) {
                            $UninstallCommandLine = $IdHashtable[$Win32AppId].UninstallCommandLine
                            #$detail = "{0,$ColonIndentInTimeLine}{1} {2}" -f "", ':', " Uninstall command: $UninstallCommandLine"
                            $detail = " Uninstall command: $UninstallCommandLine"

                            RecordStatusToTimeline -date $LogEntryList[$Win32AppInstallStartFailedIndex].DateTime -status "Info" -Type "Win32App" -Intent $intent -detail $detail -DetailToolTip $DetailToolTip -logEntry "Line $($LogEntryList[$Win32AppInstallStartFailedIndex].Line)" -Seconds '' -Color 'Yellow'

                        }
                    }

                    #Write-Host "DEBUG: Write Fail info to Timeline. `$detail=$detail"

                } else {
                    # Set RunTime to Win32App Install Start log entry
                    $LogEntryList[$Win32AppInstallStartIndex].ProcessRunTime = "Start Win32App $Win32AppName $Intent $($ProcessRunTimeTotalSeconds)sec"

                    # Set RunTime to Powershell script Install End log entry
                    #$LogEntryList[$i].ProcessRunTime = "{0,-16} {1,8:n1}" -f "End Win32App", $ProcessRunTimeTotalSeconds
                    $LogEntryList[$Win32AppInstallEndIndex].ProcessRunTime = "End Win32App $Win32AppName $Intent $($LogEntryList[$Win32AppInstallEndIndex].ProcessRunTime)"


                    if($AppSupersededUninstall) {
                        #$detail = "{0,$ColonIndentInTimeLine}{1} {2}" -f "$Intent", ':', "$Win32AppName ($Win32AppExitCode $Win32AppInstallSuccess) (Superseded)"
                        $detail = "$Win32AppName ($Win32AppExitCode $Win32AppInstallSuccess) (Superseded)"
                    } else {
                        #$detail = "{0,$ColonIndentInTimeLine}{1} {2}" -f "$Intent", ':', "$Win32AppName ($Win32AppExitCode $Win32AppInstallSuccess)"
                        $detail = "$Win32AppName ($Win32AppExitCode $Win32AppInstallSuccess)"
                    }
                    
                    # Change text color depending on Win32App install Success status
                    if($Win32AppInstallSuccess -eq 'Success') {
                        
                        RecordStatusToTimeline -date $LogEntryList[$Win32AppInstallEndIndex].DateTime -status "Success" -Type "Win32App" -Intent $intent -detail $detail -DetailToolTip $DetailToolTip -logEntry "Line $($LogEntryList[$Win32AppInstallEndIndex].Line)" -Seconds $ProcessRunTimeTotalSeconds -Color 'Green'

                    } else {
                        
                        RecordStatusToTimeline -date $LogEntryList[$Win32AppInstallEndIndex].DateTime -status "Failed" -Type "Win32App" -Intent $intent -detail $detail -DetailToolTip $DetailToolTip -logEntry "Line $($LogEntryList[$Win32AppInstallEndIndex].Line)" -Seconds $ProcessRunTimeTotalSeconds -Color 'Red'
                    }
                }
            }

            
            if(($b -eq 0) -and (-not $ProcessStartTime)) {
                # We did not find Win32App install start time
                
                Write-Host "Did NOT find matching Win32App install start time for Win32App install in line $LineNumber (Thread=$Thead)`n" -ForegroundColor Yellow
            }
            
            # Cleanup variables
            $ProcessStartTime = $null
            $ProcessEndTime = $null
            $Thread = $null
            $ProcessRunTimeTotalSeconds = $null
            $ProcessRunTimeText = $null
        }
        # endregion Find Win32App installations

<#
# Not Applicable checks
# For future TODO list
 
<![LOG[[Win32App][V3Processor] Processing subgraph with app ids: 9a7d745d-9cd2-4b05-adc2-7796f8c8cc87]LOG]!><time="08:45:59.2422749" date="9-13-2023" component="IntuneManagementExtension" context="" type="1" thread="5" file="">
<![LOG[[Win32App][V3Processor] All apps in the subgraph are not applicable due to assignment filters. Skipping processing.]LOG]!><time="08:45:59.2422749" date="9-13-2023" component="IntuneManagementExtension" context="" type="1" thread="5" file="">
 
#>




        # region Find WinGet Application install
        # Actually this start log entry applies to Win32Apps too
        if($LogEntryList[$i].Message -like '`[Win32App`]`[V3Processor`] Processing subgraph with app ids:*') {
            if($LogEntryList[$i].Message -Match '^\[Win32App\]\[V3Processor\] Processing subgraph with app ids: (.*)$') {
                $WinGetAppId = $Matches[1]
                $WinGetProcessingStartIndex = $i

                $Thread = $LogEntryList[$i].Thread
                $WinGetInstallationStartedDateTime = $LogEntryList[$i].DateTime

                $WinGetDownloadFirstEntryFound=$false
                $BackupIntent=$null
                
                $WinGetAppBytes = $null
                $WinGetDownloadStartDateTime = $null
                $WinGetDownloadEnddDateTime = $null
                $DownloadDurationDateTimeObject = $null
                $TotalBytesDownloadedMBRounded = $null
                $TotalBytesDownloadedMBExact = $null
                $AppSizeInMB = $null

                $WinGetAppName = Get-AppName $WinGetAppId

                $DetailToolTip = $null

                #Write-Host "DEBUG: Found WinGet installation start. `$i=$i"

                # Find next WinGet installation log entries
                # Now we go down (to end)
                for($b = $i + 1; $b -lt $LogEntryList.Count; $b++ ) {

                    # Use this to debug WinGet log entries
                    #Write-Host "DEBUG: $b - $($LogEntryList[$b].Message)"

                    if(($LogEntryList[$b].Message -Match '^\[Win32App\]\[WinGetApp\]\[WinGetAppExecutionExecutor\] Starting execution of app with id: (.*) and context: (.*)\.$') -and ($LogEntryList[$b].Thread -eq $Thread)) {
                        $TempAppId = $Matches[1]
                        $WinGetInstallContext = $Matches[2]
                        
                        #Write-Host "DEBUG: Found WinGet id ($TempAppId) and context ($WinGetInstallContext)"
                        
                        if($TempAppId -ne $WinGetAppId) {
                            # We got different AppIds
                            # Something is big time wrong here
                            
                            #Write-Host "DEBUG: WinGet Application Id mismatch in log entries. Aborting WinGet AppId: $WinGetAppId"

                            # Break For-loop
                            Break
                        }
                        
                        # Continue For-loop to next round
                        Continue
                    }


                    if(-not $WinGetDownloadFirstEntryFound) {
                        if(($LogEntryList[$b].Message -Match '^\[StatusService\] Downloading app .id = (.*), name (.*). via WinGet, bytes (.*)\/(.*) for user (.*)$')) {
                            $WinGetDownloadFirstEntryFound=$True
                            
                            $WinGetDownloadStartDateTime = $LogEntryList[$b].DateTime
                            
                            $AppId = $Matches[1]
                            $WinGetAppName = $Matches[2]
                            $WinGetAppBytes = $Matches[4]
                            $WinGetUserId = $Matches[5]

                            # Extract AppSize
                            # WinGet StoreApps show only xx/100
                            # WinGet Win32App show downloaded/fullsize
                            if($WinGetAppBytes -eq '100') {
                                # We are downloading Store App and we don't know app size :(
                                $WinGetAppBytes = 'N/A'
                                $TotalBytesDownloadedMBRounded = 'N/A'
                            } else {
                                # We are downloading WinGet Win32 app
                                $TotalBytesDownloadedMBExact = $WinGetAppBytes /1MB
                                $TotalBytesDownloadedMBRounded = [math]::Round($WinGetAppBytes /1MB, 0)
                            }

                            # Add WinGetAppId and name to Hashtable
                            if($IdHashtable.ContainsKey($AppId)) {
                                $IdHashtable[$AppId].id = $AppId
                                $IdHashtable[$AppId].name = $WinGetAppName
                                #$IdHashtable[$AppId].displayName = $WinGetAppName
                            } else {
                                $AppIdCustomObjectProperties = @{
                                    id = $AppId
                                    name = $WinGetAppName
                                    displayName = $WinGetAppName
                                }
                                $AppIdCustomObject = New-Object -TypeName PSObject -Prop $AppIdCustomObjectProperties

                                # Create new UserId hashtable entry
                                $IdHashtable.Add($AppId , $AppIdCustomObject)
                            }

                            # Get real UserName if found from Hashtable
                            if($IdHashtable.ContainsKey($WinGetUserId)) {
                                $Name = $IdHashtable[$WinGetUserId].name
                                if($Name) {
                                    $WinGetUserName = $IdHashtable[$WinGetUserId].name
                                } else {
                                    $WinGetUserName = $WinGetUserId
                                }
                            }

                            if($WinGetAppBytes) {
                                $LogEntryList[$b].ProcessRunTime = " Start Download WinGetApp ($WinGetAppName) ($($TotalBytesDownloadedMBRounded)MB) for user $WinGetUserName"
                            } else {
                                $LogEntryList[$b].ProcessRunTime = " Start Download WinGetApp ($WinGetAppName) for user $WinGetUserName"
                            }

                            #Write-Host "DEBUG: Found WinGet first download entry"
                            
                            # Continue For-loop to next round
                            Continue
                        }
                    }

                    $DownloadCompleteString1 = 'State transition processed - Previous State:Download In Progress received event: Download Finished which took state machine into Download Complete state.'
                    $DownloadCompleteString2 = 'Processing state transition - Current State:Download Complete With Event: Continue Install.'
                    if(($LogEntryList[$b].Message -eq $DownloadCompleteString1) -or ($LogEntryList[$b].Message -eq $DownloadCompleteString2)) {

                        $WinGetDownloadEnddDateTime = $LogEntryList[$b].DateTime                        

                        # Our if clause has or so in normal case we will find 2 log entries for download complete
                        # We need to make sure we only process one of those log entries
                        if(-not $DownloadDurationDateTimeObject) {
                            if($WinGetDownloadStartDateTime -and $WinGetDownloadEnddDateTime) {
                                $DownloadDurationDateTimeObject = New-Timespan (Get-Date $WinGetDownloadStartDateTime) (Get-Date $WinGetDownloadEnddDateTime)
                            }

                            $LogEntryList[$b].ProcessRunTime = " Download complete ($WinGetAppName) in $($DownloadDurationDateTimeObject.Seconds) seconds"

                        }

                        #Write-Host "DEBUG: Found WinGet download complete"
                        
                        # Continue For-loop to next round
                        Continue
                    }

                    
                    $WinGetInstallStartString = 'State transition processed - Previous State:Download Complete received event: Continue Install which took state machine into Install In Progress Download Complete state.'
                    if(($LogEntryList[$b].Message -eq $WinGetInstallStartString)) {
                        # -and ($LogEntryList[$b].Thread -eq $Thread)
                        $WinGetInstallStartdDateTime = $LogEntryList[$b].DateTime                        
                        $LogEntryList[$b].ProcessRunTime = " Start WinGetApp Install process ($WinGetAppName)"
                        #Write-Host "WinGet Found install start"
                    
                    }


                    # Get intent: RequiredInstall, RequiredUninstall, AvailableInstall, AvailableUninstall?
                    if(($LogEntryList[$b].Message -Match '^\[Win32App\]\[ActionProcessor\] App with id: (.*), targeted intent: (.*),.*$') -and ($LogEntryList[$b].Thread -eq $Thread)) {

                        $TempAppId = $Matches[1]
                        $BackupIntent = $Matches[2]

                    }


                    if(($LogEntryList[$b].Message -Match '^\[WinGet\] Install Operation Result has arrived (.*)$')) {
                        $WinGetInstallResult = $Matches[1]

                        # No updates available
                        if($WinGetInstallResult -eq 'NoApplicableUpgrade') {

                            #$WinGetAppName = Get-AppName $WinGetAppId

                            $LogEntryList[$i].ProcessRunTime = "Start WinGetApp processing ($WinGetAppName)"
                            $LogEntryList[$b].ProcessRunTime = "NoApplicableUpgrade for WinGetApp ($WinGetAppName)"

                            Break
                        }
            
                        
            
                        # Calculate install time
                        $ProcessStartTime = $LogEntryList[$i].LogEntryDateTime
                        $ProcessEndTime = $LogEntryList[$b].LogEntryDateTime
                        $ProcessRunTime = New-Timespan $ProcessStartTime $ProcessEndTime
                        $ProcessRunTimeTotalSeconds = $ProcessRunTime.TotalSeconds
                        
                        $WinGetInstallEndDateTime = $LogEntryList[$b].DateTime
                        $LogEntryList[$i].ProcessRunTime = "Start WinGetApp processing ($WinGetAppName)"

                        $intent = Get-AppIntent $AppId
                        if($intent -eq 'Unknown Intent') {
                            if($BackupIntent) {
                                
                                # This case when App is installed from Intune Company Portal
                                # Then that App is not shown in Get Policies -list
                                # Intent in AvailableInstall then
                                $intent = $BackupIntent
                            }
                        }

                        #$detail = "{0,$ColonIndentInTimeLine}{1} {2}" -f "$Intent", ':', "$WinGetAppName for user $WinGetUserName"
                        $detail = "$WinGetAppName for user $WinGetUserName"
                                            
                        if($WinGetInstallResult -eq 'OK') {
                            
                            RecordStatusToTimeline -date $LogEntryList[$b].DateTime -status "Success" -Type "WinGetApp" -Intent $intent -detail $detail -Seconds $ProcessRunTimeTotalSeconds -Color 'Green' -logEntry "Line $($LogEntryList[$b].Line)"

                            $LogEntryList[$b].ProcessRunTime = "Stop WinGetApp Install Success ($WinGetAppName) for user $WinGetUserName ($ProcessRunTimeTotalSeconds)"
                            
                        } else {
                            
                            RecordStatusToTimeline -date $LogEntryList[$b].DateTime -status "Failed" -Type "WinGetApp" -Intent $intent -detail $detail -Seconds $ProcessRunTimeTotalSeconds -Color 'Red' -logEntry "Line $($LogEntryList[$b].Line)"
                            
                            $LogEntryList[$b].ProcessRunTime = "Stop WinGetApp Install Failed ($WinGetAppName) for user $WinGetUserName ($ProcessRunTimeTotalSeconds)"
                        }
                        
                        # Add App Download information to list
                        if($DownloadDurationDateTimeObject) {
                            $BytesFromPeersMBRounded = 'N/A'
                            
                            $DownloadDurationSeconds = $DownloadDurationDateTimeObject.Seconds
                            
                            if($WinGetAppBytes -ne 'N/A') {
                                $DownloadMBps = $TotalBytesDownloadedMBExact / $DownloadDurationSeconds
                                $DownloadMBps = [math]::Round($DownloadMBps, 1)
                                if($DownloadMBps -eq 0) {
                                    $DownloadMBps = 'N/A'
                                }
                            } else {
                                $DownloadMBps = 'N/A'
                            }
                            
                            $BytesFromPeersPercentage = 'N/A'
                            
                            # Add download statistics object to list
                            $ApplicationDownloadStatistics.add([PSCustomObject]@{
                                'AppType' = 'WinGetApp'
                                'AppName' = $WinGetAppName
                                'DL Sec' = $DownloadDurationSeconds
                                'Size (MB)' = $TotalBytesDownloadedMBRounded
                                'MB/s' = $DownloadMBps
                                'Delivery Optimization %' = $BytesFromPeersPercentage
                                })
                        }

                        #Write-Host "WinGet Found install OK"
                        
                        # Break For-loop
                        Break
                    
                    }

                    if(((($LogEntryList[$b].Message -eq '[Win32App][V3Processor] Done processing subgraph.') -or `
                         ($LogEntryList[$b].Message -like '`[Win32App`]`[V3Processor`] Done processing * subgraphs.') -or `
                         ($LogEntryList[$b].Message -eq '[Win32App][V3Processor] All apps in the subgraph are not applicable due to assignment filters. Processing will be skipped.')))`
                         -and ($LogEntryList[$b].Thread -eq $Thread)) {

                        #Write-Host "DEBUG: Found WinGet Done Processing subgraph log entry"
                        #Write-Verbose "Subgraph $WinGetAppId did not include WinGet Application install ($($LogEntryList[$i].FileName) Line $($LogEntryList[$i].Line))"
                        
                        # Break For-loop
                        Break
                    }
                    
                    
                    # Check that we didn't go over end log of this installation process
                    # This is just fail safe check. We should not get here
                    # Except we will get here if log start entry was for App witch is notApplicable
                    if($LogEntryList[$b].Message -like '`[Win32App`]`[V3Processor`] Processing subgraph with app ids:*') {
                        # New WinGet installation started.
                        # We didn't catch all log entries to process WinGet installation
                        Write-Verbose "Could not detect WinGet installation for WinGet AppId: $WinGetAppId (Line $($LogEntryList[$i].Line))"
                        
                        # Break For-loop
                        Break
                    }

                    
                }
            }
            
        } # Region ends here

<#
        # region Application Detection check (Win32App and WinGetApp)
 
        #<![LOG[[Win32App][ActionProcessor] App with id: 334e8b62-281e-419a-b084-c7c806e4975f, targeted intent: RequiredInstall, and enforceability: Enforceable has projected enforcement classification: EnforcementPoint with desired state: Present. Current state is:
        if($LogEntryList[$i].Message -like '`[Win32App`]`[ActionProcessor`] App with id: *, targeted intent: *, and enforceability: * Current state is:') {
            if($LogEntryList[$i].Message -Match '^\[Win32App\]\[ActionProcessor\] App with id: (.*), targeted intent: (.*), and enforceability:.*classification: (.*) with desired state: (.*). Current state is:$') {
                $AppId = $Matches[1]
 
                # Example values
                # RequiredInstall
                # RequiredUninstall ?
                # NotTargeted (usually this application is superceeded)
                $AppIntent = $Matches[2]
                 
                # Example values
                # EnforcementPoint
                # InstalledOrNotInstalled
                $AppEnforcementClassification = $Matches[3]
                 
                # Example values
                # Present
                # NotPresent
                $AppDesiredState = $Matches[4]
 
                $AppDetectionStartIndex = $i
 
                $Thread = $LogEntryList[$i].Thread
                $DetectEndDateTime = $LogEntryList[$i].DateTime
 
                $DetectionAppName = Get-AppName $AppId
                 
                #Write-Verbose "DEBUG Application Detection: $DetectEndDateTime $DetectionAppName - $AppIntent"
 
                $AppDetectionResult = $null
                $AppApplicabilityResult = $null
                $AppIntuneCompanyPortalReport = $null
                $AppIntuneCompanyPortalReportJSON = $null
 
                # Find DetectionCheck status information
                # Now we go down (to end)
                for($b = $i + 1; $b -lt $LogEntryList.Count; $b++ ) {
 
                    #Detection = NotDetected
                    #Applicability = Applicable
                    #Reboot = Clean
                    #Local start time = 01/01/0001 0.00.00
                    #Local deadline time = 01/01/0001 0.00.00
 
                    # Use this to debug log entries
                    #Write-Host "DEBUG: $b - $($LogEntryList[$b].Message)"
 
                    if((-not $AppDetectionResult) -and (($LogEntryList[$b].Message -Match '^Detection = (.*)$') -and ($LogEntryList[$b].Thread -eq $Thread))) {
                        $AppDetectionResult = $Matches[1]
                         
                        # Continue For-loop to next round
                        Continue
                    }
                     
                    if((-not $AppApplicabilityResult) -and (($LogEntryList[$b].Message -Match '^Applicability = (.*)$') -and ($LogEntryList[$b].Thread -eq $Thread))) {
                        $AppApplicabilityResult = $Matches[1]
                         
                        # Continue For-loop to next round
                        Continue
                    }
 
                    # Try to find Intune Company Portal report aboud application detection
                    # This may not work during Autopilot enrollment ESP phase
                    # But it will work later
                     
                    # [Win32App][ReportingManager] Sending status to company portal based on report: {"ApplicationId":"1f3f5171-25a2-44da-b3e8-fba81184d1df","ResultantAppState":1,"ReportingImpact":{"DesiredState":3,"Classification":2,"ConflictReason":0,"ImpactingApps":[]},"WriteableToStorage":true,"CanGenerateComplianceState":true,"CanGenerateEnforcementState":true,"IsAppReportable":true,"IsAppAggregatable":true,"AvailableAppEnforcementFlag":0,"DesiredState":2,"DetectionState":1,"DetectionErrorOccurred":false,"DetectionErrorCode":null,"ApplicabilityState":0,"ApplicabilityErrorOccurred":false,"ApplicabilityErrorCode":null,"EnforcementState":1000,"EnforcementErrorCode":null,"TargetingMethod":0,"TargetingType":2,"InstallContext":1,"Intent":3,"InternalVersion":1,"DetectedIdentityVersion":"1.18.1462.0","RemovalReason":null}
 
                    if(($LogEntryList[$b].Message -Match '^\[Win32App\]\[ReportingManager\] Sending status to company portal based on report: (.*)$') -and ($LogEntryList[$b].Thread -eq $Thread)) {
                        $AppIntuneCompanyPortalReportJSON = $Matches[1]
                         
                        # Try to convert from report json
                        $AppIntuneCompanyPortalReport = $AppIntuneCompanyPortalReportJSON | ConvertFrom-JSON -ErrorAction SilentlyContinue
 
                        # Check we got IntuneCompanyPortal report for applicationId we are processing
                        # If there are Superceded applications or required application
                        # Then we do detection checks to other AppId's than the "main application"
                        if($AppIntuneCompanyPortalReport.ApplicationId -eq $AppId) {
                            # We found "main" application information
                        } else {
                            # We found some other applicationId's Company Portal report
                            # Set variables to $null
                            $AppIntuneCompanyPortalReportJSON = $null
                            $AppIntuneCompanyPortalReport = $null
                        }
                         
                        # Break For-loop
                        Break
                    }
 
                    # Check if we reached end of subgraph processing
                    # We should not end here
                    if((($LogEntryList[$b].Message -eq '[Win32App][V3Processor] Done processing subgraph.') -or `
                         ($LogEntryList[$b].Message -like '`[Win32App`]`[V3Processor`] Done processing * subgraphs.')) -and ($LogEntryList[$b].Thread -eq $Thread)) {
                        $AppApplicabilityResult = $Matches[1]
                         
                        #Write-Verbose "DEBUG: reached end of subgraph processing in App detection check. We should not get here."
                         
                        # Break For-loop
                        Break
                    }
                     
                    # Double check if we reached end of previous subgraph processing
                    # We should not end here
                    if($LogEntryList[$b].Message -like '`[Win32App`]`[V3Processor`] Processing subgraph with app ids:*') {
                        # Previous application processsing ended and we are already processing new application
                        # We should never get here
                         
                        # Break For-loop
                        Break
                    }
                } # For-loop ends
                 
                 
                if($AppIntuneCompanyPortalReport.DetectedIdentityVersion) {
                        $DetectAppVersion = $AppIntuneCompanyPortalReport.DetectedIdentityVersion
                    } else {
                        $DetectAppVersion = $null
                    }
                     
                    if($DetectAppVersion) {
                        # This should be WinGetApp
                        $detail = "{0,$ColonIndentInTimeLine}{1} {2}" -f "$AppIntent", ':', "$($DetectionAppName) $($DetectAppVersion) - $($AppDetectionResult) - $($AppApplicabilityResult)"
                         
                    } else {
                        # This should be Win32App
                        $detail = "{0,$ColonIndentInTimeLine}{1} {2}" -f "$AppIntent", ':', "$($DetectionAppName) - $($AppDetectionResult) - $($AppApplicabilityResult)"
                    }
 
                    RecordStatusToTimeline -date $LogEntryList[$i].DateTime -status "AppDetection" -detail $detail -Seconds $null -Color 'Yellow' -logEntry "Line $($LogEntryList[$i].Line)"
 
                    $LogEntryList[$i].ProcessRunTime = "Detection check for app: $DetectionAppName"
                     
            }
        }
        # region Application Detection check (Win32App and WinGetApp)
#>

        
        
        # region Application Detection check (Win32App and WinGetApp) nextGen


        if($LogEntryList[$i].Message -like '`[Win32App`]`[ReportingManager`] Sending status to company portal based on report: *') {
            if($LogEntryList[$i].Message -Match '^\[Win32App\]\[ReportingManager\] Sending status to company portal based on report: (.*)$') {
                $AppIntuneCompanyPortalReportJSON = $Matches[1]

                $AppIntuneCompanyPortalReport = $null
                
                $ResultantAppState = $null
                $DesiredState = $null
                $DetectionState = $null
                $InstallContext = $null
                $Intent = $null
                $InternalVersion = $null
                $DetectedIdentityVersion = $null
                $ImpactingApps = $null
                
                $DetectAppVersion = $null
                $AppIntent = $null
                $AppDetectionResult = $null
                $AppApplicabilityResult = $null
                
                $AppDetectionReportIndex = $i

                $Thread = $LogEntryList[$i].Thread
                $DetectEndDateTime = $LogEntryList[$i].DateTime

                Try {
                    $AppIntuneCompanyPortalReport = $AppIntuneCompanyPortalReportJSON | ConvertFrom-JSON -ErrorAction SilentlyContinue
                } catch {
                    Write-Verbose "Error converting Application Intune Company Portal detection report from JSON"
                }

                # Continue only if we have value in ResultantAppState property
                # This should get only 2 detections per application
                # one detection before app install and second detection after app install
                if($AppIntuneCompanyPortalReport.ResultantAppState) {
                    $ApplicationId = $AppIntuneCompanyPortalReport.ApplicationId
                    $ResultantAppState = $AppIntuneCompanyPortalReport.ResultantAppState
                    $DesiredState = $AppIntuneCompanyPortalReport.DesiredState
                    $DetectionState = $AppIntuneCompanyPortalReport.DetectionState
                    $InstallContext = $AppIntuneCompanyPortalReport.InstallContext
                    $Intent = $AppIntuneCompanyPortalReport.Intent
                    $InternalVersion = $AppIntuneCompanyPortalReport.InternalVersion
                    $DetectedIdentityVersion = $AppIntuneCompanyPortalReport.DetectedIdentityVersion
                    $ImpactingApps = $AppIntuneCompanyPortalReport.ReportingImpact.ImpactingApps

                    $DetectionAppName = Get-AppName $ApplicationId

                    # Required Install
                    # Required Uninstall
                    # Available
                    $AppIntent = Get-AppIntentNameForNumber $Intent

                    # Detected
                    # Not Detected
                    $AppDetectionResult = Get-AppDetectionResultForNumber $DetectionState

                    # Win32App
                    # WinGetApp
                    $DetectionAppType = Get-AppType $ApplicationId

                    if($AppIntuneCompanyPortalReport.DetectedIdentityVersion) {
                            $DetectAppVersion = $AppIntuneCompanyPortalReport.DetectedIdentityVersion
                    } else {
                        $DetectAppVersion = $null
                    }
                    
                    if($DetectAppVersion) {
                        # This should be WinGetApp
                        #$detail = "{0,$ColonIndentInTimeLine}{1} {2}" -f "$AppIntent", ':', "$($DetectionAppName) $($DetectAppVersion) - $($AppDetectionResult)"
                        #$detail = "$($DetectionAppName) $($DetectAppVersion) - $($AppDetectionResult)"
                        $detail = "`t$($DetectionAppName) $($DetectAppVersion)"
                        
                    } else {
                        # This should be Win32App
                        #$detail = "{0,$ColonIndentInTimeLine}{1} {2}" -f "$AppIntent", ':', "$($DetectionAppName) - $($AppDetectionResult)"
                        #$detail = "$($DetectionAppName) - $($AppDetectionResult)"
                        $detail = "`t$($DetectionAppName)"
                    }

                    $Color = 'Yellow'

                    # Check if there was error in detection check
                    if($AppIntuneCompanyPortalReport.DetectionErrorOccurred) {
                        # There was error in Detection Check
                        $DetectionErrorCode = $AppIntuneCompanyPortalReport.DetectionErrorCode
                        $detail = "$($detail). DetectionErrorOccurred: $DetectionErrorCode"
                        $Color = 'Red'
                    } else {
                        # There was no error in Detection Check
                    }

                    # Check if there was enforcement error (app was not detected after successful install)
                    if($AppIntuneCompanyPortalReport.EnforcementErrorCode) {
                        # There was error in Detection Enforcement Check -> App was not Detected after successful install
                        $EnforcementErrorCode = $AppIntuneCompanyPortalReport.EnforcementErrorCode
                        
                        if($AppDetectionResult -eq 'Not Detected') {
                            $detail = "$($detail). Not Detected after App install ($($EnforcementErrorCode))"
                        } else {
                            $detail = "$($detail). EnforcementErrorCode: $EnforcementErrorCode"
                        }
                        
                        $Color = 'Red'
                    } else {
                        # There was no error in Detection Enforcement Check -> App was detected after successfull install
                    }

                    # Create App Detection Details column ToolTip information
                    $Win32AppInformationForToolTip = $null
                    if($IdHashtable.ContainsKey($ApplicationId)) {
                        $Win32AppInformationForToolTip = $IdHashtable[$ApplicationId]
                        if($Win32AppInformationForToolTip) {
                            
                            # DEMO for conference session
                            # DEBUG
                            #$Win32AppInformationForToolTip | ConvertTo-Json -Depth 6 | Set-Clipboard
                            
                            # Not working, empty variable
                            #Write-Host "DEBUG: $($Win32AppInformationForToolTip.DetectionRule.value)" -ForegroundColor Red
                            
                            # And we notice that DetectionRule itself is also Json
                            # So we need to convert this from-json first
                            #Write-Host "DEBUG: $($Win32AppInformationForToolTip.DetectionRule)" -ForegroundColor Red
                            
                            $detailToolTip = $null
                            $DetectionRule = $null
                            $DetectionType = $null
                            $DetectionText = $null
                            if($Win32AppInformationForToolTip.DetectionRule) {
                                
                                # DEBUG
                                #$Win32AppInformationForToolTip.DetectionRule

                                # Import JSON
                                # Fix JSON double escapes
                                $DetectionRulesObject = $Win32AppInformationForToolTip.DetectionRule -replace '\\\\', '\\' | ConvertFrom-json
                                
                                #DEBUG
                                #$DetectionRulesObject
                                #$DetectionRulesObject | ConvertTo-Json -Depth 5 | Set-Clipboard
                                
                                # Process DetectionText properties
                                # First convert DetectionText JSON to Powershell objects
                                # Add each DetectionText property to temporary custom Powershell object
                                # and then replace DetectionText json with created custom object
                                foreach($rule in $DetectionRulesObject) {
                                    
                                    $detectionType = $rule.DetectionType
                                    $DetectionText = $rule.DetectionText -replace '\\\\', '\\' | ConvertFrom-Json
                                    
                                    # Temp object to save each property
                                    $tempObject = New-Object PSCustomObject
                                    
                                    foreach($property in $DetectionText.PSObject.Properties) {
                                        $propertyName = $property.Name
                                        $propertyValue = $property.Value
                                        
                                        $tempObject | Add-Member -MemberType noteProperty -Name $propertyName -Value $propertyValue
                                    }
                                    
                                    $rule.DetectionText = $tempObject
                                }                                
                                
                                #DEBUG
                                #$DetectionRulesObject
                                #$DetectionRulesObject | ConvertTo-Json -Depth 5 | Set-Clipboard

                                # Convert DetectionText to human readable values
                                $DetectionRulesObject = Convert-AppDetectionValuesToHumanReadable $DetectionRulesObject

                                
                                # Show information as list
                                $DetectionText = $DetectionRulesObject | Select-Object -Property DetectionTypeAsText -ExpandProperty DetectionText | Select-Object -Property DetectionTypeAsText,* -ErrorAction SilentlyContinue | Format-List -Property * | Out-String
                                
                                $detailToolTip = "Detection rules:`n$DetectionText" | Out-String
                            } else {
                                # There was no DetectionRule property
                                $detailToolTip = $null
                            }
                        } else {
                            # There was no Application information available
                        }
                    }
                    
                    RecordStatusToTimeline -date $LogEntryList[$i].DateTime -status $AppDetectionResult -Type $DetectionAppType -Intent $AppIntent -detail $detail -detailToolTip $detailToolTip -Seconds $null -Color $Color -logEntry "Line $($LogEntryList[$i].Line)"    

                    $LogEntryList[$i].ProcessRunTime = "$AppDetectionResult app: $DetectionAppName"
                }
            } # end log entry match



<#
                #Write-Verbose "DEBUG Application Detection: $DetectEndDateTime $DetectionAppName - $AppIntent"
 
                $AppDetectionResult = $null
                $AppApplicabilityResult = $null
                $AppIntuneCompanyPortalReport = $null
                $AppIntuneCompanyPortalReportJSON = $null
 
                # Find DetectionCheck status information
                # Now we go down (to end)
                for($b = $i + 1; $b -lt $LogEntryList.Count; $b++ ) {
 
                    #Detection = NotDetected
                    #Applicability = Applicable
                    #Reboot = Clean
                    #Local start time = 01/01/0001 0.00.00
                    #Local deadline time = 01/01/0001 0.00.00
 
                    # Use this to debug log entries
                    #Write-Host "DEBUG: $b - $($LogEntryList[$b].Message)"
 
                    if((-not $AppDetectionResult) -and (($LogEntryList[$b].Message -Match '^Detection = (.*)$') -and ($LogEntryList[$b].Thread -eq $Thread))) {
                        $AppDetectionResult = $Matches[1]
                         
                        # Continue For-loop to next round
                        Continue
                    }
                     
                    if((-not $AppApplicabilityResult) -and (($LogEntryList[$b].Message -Match '^Applicability = (.*)$') -and ($LogEntryList[$b].Thread -eq $Thread))) {
                        $AppApplicabilityResult = $Matches[1]
                         
                        # Continue For-loop to next round
                        Continue
                    }
 
                    # Try to find Intune Company Portal report aboud application detection
                    # This may not work during Autopilot enrollment ESP phase
                    # But it will work later
                     
                    # [Win32App][ReportingManager] Sending status to company portal based on report: {"ApplicationId":"1f3f5171-25a2-44da-b3e8-fba81184d1df","ResultantAppState":1,"ReportingImpact":{"DesiredState":3,"Classification":2,"ConflictReason":0,"ImpactingApps":[]},"WriteableToStorage":true,"CanGenerateComplianceState":true,"CanGenerateEnforcementState":true,"IsAppReportable":true,"IsAppAggregatable":true,"AvailableAppEnforcementFlag":0,"DesiredState":2,"DetectionState":1,"DetectionErrorOccurred":false,"DetectionErrorCode":null,"ApplicabilityState":0,"ApplicabilityErrorOccurred":false,"ApplicabilityErrorCode":null,"EnforcementState":1000,"EnforcementErrorCode":null,"TargetingMethod":0,"TargetingType":2,"InstallContext":1,"Intent":3,"InternalVersion":1,"DetectedIdentityVersion":"1.18.1462.0","RemovalReason":null}
 
                    if(($LogEntryList[$b].Message -Match '^\[Win32App\]\[ReportingManager\] Sending status to company portal based on report: (.*)$') -and ($LogEntryList[$b].Thread -eq $Thread)) {
                        $AppIntuneCompanyPortalReportJSON = $Matches[1]
                         
                        # Try to convert from report json
                        $AppIntuneCompanyPortalReport = $AppIntuneCompanyPortalReportJSON | ConvertFrom-JSON -ErrorAction SilentlyContinue
 
                        # Check we got IntuneCompanyPortal report for applicationId we are processing
                        # If there are Superceded applications or required application
                        # Then we do detection checks to other AppId's than the "main application"
                        if($AppIntuneCompanyPortalReport.ApplicationId -eq $AppId) {
                            # We found "main" application information
                        } else {
                            # We found some other applicationId's Company Portal report
                            # Set variables to $null
                            $AppIntuneCompanyPortalReportJSON = $null
                            $AppIntuneCompanyPortalReport = $null
                        }
                         
                        # Break For-loop
                        Break
                    }
 
                    # Check if we reached end of subgraph processing
                    # We should not end here
                    if((($LogEntryList[$b].Message -eq '[Win32App][V3Processor] Done processing subgraph.') -or `
                         ($LogEntryList[$b].Message -like '`[Win32App`]`[V3Processor`] Done processing * subgraphs.')) -and ($LogEntryList[$b].Thread -eq $Thread)) {
                        $AppApplicabilityResult = $Matches[1]
                         
                        #Write-Verbose "DEBUG: reached end of subgraph processing in App detection check. We should not get here."
                         
                        # Break For-loop
                        Break
                    }
                     
                    # Double check if we reached end of previous subgraph processing
                    # We should not end here
                    if($LogEntryList[$b].Message -like '`[Win32App`]`[V3Processor`] Processing subgraph with app ids:*') {
                        # Previous application processsing ended and we are already processing new application
                        # We should never get here
                         
                        # Break For-loop
                        Break
                    }
                } # For-loop ends
                 
                 
                if($AppIntuneCompanyPortalReport.DetectedIdentityVersion) {
                        $DetectAppVersion = $AppIntuneCompanyPortalReport.DetectedIdentityVersion
                    } else {
                        $DetectAppVersion = $null
                    }
                     
                    if($DetectAppVersion) {
                        # This should be WinGetApp
                        $detail = "{0,$ColonIndentInTimeLine}{1} {2}" -f "$AppIntent", ':', "$($DetectionAppName) $($DetectAppVersion) - $($AppDetectionResult) - $($AppApplicabilityResult)"
                         
                    } else {
                        # This should be Win32App
                        $detail = "{0,$ColonIndentInTimeLine}{1} {2}" -f "$AppIntent", ':', "$($DetectionAppName) - $($AppDetectionResult) - $($AppApplicabilityResult)"
                    }
 
                    RecordStatusToTimeline -date $LogEntryList[$i].DateTime -status "AppDetection" -detail $detail -Seconds $null -Color 'Yellow' -logEntry "Line $($LogEntryList[$i].Line)"
 
                    $LogEntryList[$i].ProcessRunTime = "Detection check for app: $DetectionAppName"
                     
            }
#>
            
            
        }
        # region Application Detection check (Win32App and WinGetApp) nextGen
        
        
        
        
        # Region ends here
        
        
        $i++
    }
    # endregion analyze log entries


    # Application download statistics
    Write-Host ""
    Write-Host "Application download statistics" -ForegroundColor Magenta
    $ApplicationDownloadStatistics | Format-Table

    # Filtered Assignments
    Write-Host ""
    Write-Host "Application Assignment Filters applied" -ForegroundColor Magenta
    $ApplicationAssignmentFilterApplied | Select-Object -Property AppType, Applicable, AppName, AssignmentFilterNames | Format-Table

    # This is aligned with Michael Niehaus's Get-AutopilotDiagnostics script just in case
    Write-Host ""
    Write-Host "OBSERVED TIMELINE" -ForegroundColor Magenta
    $observedTimeline |
        Format-Table @{
            label= 'Date'
            Expression = { $_.Date.Split('.')[0] }
        },
        @{
            Label = "Status"
            Expression =
            {
                Switch ($_.Color)
                {
                    'Red'    { $color = "91"; break }
                    'Yellow' { $color = '93'; break }
                    'Green'  { $color = "92"; break }
                    default { $color = "0" }
                }
                $e = [char]27
                "$e[${color}m$($_.Status)$e[0m"
            }
        },
        @{
            Label = "Type"
            Expression =
            {
                Switch ($_.Color)
                {
                    'Red'    { $color = "91"; break }
                    'Yellow' { $color = '93'; break }
                    'Green'  { $color = "92"; break }
                    default { $color = "0" }
                }
                $e = [char]27
                "$e[${color}m$($_.Type)$e[0m"
            }
        },
        @{
            Label = "Intent"
            Expression =
            {
                Switch ($_.Color)
                {
                    'Red'    { $color = "91"; break }
                    'Yellow' { $color = '93'; break }
                    'Green'  { $color = "92"; break }
                    default { $color = "0" }
                }
                $e = [char]27
                "$e[${color}m$($_.Intent)$e[0m"
            }
        },
        @{
            Label = "Detail"
            Expression =
            {
                Switch ($_.Color)
                {
                    'Red'    { $color = "91"; break }
                    #'Yellow' { $color = '93'; break }
                    'Green'  { $color = "92"; break }
                    default { $color = "0" }
                }
                $e = [char]27
                "$e[${color}m$($_.Detail)$e[0m"
            }
        },
        @{
            label= 'Seconds'
            Expression = { "{0,6:N0}" -f $_.Seconds }
        },logEntry

    Write-Host ""


    # Print $IdHastable
    #Write-Host "DEBUG: `$IdHastable:`n$($IdHashtable | ConvertTo-Json)"


    # Command line Parameter -ShowErrorsSummary
    if($ShowErrorsSummary) {
        if($PowershellScriptErrorLogs) {
            Write-Host "`n`n`n`n"
            Write-Host "####################################"
            Write-Host "Powershell scripts error logs found:"
            Write-Host "$($PowershellScriptErrorLogs -Join "`n")"
            Write-Host "`n"
        }
    }


    ############################################
    #region Export info to text file
    if($ExportTextFileName) {
        if(Test-Path $ExportTextFileName) {
            Write-Host "File $ExportTextFileName already exist. Give another filename to export info to." -ForegroundColor Yellow
            Write-Host "We will not overwrite existing files" -ForegroundColor Yellow
            $ExportTextFileName = $null
        }
    }

    $ExportDateTime = Get-Date -Format 'yyyyMMdd-HHmmss'

    if($ExportTextFileName) {
        Write-Host "Exporting information to text file: $ExportTextFileName"

        "Get-IntuneManagementExtensionDiagnostics.ps1 $ScriptVersion report" | Out-File -FilePath $ExportTextFileName -Force
        "https://github.com/petripaavola/Get-IntuneManagementExtensionDiagnostics`n" | Out-File -FilePath $ExportTextFileName -Append
        "Export DateTime: $ExportDateTime`n`n" | Out-File -FilePath $ExportTextFileName -Append

        "Application download statistics`n" | Out-File -FilePath $ExportTextFileName -Append
        $ApplicationDownloadStatistics | Format-Table | Out-File -FilePath $ExportTextFileName -Append

        "`nOBSERVED TIMELINE`n" | Out-File -FilePath $ExportTextFileName -Append
        $observedTimeline | Select-Object -Property Date, Status, Detail, Seconds, LogEntry | Format-Table | Out-File -FilePath $ExportTextFileName -Append

        if($PowershellScriptErrorLogs) {        
            "`n`nPowershell scripts errors`n" | Out-File -FilePath $ExportTextFileName -Append
            "$($PowershellScriptErrorLogs -Join "`n")"  | Out-File -FilePath $ExportTextFileName -Append
        }
    }
    #endregion Export info to text file
    ###################expor#########################



    # Command line Parameter -ConvertAllKnownGuidsToClearText
    if($ConvertAllKnownGuidsToClearText) {
        Foreach($HashtableKey in $IdHashtable.Keys) {
            #Write-Host "Processing Hashtable object $($IdHashtable[$HashtableKey].id)"

            if($IdHashtable[$HashtableKey].name) {            
                Foreach($LogEntry in $LogEntryList) {
                        $Logentry.Message = ($Logentry.Message).Replace($HashtableKey, "' $($IdHashtable[$HashtableKey].name) '")
                }
                
                Foreach($LogEntry in $LogEntryList | Where-Object ProcessRuntime -ne $null) {
                    $Logentry.ProcessRuntime = ($Logentry.ProcessRuntime).Replace($HashtableKey, "'$($IdHashtable[$HashtableKey].name)'")
                }
                
                # Continue to next HashtableKey
                # So we skip next if clause in case object has both name and displayName
                Continue
            }

            if($IdHashtable[$HashtableKey].displayName) {
                Foreach($LogEntry in $LogEntryList) {
                        $Logentry.Message = ($Logentry.Message).Replace($HashtableKey, "' $($IdHashtable[$HashtableKey].displayName) '")
                }
                
                Foreach($LogEntry in $LogEntryList | Where-Object ProcessRuntime -ne $null) {
                    $Logentry.ProcessRuntime = ($Logentry.ProcessRuntime).Replace($HashtableKey, "'$($IdHashtable[$HashtableKey].displayName)'")
                }
            }
        }
    }



    if($ExportHTML) {

        ########################################################################################################################
        # Create HTML report

    $head = @'
    <style>
        body {
            background-color: #FFFFFF;
            font-family: Arial, sans-serif;
        }
 
          header {
            background-color: #444;
            color: white;
            padding: 10px;
            display: flex;
            align-items: center;
          }
 
          header h1 {
            margin: 0;
            font-size: 24px;
            margin-right: 20px;
          }
 
          header .additional-info {
            display: flex;
            flex-direction: column;
            align-items: flex-start;
            justify-content: center;
          }
 
          header .additional-info p {
            margin: 0;
            line-height: 1.2;
          }
 
          header .author-info {
            display: flex;
            flex-direction: column;
            align-items: flex-end;
            justify-content: center;
            margin-left: auto;
          }
 
          header .author-info p {
            margin: 0;
            line-height: 1.2;
          }
           
          header .author-info a {
            color: white;
            text-decoration: none;
          }
 
          header .author-info a:hover {
            text-decoration: underline;
          }
 
        table {
            border-collapse: collapse;
            width: 100%;
            text-align: left;
        }
 
        table, table#TopTable {
            border: 2px solid #1C6EA4;
            /* background-color: #f7f7f4; */
            background-color: #012456; /* dark background */
            color: #f0f0f0; /* light color for the text */
        }
 
        table td, table th {
            /* border: 2px solid #AAAAAA; */
            border: 1px solid #1a2f6a;
            padding: 2px;
        }
 
        table td {
            font-size: 15px;
            white-space: pre-wrap; /* or 'pre' if you don't want any wrapping */
        }
 
        table th {
            font-size: 18px;
            font-weight: bold;
            color: #FFFFFF;
            background: #1C6EA4;
            background: -moz-linear-gradient(top, #5592bb 0%, #327cad 66%, #1C6EA4 100%);
            background: -webkit-linear-gradient(top, #5592bb 0%, #327cad 66%, #1C6EA4 100%);
            background: linear-gradient(to bottom, #5592bb 0%, #327cad 66%, #1C6EA4 100%);
        }
 
        table tr:hover {
            background-color: #1C6EA4;
            cursor: pointer;
        }
 
        table#TopTable td, table#TopTable th {
            vertical-align: top;
            text-align: center;
        }
 
        table thead th:first-child {
            border-left: none;
        }
 
        table thead th span {
            font-size: 14px;
            margin-left: 4px;
            opacity: 0.7;
        }
 
        table tfoot {
            font-size: 16px;
            font-weight: bold;
            color: #FFFFFF;
            background: #D0E4F5;
            background: -moz-linear-gradient(top, #dcebf7 0%, #d4e6f6 66%, #D0E4F5 100%);
            background: -webkit-linear-gradient(top, #dcebf7 0%, #d4e6f6 66%, #D0E4F5 100%);
            background: linear-gradient(to bottom, #dcebf7 0%, #d4e6f6 66%, #D0E4F5 100%);
            border-top: 2px solid #444444;
        }
 
        table tfoot .links {
            text-align: right;
        }
 
        table tfoot .links a {
            display: inline-block;
            background: #1C6EA4;
            color: #FFFFFF;
            padding: 2px 8px;
            border-radius: 5px;
        }
 
        /* Every other table row to different background color
        table tbody tr:nth-child(even) {
          background-color: #D0E4F5;
        }
        */
 
        /* Set table row background color */
        table tbody tr {
          /* background-color: #f7f7f4; */
          background-color: #012456;
        }
 
        select {
          font-family: "Courier New", monospace;
        }
 
      /* Set DownloadTable settings */
      #ApplicationDownloadStatistics {
        width: auto;
        max-width: 1000px;
        margin: 0 auto; /* Centers the table if it doesn't span the entire width of its container */
         
        /* Float -> align table to left
           This setting needs to be cleared after table with html syntax
           <div style="clear:both;"></div>
           because otherwise page bottom element will float on right of table
         */
        float: left;
      }
 
         
      footer {
        background-color: #444;
        color: white;
        padding: 10px;
        display: flex;
        align-items: center;
        justify-content: center;
      }
 
 
      footer .creator-info {
        display: flex;
        flex-direction: row;
        align-items: center;
        margin-right: 20px;
      }
 
      footer .creator-info p {
        line-height: 1.2;
        margin: 0;
      }
 
      footer .creator-info p.author-text {
        margin-right: 20px; /* Add margin-right rule here */
      }
 
      .profile-container {
        position: relative;
        width: 50px;
        height: 50px;
        border-radius: 50%;
        overflow: hidden;
        margin-right: 10px;
      }
 
      .profile-container img {
        width: 100%;
        height: 100%;
        object-fit: cover;
        transition: opacity 0.3s;
      }
 
      .profile-container img.black-profile {
        position: absolute;
        top: 0;
        left: 0;
        z-index: 1;
      }
 
      .profile-container:hover img.black-profile {
        opacity: 0;
      }
 
      footer .company-logo {
        width: 100px;
        height: auto;
        margin: 0 20px;
      }
 
      footer a {
        color: white;
        text-decoration: none;
      }
 
      footer a:hover {
        text-decoration: underline;
      }
 
       
        .filter-row {
          display: flex;
          align-items: center;
        }
 
        .control-group {
          display: flex;
          flex-direction: column;
          align-items: flex-start;
          margin-right: 16px;
        }
 
        .control-group label {
          font-weight: bold;
        }
 
      /* ################################################### */
      /* ToolTip related style. Do not remove */
 
       /* Tooltip container */
      .tooltip {
        position: relative;
        display: inline-block;
        cursor: pointer;
      }
 
      /* Tooltip text */
      .tooltip .tooltiptext {
        visibility: hidden;
        width: auto; /* Allow the tooltip to expand based on content */
        min-width: 600px; /* Set a minimum width for the tooltip */
        max-width: 90vw; /* Set a maximum width to 90% of the viewport width */
        background-color: #f9f9f9; /* White background for the tooltip */
        color: #333; /* Dark text for readability */
        text-align: left;
        border-radius: 6px; /* Rounded corners */
        border: 1px solid #ccc; /* Light gray border for better visibility */
        position: absolute;
        z-index: 1;
        bottom: 105%;
        left: 50%;
        transform: translateX(-50%);
        opacity: 0;
        transition: opacity 0.3s;
        padding: 15px 15px 15px 15px; /* Set bottom padding to 0 */
        font-family: Consolas, "Courier New", Monaco, monospace; /* Monospaced font for code */
        font-weight: normal; /* Ensure text is not bold */
        white-space: pre; /* Prevent wrapping and preserve all spaces and line breaks */
        overflow-x: auto; /* Horizontal scrolling for long lines of code */
        overflow-y: auto; /* Vertical scrolling for long content */
        max-height: 400px; /* Limit the height to prevent overflow */
        box-shadow: 0 4px 8px rgba(0, 0, 0, 0.1); /* Subtle shadow for depth */
      }
 
 
      /* Show tooltip text when hovering */
      .tooltip:hover .tooltiptext {
        visibility: visible;
        opacity: 1;
      }
 
 
      /* ################################################### */
      /* ToolTip context menu related style. Do not remove */
       
     .context-menu {
      position: absolute;
      background-color: #fff;
      border: 1px solid #ccc;
      box-shadow: 0 4px 8px rgba(0, 0, 0, 0.1);
      z-index: 1000;
      display: none;
      font-family: 'Arial', sans-serif; /* Use a nice modern font */
      font-size: 14px; /* Decrease the font size slightly */
      font-weight: bold; /* Make the text bold */
      border-radius: 4px; /* Rounded corners for a more polished look */
    }
 
    .context-menu ul {
      list-style-type: none;
      margin: 0;
      padding: 5px; /* Padding for spacing inside the menu */
    }
 
    .context-menu ul li {
      padding: 8px 12px;
      cursor: pointer;
      transition: background-color 0.2s ease; /* Smooth hover effect */
    }
 
    .context-menu ul li:hover {
      background-color: #eee; /* Lighter hover background for better visibility */
    }
    </style>
'@


        ############################################################
        # Create HTML report

        Write-Host "Create HTML report information"

        ######################
        #Write-Host "Create Observed Timeline HTML fragment."

        try {

            $PreContent = @"
                <div class="filter-row">
                    <div class="control-group">
                      <label><input type="checkbox" class="filterCheckbox" value="Win32App" onclick="toggleCheckboxes(this)"> Win32App</label>
                      <label><input type="checkbox" class="filterCheckbox" value="WinGetApp" onclick="toggleCheckboxes(this)"> WinGetApp</label>
                      <label><input type="checkbox" class="filterCheckbox" value="Powershell script" onclick="toggleCheckboxes(this)"> Powershell script</label>
                      <label><input type="checkbox" class="filterCheckbox" value="Remediation" onclick="toggleCheckboxes(this)"> Remediation</label>
                    </div>
                    <!-- Dropdown 1 -->
                    <div class="control-group">
                        <label for="dropdown1">Status</label>
                        <select id="filterDropdown1" multiple>
                          <option value="all" selected>All</option>
                        </select>
                    </div>
                    <!-- Dropdown 2 -->
                    <div class="control-group">
                        <label for="dropdown1">Type</label>
                        <select id="filterDropdown2">
                          <option value="all">All</option>
                        </select>
                    </div>
                    <!-- Dropdown 3 -->
                    <div class="control-group">
                    <label for="dropdown1">Intent</label>
                    <select id="filterDropdown3">
                          <option value="all">All</option>
                        </select>
                    </div>
                </div>
                <br>
                <div>
                    <input type="text" id="searchInput" placeholder="Search...">
                    <button id="clearSearch" onclick="clearSearch()">X</button>
                    <button id="resetFilter" onclick="resetFilters()">Reset filters</button>
                </div>
"@

            
            $observedTimelineHTML = $observedTimeline | ConvertTo-Html -As Table -Fragment -PreContent $PreContent

            # Fix &lt; &quot; etc...
            $observedTimelineHTML = Fix-HTMLSyntax $observedTimelineHTML

            # Fix column names
            #$AllAppsByDisplayNameHTML = Fix-HTMLColumns $AllAppsByDisplayNameHTML

            # Add TableId
            $TableId = 'ObservedTimeline'
            $observedTimelineHTML = $observedTimelineHTML.Replace('<table>',"<table id=`"$TableId`">")

            # Convert HTML Array to String which is requirement for HTTP PostContent
            $observedTimelineHTML = $observedTimelineHTML | Out-String


            # Debug- save $html1 to file
            #$observedTimelineHTML | Out-File "$PSScriptRoot\observedTimelineHTML.html"

        }
        catch {
            Write-Error "$($_.Exception.GetType().FullName)"
            Write-Error "$($_.Exception.Message)"
            Write-Error "Error creating HTML fragment information"
            Write-Host "Script will exit..."
            Pause
            Exit 1        
        }
        #############################
        # Create html
        Write-Host "Creating HTML report..."

        try {

            $ReportRunDateTime = (Get-Date).ToString("yyyyMMddHHmm")
            $ReportRunDateTimeHumanReadable = (Get-Date).ToString("yyyy-MM-dd HH:mm")
            $ReportRunDateFileName = (Get-Date).ToString("yyyyMMddHHmm")

            if($ExportHTMLReportPath) {
                if(Test-Path $ExportHTMLReportPath) {
                    $ReportSavePath = $ExportHTMLReportPath
                } else {
                    Write-Host "Warning: Parameter -ExportHTMLReportPath specified but destination directory does not exists ($ExportHTMLReportPath) !" -ForegroundColor Yellow
                    Write-Host "Warning: Defaulting to running directory ($PSScriptRoot)!" -ForegroundColor Yellow
                    $ReportSavePath = $PSScriptRoot
                }
            } else {
                $ReportSavePath = $PSScriptRoot
            }
            
            
            if($ComputerNameForReport -eq 'N/A') {
                $HTMLFileName = "$($ReportRunDateFileName)_Intune_Logs_Report.html"    
            } else {
                $HTMLFileName = "$($ReportRunDateFileName)_$($ComputerNameForReport)_Intune_Logs_Report.html"    
            }
            
            
            $PreContent = @"
        <header>
          <h1>Get-IntuneManagementExtensionDiagnostics ver $ScriptVersion</h1>
          <div class="additional-info">
            <p><strong>Report run:</strong> $ReportRunDateTimeHumanReadable</p>
            <p><strong>Computer Name:</strong> $ComputerNameForReport</p>
            <!-- <p><strong>Tenant name:</strong> $TenantDisplayName</p> -->
            <!-- <p><strong>Tenant id:</strong> $($ConnectMSGraph.TenantId)</p> -->
          </div>
          <div class="author-info">
            <p><a href="https://github.com/petripaavola/Get-IntuneManagementExtensionDiagnostics" target="_blank"><strong>Download Report tool from GitHub</strong></a><br>Author: Petri Paavola - Microsoft MVP</p>
          </div>
        </header>
        <br>
"@


            $JavascriptPostContent = @'
        <div style="clear:both;"></div>
        <p><br></p>
        <footer>
            <div class="creator-info">
            <p class="author-text">Author:</p>
              <div class="profile-container">
                <img src="data:image/png;base64,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" alt="Profile Picture Petri Paavola">
                <img class="black-profile" src="data:image/png;base64,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" alt="Black Profile Picture Petri Paavola">
              </div>
              <p><strong>Petri Paavola</strong><br>
                <a href="mailto:Petri.Paavola@yodamiitti.fi">Petri.Paavola@yodamiitti.fi</a><br>
                Senior Modern Management Principal
              </p>
            </div>
            <img class="company-logo" src="data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAGQAAAAoCAYAAAAIeF9DAAAAAXNSR0IArs4c6QAAAARnQU1BAACxjwv8YQUAAAAJcEhZcwAALiIAAC4iAari3ZIAAAAZdEVYdFNvZnR3YXJlAEFkb2JlIEltYWdlUmVhZHlxyWU8AAAS4klEQVRoQ+1bB3RVZbrdF0JIIRXSwITeQi8SupCAMogKjCDiQgZ9wltvRBxELAiCNGdQUFEERh31DYo61lEUEWQQmCC9SpUkEBJIJQlpQO7b+7/nxAQJ3JTnems99uLm3vOfc/7y7a/+5+DA1K1O3MD/GdSyvmsWJeT4col1cAOVQc0TcqkEtWo7EB5YFyi+bDXegLuoWUJIBoouo+D5GKTM7o7adWvfIKWSqDlCbDKW9oWnh6vbSy/0goeXx29PipMuU5/fChrrItdfA+usGUJEBidU9GpfeNUp3+XFRT3h6U1SSFaNIPcikFMMFFyyGgj9Vlsez0k4+TzW57cgRWNw3FtaB2L0zaEuUtRWxbGrn2VZZFykZXgwdlQEryfiUXSBQpIbqypIxu7Z3eDPPj4/kImpH5wwzfNHNsV93UJwMqMQA2duh/P9QabdMXETUI/K4Kh4XtVG/kWsHN8aD/UMM4eO8d8DHg7UCqqLEpPc8CPr0Rw8qay1rj2X6lmIyODnEi3jWmQIhX/uCe96dapnKczcOjf0RbP6XpjSP4KWwb7Y36Te4WhMAXRu5GsyvOPpBcgtO44EI81V5qffmre+L5bRZp2X4GzN1reuV7uuv7JNgtYx5yAyEjIL4Ri5Fi3bBML55gDc07k+QAVsw/muvL8V5gxv7CJD414DVSfEkOHEZZJR+zqs28hnsPf1qyYpRBbdUS1pXIAnQEWo7+OBnMJLuCQh8Xe3xfsR+dxOwJvWWFQCB+d3R5cGCAuoCw/Gt05R9YxAh7Sn0HQPhTqofTC6NvWj67PcHtuCqEC6LyrEy7VejhvCMe/o3AAecs089grzMXPK4O+g6GDc0tzfHIdqnb4eaMl7AxhHU9nvGJFkk1sBquaypElk2kkyqgK/p7chTwuvrPvKLjLat/FEDgZw4YOWH0QK+zk4vTM2Hj9vLCSILqPwvTjUpeAd929AD7qybVM6mNsTs4pw99tHsf1PHXA2txhhfp7weXKbURQbB1Ly0WF6PEYNvgkfUrNtOO5bj0dHN8eSu5pYLUD0X/bgvftacFwSfBXM+voUDqRegI9nbQxuFYAF65NxNDWfSlSxHVTeQiyzrioZQu6CGARIu6toKZtIiDCoVSDiWgYgk9qZyvhie810uopCzZPtNhmOKVvQ8YW98JUfJy4UlyB89g6sGNXMHEfw95xvT6F9hA9atwvGk7ENTbtj+Fr4U4FAaxEZR9MKGCc2mHO7p3ZElxnbze8dp/LMGKPeOWKOH/zgOOauO4VPt6fh0NkCPPp5Ao6euTYZQuUIMWTwHwN4dZE9vwcCA0lKYRVJ+TkHw9oG4VZmNx/vy6AWll9KidyOSCc+3Jth/HkOLayOxdq0LxJx9mg2YuS+iFRa2Md7M83vgS388cXBLPP74Ip+yCXZjSMZn4gP96RTDk58eyTbWKGJY8RFxYbMIqMMQloev6VwXrWxOykX2Wq/IgO9GtwnRGQQzqV9zHdNIGteDwQHs6KvJCneXNg/KGRpc1xLf2ZcWS7hlIHxw1bbZcUJwfoSLkuAdcq6TEep8tajK53z9hHM+iYJ0YwRUsCsfNccHVbGZhPL2wxMGGWbhxVP6UP4l791vTp2M866R4jI4GDOV2qODBsZc29GRCiDJoOyu6hLQj5j2it4U6hfHcwkIeUXXFuCoMYK93ZtYAJsWKg3EysXK0auXNPWhFxzHNk6AGMYwAVpfzR/z339EF75IcW05ajOIcYwoEvrB7YIQJ4swCZbYJ92EqWsz5AlS60Erk+IyOBizy/sgQmrj5vP61tSrZMujHvvmPGZc9aewk760tHvHsUb8Wetsy7Itz704QkkMbDet+oYJn10whzPWJOEM892R3MFRjdJCaU/P3U42zoiOGY4A7SfdgWIEN86rgKVgot9/aBpc77cB/FT2rNscFm6rEznJ6x21TJJM7vhidhG2JaYi33bz+Ff/9UOzs+H4JF+ETioQJxdjKmMA80beMH51gBzz+AVh9iRa0xlerKEDcfOm+NXRzbD8yOaVjpOXjvLsi1jSW+cpLY1Y9BSWiltcy7ubS7JuHARDSaxAKMQIuhCDj3RGUEPbERgM39kMU4Iu5Pz0HVaPGKYry++swn6MICiLgUiN+DFb6bPuSv6o/2ivUhMucA21yJ/BdYAUaxBcrjI7PPFCOfvS1TJdAorhPHIkwJJZg0SwfZadBHJrA3A4O3j74kBjAtrSaIWK+09Q40v4jlXPVKCuI71cY4V934RTcLrMCYpk8ug79/1E+OJ0lilvaE+6M54spZWVKL4Ucdh5lTAdDbtPDNH9ufFdDuWycYGxqVCpt3uuiuhYgtR8WORIWjhqjRDwrxLXYHwjRZAMupxUqpHAqUxFE4200cb645Sa+iXJ/QIcfXDfof2CIXz77GI6x5qzg1YdhAJz3RFQy643LZIWVDgSemFrgDJVDKVQTpdroQWnMb0NzmLBLA9he2GDPluziefMWoNY45iiarnk+cKXGRIToozHH893d7+pDzAn4Ln/C6SpHX7MrGLyYMhQz7Opw7HKcLXTCJK5KpoYRK25pSWQZnwPgVukbBmVzoKCytHhnB1QkQGfbNNRim4GFMNc+A10hpizU/ZCKZv9iNZJrMh+jJt1LX7zlDbie9ECBdwN7WwUIURr7Nj8Fv3NDfF0s7TFAaR/Gw3RIZXQIr6lwsQqfqtxcqKJVz1JwIEfdu/BQlKA+oeE2T5UR+6z7TxGlqpiC0FDxvT4pvwA15moNPql6SXEzT7jG0X5JqLwD57MvvzUp2l/iuBMrO2IDIoXOfiXlZDGVCo2roQRITwBTXrd20CkW0yJdckh7Pq1YKMZRDrLDdQn5ZUrIUTJugSqgeEYG9qoYWkWd0QJUFcSQqtwLm8P6KlFOqHLmPVg22wbGxLV6amxUtzbSHYx/yEUPPbmfs4HgU3eUBDLBxp+Xj2e3kZU3n91q0ii9fdxXWM0Fok6LJ92t/qW+C1b49t4Zqv7mU/zw+LYlzjmnRsf9xAeULooxsqaL14hWXY4ARiGjP4UrhfHnJlOXknczG8QzCKtFALOtaEVVEb0KQHadvABrUrgcH9q0NZiGO1rck+3Ne1OWcjkUG2B7WsVOssaNvk8YEs2qQ4/IztGoJsCUIC4rlGSqPl0nTM78j6rgdlozs1MAI2QqS1LGX29Ae5SwqvdpAnjqfRxZHgulRGL350j9ak2GD6kjzLCpeK1EiZlBlLdQfjhyxH13GMPLotwxcVxVeuTSgjo4rwKwtRH9dCMUm7vUN9JJKIExS0MJQFWoml+UJzxhP5bmUch+mv1elICcMGBbKbZA1bsAspvCaGqeScIVHWyWuArkYVsYpBLTSa3xrD5P5MEHKYST03JBIJ2gphZnSYKfXMwTdhBOPVgzGhmMikoo/ulUBpFf5KKCijB3j+lc0paM3K/9M/tMZPTEx60O0WUBlySJI2B2NbBQBMJJxLmPpzTXue7oL5t0fhq0c7mF3orswSV45rCedLVGYmPioUlRaPuyUCn01ojbR5NyNUT1G1OXkNlCeE/jGFQnY8ttVq+DU0yeHt6S+Jed+dNoL3UQppm6+FvrSSfAbWd5hCipFRZS2EmtKTi988twdOvdqX6ahre6MsGs/diR+ZyVxZ3Spd/YRBtRkF9BjdznzOQd5vaN9wLPnXGTz44l7sZ0IR1jIQrRnbJr52EJ9uSsHzG5LNtVvoYk08YT+r92SgHd3t/d1C8Oa2czhyMgdD/7KH1yWb3doiS8nO0wJtD6BMTGvuPD0ekz856ar0aUGZzDYnLt6H4X87jCdvjaScLiOPVvbiHU0weM5O3PPuMTwzqNGvLP5KlF+tILOjOTqm/ttqKI9CTtKYPvHujjREt6XGGZQnZITcFiX1zvY0gKbdgPGjFNSecPr0Pk39cJO05gpopzZJWZoytiugjbrn1p3GtFsaottNvohn3aB41ISu6cg5Wiz9tqwyOtwbTefvgvO/YxHVzM9YkdnHsrMJEvJX1kr3022F8Z7C1ALcSSvaNqc7GjJNLrr4y3pEuIxKf0yFzy6SX+uHkVxjptwl+05kZidXnkkXpm0ceTkZQwDd1crJ7TGxV6h5hlMu2bgKrn5WpJBhx59+sRQNoAlJU0I4sDouoakOi3ZZi0zfzrIEQwi1IeVsAYapurVgruC1dsV8JRpRm06rELsKGUI91i9pTBL0DEKJhS/nqiJwixXLlJJr7F2nLiCBblFr+GR8a+RTyYy3sDWUGVU809rpsQ1d+1ac+4uskWKoDHuZHapPeULJT9slnsrA2ObL8WNZ+b/MGPQOC8UWcs9cSxcVtnRpI5lJavNTXkPbK9rbmsiKfwytZ72SnLKZ3FVQMV0ihQPZpHgqVbQ1hegpt0UfqfhxNTQN5kQVzEjg6E6/uCubtDLclUI7rmfOVUyGYshe7ZjS2hZtPINlW1IpMIcp8vZSuNLeoyv7Gzd5nml0wgu9cIS+fuyqY/gnXdWfhzVGXBfORaRI7Sk0bUy+QXcl7dYuRCpjhIg9klZgdpFzGZxnrz2Fzx9oizf/oy2+PXIeG3ams4IPx6rpnfAB3Z6y0h9Yr2xjmdCVmdx3W1NpMcWGlF5L9yPp5d44uKgnIrSZaulDRbj+8xBNnrQ5X6r5fayyCH92B86yyq6wSrchFyHCtM3CBRstkX+XO1LlLI2RFqoGUAYkWAUc6OdN/+Y+CxS6uVeuTGtV+izl00f3CLpc1xl3w/7Vh/rWNYIehNljqUm7GUqhpdSajykJCM3bvqcCuPeAymgUp8Is5n8DIbO2Iz2D/v96ZAhaqGashUnw0vSKIKJEkg8Fcx3fXQql0yLHJsNdyOTNWG6s4Rpwb5bSKI7neGSL1VBzqD9TZDAguknGBGZTs+9qjH5KQyWAq/k+gWREsaa68FoftIlgQXiddNOAZPwnkwV/CdX2ze5Ac6BrWz+1o8uSqgE31YawzNwxueZICXrmR2RqX8wunK4HErL87uZYvTsdq1kb3HlziEsAshp9ZBFljmewBmm1YA8OMxMzlb3OKZkw2szfdkGp661zpQandrXJNeq3+pb1qM1ODOxj9U0Cm6oIrSBZcRfuEyKIFHmJyZuthqojcMaPyGbgc5sMgWNnUYiHD2ThthWHMJrZWwyzvHG9w/DU0CgEMn3d8FhHjNUrOfTXd7YLwpvaK6N7eziuEb5hEeepeEGf/hYLwNUT25rMaGyvMGx6vJN5XqKXEXIkZOKzP7bDolG8n0lDR9YbI1ivyAp66EUGxqNJtKbvp3VEJ+1ekDCzcVpNVI4QwZDigOPhqpOilxzOUxCVfsmBcEpbWZxNpBC/Y+E4rlsD3MbibuGXieYJZCwLwyeYyrYO88aXh7Iw4+sk3NahPlrQfQ1ZegA/UMizhzcxD6HGv38cQXRn834Xif4Ld+Mc0/in4hrSYkrw86zumPTRz7hA63lqZDO0C/fBJI4Zt2Sf6zk92/VsZ+C8Xa5juc8aQOUJEUQKtc7xx82laay7qPdUFd84Eegp9Hj10PJ+iPDzxNtfJcKPxzO/PoWmTDc/2ptutH/6l0m0nvrIppDOkHil3WFMaxeMaIL2JEAF7Ru0HG3BZzGzU4oeR+13ns03hS9pN9sqZ+nqZrPve5kqF9MV/e1HpsdUhhNKQDiXbLqz9x+h1SnBqAHrEKpGiKBMhFlObVqKqV7dgF65uUAtrBIZAofU/lA0a6NRyw/R1DyZDDmM29YU9DhX/tyL89IWTy3pDbMlvcSnV3Ce/mcSfB/bip8T8hi/trseLzD70utCC2+PwqC+ESwgqensy+xGq2P2UcCqXaHFPLdnf1JCT1rgst83w71zduC0XG9ls7IKwBGqAUNKLXiQFPOS2jWgV0kLtA9UVTIsBPvUMUWcnV6qclYBlsjCbGjbQHRnFf3WmOZ4aVOK2a6RBb3wfTL+Mb4VujIOaNdgIDW+JV2YdokjQrxxe/8Is2mpR8OyQAlXr+6MvS0Sax6KNntgwRzPfm7vz4xQROux7UBeE6ldX2qEtkmqi+q/2ysoA6FGFr3Sp/TN97KoOz0exQqU1SRDY8QxiK9nbDBuk+Mq2GrrPF/ZEIX0zK2RWLUzDSeTL6B9Uz8kMIvLo9vqxECsIL9sy1mzLfJQz1D8Nf4cktMLMXVQI/MS3cebUxHTPhjbEvNkFphCYf9EYr5lf1GR9UzQPsPrB7QNwsa9GbiV5Gt7RBuO24/loD/ntulotktRq4iaIUQQKXQn+u8IZd+Ar/N4PC5JWKpaawJKOcv0b8aVD5eLkd/SeR1LKKo91C53outkxbpXK9axNF7n9JCMX+acrtH9gtp1Xtfxp7lGxxpD/asPG7pG91aDDKHmCBEsUvTfEmQpHtP+jcs8rjEy/h+gZgkRSEotapr8caqykRtkVArVs6+rgZahNzL0RsgNMiqPmidEkJ91dzPvBsoA+B+htJLVXhyOiAAAAABJRU5ErkJggg==" alt="Microsoft MVP">
            <p style="margin: 0;">
              <a href="https://github.com/petripaavola/Get-IntuneManagementExtensionDiagnostics" target="_blank"><strong>Download Get-IntuneManagementExtensionDiagnostics from GitHub</strong<</a>
            </p>
        </footer>
        <script>
 
            // ##################################################################
            // Set constants
             
            const HTML_TABLE_ID = "ObservedTimeline";
            const COLUMN_INDEX_FOR_DROPDOWN1 = 2;
            const COLUMN_INDEX_FOR_DROPDOWN2 = 3;
            const COLUMN_INDEX_FOR_DROPDOWN3 = 4;
            const COLUMN_INDEX_FOR_CHECKBOXES = 3;
            const SORT_BY_COLUMN_INDEX = 0;
            const SORT_AS_INTEGER_COLUMNS = [0, 6];
            const INTENT_COLUMN_INDEX = 4;
 
            // Specify which columns will be sorted as integers
            const integerColumns = [0, 6];
 
            // Add column names you want to hide
            const COLUMN_NAMES_TO_HIDE = ['Index', 'Color', 'DetailToolTip'];
 
            // Add column names which are set to bold text
            const COLUMN_NAMES_TO_BOLD = ['Detail'];
 
            // Change table row background color based on Color column value
            // Green to success, red to fail and Yellow for Info
            const COLOR_COLUMN_NAME = 'Color';
            const NAMED_COLUMNS_TO_COLOR = ['Status', 'Type', 'Intent', 'Detail'];
 
     
            // Constant object with predefined hex color values
             
            const FONT_COLOR_CONSTANTS = {
                'Green': '#00ff00',
                'Red': '#ff0000',
                'Yellow': '#ffffcc'
                // Add other colors as needed
            };
 
 
            // Copy context menu settings
            const tooltipText = document.getElementById('tooltipText');
            const contextMenu = document.getElementById('customContextMenu');
            const copyTooltip = document.getElementById('copyTooltip');
 
 
            // ##################################################################
            // Functions for sorting and filtering
 
     
            function updateRowBackgroundOnColumnValueChange(tableId, columnIndex) {
              let table = document.getElementById(tableId);
              let rows = table.getElementsByTagName("tr");
              let previousValue = null;
              let currentColor = "rgba(208, 228, 245, 1)";
 
              table.setAttribute("data-last-color", currentColor);
 
              for (let i = 1; i < rows.length; i++) {
                let row = rows[i];
 
                // Skip hidden rows
                if (row.style.display === "none") {
                  continue;
                }
 
                let currentValue = row.getElementsByTagName("td")[columnIndex].textContent;
 
                if (previousValue !== null && currentValue !== previousValue) {
                  currentColor = table.getAttribute("data-last-color") === "rgba(242, 242, 242, 1)" ? "rgba(208, 228, 245, 1)" : "rgba(242, 242, 242, 1)";
                  table.setAttribute("data-last-color", currentColor);
                }
 
                row.style.backgroundColor = currentColor;
                previousValue = currentValue;
              }
            }
 
 
            function setColumnBold(tableId, columnIndex) {
              let table = document.getElementById(tableId);
              let rows = table.getElementsByTagName("tr");
 
              // Unbold previously selected column
              if (table.hasAttribute("data-bold-column")) {
                let previousBoldColumn = parseInt(table.getAttribute("data-bold-column"));
 
                rows[0].getElementsByTagName("th")[previousBoldColumn].style.fontWeight = "normal";
                for (let i = 1; i < rows.length; i++) {
                  rows[i].getElementsByTagName("td")[previousBoldColumn].style.fontWeight = "normal";
                }
              }
 
              // Set header text to bold
              rows[0].getElementsByTagName("th")[columnIndex].style.fontWeight = "bold";
 
              // Set column values to bold
              for (let i = 1; i < rows.length; i++) {
                rows[i].getElementsByTagName("td")[columnIndex].style.fontWeight = "bold";
              }
 
              // Save current bold column index
              table.setAttribute("data-bold-column", columnIndex);
            }
 
 
            function mergeSort(arr, comparator) {
              if (arr.length <= 1) {
                return arr;
              }
 
              const mid = Math.floor(arr.length / 2);
              const left = mergeSort(arr.slice(0, mid), comparator);
              const right = mergeSort(arr.slice(mid), comparator);
 
              return merge(left, right, comparator);
            }
 
            function merge(left, right, comparator) {
              let result = [];
              let i = 0;
              let j = 0;
 
              while (i < left.length && j < right.length) {
                if (comparator(left[i], right[j]) <= 0) {
                  result.push(left[i]);
                  i++;
                } else {
                  result.push(right[j]);
                  j++;
                }
              }
 
              return result.concat(left.slice(i)).concat(right.slice(j));
            }
 
            // Declare a sortingDirections object outside the sortTable function to store the sorting directions for each column.
            const sortingDirections = {};
 
 
            function sortTable(n, tableId, dateColumns = []) {
              let table, rows;
              table = document.getElementById(tableId);
 
              // Initialize the sorting direction for the column if it hasn't been set yet
              if (!(n in sortingDirections)) {
                sortingDirections[n] = "asc";
              }
 
              // Remove existing arrow icons
              let headerRow = table.getElementsByTagName("th");
              for (let i = 0; i < headerRow.length; i++) {
                headerRow[i].innerHTML = headerRow[i].innerHTML.replace(
                  /<span>.*<\/span>/,
                  "<span>&#8597;</span>"
                );
              }
 
              const isDateColumn = dateColumns.includes(n);
              rows = Array.from(table.rows).slice(1);
 
              const comparator = (a, b) => {
                const x = a.cells[n].innerHTML.toLowerCase();
                const y = b.cells[n].innerHTML.toLowerCase();
                const isIntegerColumn = integerColumns.includes(n);
 
                if (isDateColumn) {
                  const xDate = getDateFromString(x);
                  const yDate = getDateFromString(y);
 
                  if (sortingDirections[n] === "asc") {
                    return xDate - yDate;
                  } else {
                    return yDate - xDate;
                  }
                } else if (isIntegerColumn) {
                    const xInt = parseInt(x, 10) || 0; // Use 0 if parsing fails
                    const yInt = parseInt(y, 10) || 0; // Use 0 if parsing fails
                    if (sortingDirections[n] === "asc") {
                      return xInt - yInt;
                    } else {
                      return yInt - xInt;
                    }
                } else {
                  if (sortingDirections[n] === "asc") {
                    return x.localeCompare(y);
                  } else {
                    return y.localeCompare(x);
                  }
                }
              };
 
              const sortedRows = mergeSort(rows, comparator);
 
              // Reinsert sorted rows into the table
              for (let i = 0; i < sortedRows.length; i++) {
                table.tBodies[0].appendChild(sortedRows[i]);
              }
 
              // Update arrow icon for the last sorted column
              if (sortingDirections[n] === "asc") {
                headerRow[n].innerHTML = headerRow[n].innerHTML.replace(
                  /<span>.*<\/span>/,
                  "<span>&#x25B2;</span>"
                );
              } else {
                headerRow[n].innerHTML = headerRow[n].innerHTML.replace(
                  /<span>.*<\/span>/,
                  "<span>&#x25BC;</span>"
                );
              }
 
              // Create row coloring based on selected column
              updateRowBackgroundOnColumnValueChange(HTML_TABLE_ID, n);
 
              // Bold selected column header and text
              setColumnBold(HTML_TABLE_ID, n);
 
              // Toggle sorting direction for the next click
              sortingDirections[n] = sortingDirections[n] === "asc" ? "desc" : "asc";
            }
 
 
            function getDateFromString(dateStr) {
                let [day, month, year, hours, minutes, seconds] = dateStr.split(/[. :]/);
                return new Date(year, month - 1, day, hours, minutes, seconds);
            }
 
            // Populates the given select element with unique values from the specified table and column
            function populateSelectWithUniqueColumnValues(tableId, column, selectId) {
              let table = document.getElementById(tableId);
              let rows = table.getElementsByTagName("tr");
              let uniqueValues = {};
 
              for (let i = 1; i < rows.length; i++) {
                let cellValue = rows[i].getElementsByTagName("td")[column].innerText;
 
                if (uniqueValues[cellValue]) {
                  uniqueValues[cellValue]++;
                } else {
                  uniqueValues[cellValue] = 1;
                }
              }
 
              let select = document.getElementById(selectId);
 
              // Convert the uniqueValues object to an array of key-value pairs
              let uniqueValuesArray = Object.entries(uniqueValues);
 
              // Sort the array by the keys (unique column values)
              uniqueValuesArray.sort((a, b) => a[0].localeCompare(b[0]));
 
              // Find the longest text
              let longestTextLength = Math.max(...uniqueValuesArray.map(([value, count]) => (value + " (" + count + ")").length));
 
              // Loop through the sorted array to create the options with padded number values
              for (let [value, count] of uniqueValuesArray) {
                let optionText = value + " (" + count + ")";
                let paddingLength = longestTextLength - optionText.length;
                let padding = "\u00A0".repeat(paddingLength);
                let option = document.createElement("option");
                option.value = value;
                option.text = value + padding + " (" + count + ")";
                select.add(option);
              }
            }
 
            // This is used to extract AssignmentGroupDisplayName from complex a tag with span
            function getDirectChildTextNotWorking(parentNode) {
                let childNodes = parentNode.childNodes;
                let textContent = '';
 
                for(let i = 0; i < childNodes.length; i++) {
                    if(childNodes[i].nodeType === Node.TEXT_NODE) {
                        textContent += childNodes[i].nodeValue;
                    }
                }
 
                return textContent.trim(); // Remove leading/trailing whitespaces
            }
 
 
            // Returns the textContent of the node if the node doesn't have any child nodes
            // In some Columns we have a href tag so we in those cases we need to get display value from <a> tag
            function getDirectChildText(node) {
              if (!node || !node.hasChildNodes()) return node.textContent.trim();
              return Array.from(node.childNodes)
                .filter(child => child.nodeType === Node.TEXT_NODE)
                .map(textNode => textNode.textContent)
                .join("");
            }
 
 
            // Filters the table based on the selected dropdown values, checkboxes, and search input
            // Should find AssignmentGroup and Filter displayNames for filtering from a href tag
            function combinedFilter(tableId, columnIndexForDropdown1, columnIndexForDropdown2, columnIndexForDropdown3, columnIndexForCheckboxes) {
              let table = document.getElementById(tableId);
              let rows = table.getElementsByTagName("tr");
              let checkboxes = document.getElementsByClassName("filterCheckbox");
              let dropdown1 = document.getElementById("filterDropdown1");
              let dropdown2 = document.getElementById("filterDropdown2");
              let dropdown3 = document.getElementById("filterDropdown3");
              let searchInput = document.getElementById("searchInput");
              let searchText = searchInput.value.toLowerCase();
 
              let selectedDropdownValues1 = Array.from(dropdown1.selectedOptions).map(option => option.value);
 
              for (let i = 1; i < rows.length; i++) {
                let row = rows[i];
                let cell1 = row.getElementsByTagName("td")[columnIndexForDropdown1];
                let cell2 = row.getElementsByTagName("td")[columnIndexForDropdown2];
                let cell3 = row.getElementsByTagName("td")[columnIndexForDropdown3];
 
                let cellValueDropdown1 = getDirectChildText(cell1.querySelector('a') || cell1);
                let cellValueDropdown2 = getDirectChildText(cell2.querySelector('a') || cell2);
                let cellValueDropdown3 = getDirectChildText(cell3.querySelector('a') || cell3);
 
                let showRowByDropdown1 = selectedDropdownValues1.includes("all") || selectedDropdownValues1.includes(cellValueDropdown1);
                let showRowByDropdown2 = dropdown2.value === "all" || cellValueDropdown2 === dropdown2.value;
                let showRowByDropdown3 = dropdown3.value === "all" || cellValueDropdown3 === dropdown3.value;
 
                let showRowByCheckboxes = true;
                for (let checkbox of checkboxes) {
                  if (checkbox.checked) {
                    let cellValue = row.getElementsByTagName("td")[columnIndexForCheckboxes].textContent;
                    let checkboxValues = checkbox.value.split(",");
                    if (!checkboxValues.includes(cellValue)) {
                      showRowByCheckboxes = false;
                      break;
                    }
                  }
                }
 
                let showRowBySearch = true;
                if (searchText) {
                  showRowBySearch = false;
                  let cells = row.getElementsByTagName("td");
                  for (let cell of cells) {
                    if (getDirectChildText(cell.querySelector('a') || cell).toLowerCase().includes(searchText)) {
                      showRowBySearch = true;
                      break;
                    }
                  }
                }
 
                row.style.display = (showRowByDropdown1 && showRowByDropdown2 && showRowByDropdown3 && showRowByCheckboxes && showRowBySearch) ? "" : "none";
              }
 
              let visibleRowCount = 0;
              for (let i = 1; i < rows.length; i++) {
                if (rows[i].style.display !== 'none') {
                  visibleRowCount++;
                }
              }
 
              const noResultsMessage = document.getElementById('noResultsMessage');
              if (visibleRowCount === 0) {
                noResultsMessage.style.display = 'block';
              } else {
                noResultsMessage.style.display = 'none';
              }
            }
            // function combinedFilter ends
 
 
            // Unchecks the other checkboxes in the group and updates the table filters
            function toggleCheckboxes(checkbox) {
              let checkboxes = document.getElementsByClassName("filterCheckbox");
               
              for (let cb of checkboxes) {
                if (cb !== checkbox) {
                  cb.checked = false;
                }
              }
               
              combinedFilter(HTML_TABLE_ID, COLUMN_INDEX_FOR_DROPDOWN1, COLUMN_INDEX_FOR_DROPDOWN2, COLUMN_INDEX_FOR_DROPDOWN3, COLUMN_INDEX_FOR_CHECKBOXES);
            }
 
 
            // Clears the search input and updates the table filters
            function clearSearch() {
              let searchInput = document.getElementById("searchInput");
              searchInput.value = "";
              combinedFilter(HTML_TABLE_ID, COLUMN_INDEX_FOR_DROPDOWN1, COLUMN_INDEX_FOR_DROPDOWN2, COLUMN_INDEX_FOR_DROPDOWN3, COLUMN_INDEX_FOR_CHECKBOXES);
            }
 
            // Resets all filters and updates the table
            function resetFilters() {
              let searchInput = document.getElementById("searchInput");
              searchInput.value = "";
               
              let checkboxes = document.getElementsByClassName("filterCheckbox");
              for (let checkbox of checkboxes) {
                checkbox.checked = false;
              }
               
              let filterDropdown1 = document.getElementById("filterDropdown1");
              filterDropdown1.value = "all";
               
              let filterDropdown2 = document.getElementById("filterDropdown2");
              filterDropdown2.value = "all";
               
              let filterDropdown3 = document.getElementById("filterDropdown3");
              filterDropdown3.value = "all";
               
              combinedFilter(HTML_TABLE_ID, COLUMN_INDEX_FOR_DROPDOWN1, COLUMN_INDEX_FOR_DROPDOWN2, COLUMN_INDEX_FOR_DROPDOWN3, COLUMN_INDEX_FOR_CHECKBOXES);
            }
 
 
             
            // Change Intent column cell background color based on value (Included or Excluded)
            function colorCells(tableId, columnIndex) {
              let table = document.getElementById(tableId);
              for (let i = 0; i < table.rows.length; i++) {
                let cell = table.rows[i].cells[columnIndex];
                if (cell) {
                  switch (cell.innerText.trim()) {
                    case 'Included':
                      cell.style.backgroundColor = 'lightgreen';
                      break;
                    case 'Excluded':
                      cell.style.backgroundColor = 'lightSalmon';
                      break;
                    default:
                      break;
                  }
                }
              }
            }
 
 
            // Event listeners for the dropdowns and checkboxes
            document.getElementById("filterDropdown1").addEventListener("change", function() {
              combinedFilter(HTML_TABLE_ID, COLUMN_INDEX_FOR_DROPDOWN1, COLUMN_INDEX_FOR_DROPDOWN2, COLUMN_INDEX_FOR_DROPDOWN3, COLUMN_INDEX_FOR_CHECKBOXES);
            });
 
            document.getElementById("filterDropdown2").addEventListener("change", function() {
              combinedFilter(HTML_TABLE_ID, COLUMN_INDEX_FOR_DROPDOWN1, COLUMN_INDEX_FOR_DROPDOWN2, COLUMN_INDEX_FOR_DROPDOWN3, COLUMN_INDEX_FOR_CHECKBOXES);
            });
             
            document.getElementById("filterDropdown3").addEventListener("change", function() {
              combinedFilter(HTML_TABLE_ID, COLUMN_INDEX_FOR_DROPDOWN1, COLUMN_INDEX_FOR_DROPDOWN2, COLUMN_INDEX_FOR_DROPDOWN3, COLUMN_INDEX_FOR_CHECKBOXES);
            });
 
            let checkboxes = document.getElementsByClassName("filterCheckbox");
            for (let checkbox of checkboxes) {
              checkbox.addEventListener("change", function() {
                combinedFilter(HTML_TABLE_ID, COLUMN_INDEX_FOR_DROPDOWN1, COLUMN_INDEX_FOR_DROPDOWN2, COLUMN_INDEX_FOR_DROPDOWN3, COLUMN_INDEX_FOR_CHECKBOXES);
              });
            }
 
            // Add an event listener for the search input
            document.getElementById("searchInput").addEventListener("input", function() {
              combinedFilter(HTML_TABLE_ID, COLUMN_INDEX_FOR_DROPDOWN1, COLUMN_INDEX_FOR_DROPDOWN2, COLUMN_INDEX_FOR_DROPDOWN3, COLUMN_INDEX_FOR_CHECKBOXES);
            });
 
        // Another approach to get function to run when loading page
        // This is not needed but left here on purpose just in case needed in the future
        //window.addEventListener('load', function() {
        // sortTable(2, HTML_TABLE_ID, SORT_AS_INTEGER_COLUMNS);
        //});
 
 
            // ##################################################################
            // Functions for right click functionality (Copy, etc...)
 
            // Add right-click menu for copying tooltip text
            function addRightClickCopyTooltip(tableId, tooltipSourceColumnName) {
              let table = document.getElementById(tableId);
              let headerCells = table.getElementsByTagName('th');
              let sourceColumnIndex = null;
 
              // Find the index of the tooltip source column
              for (let i = 0; i < headerCells.length; i++) {
                if (headerCells[i].innerText.trim() === tooltipSourceColumnName) {
                  sourceColumnIndex = i;
                  break;
                }
              }
 
              if (sourceColumnIndex === null) {
                console.error(`Couldn't find the '${tooltipSourceColumnName}' column.`);
                return;
              }
 
              // Create a custom context menu
              const contextMenu = document.createElement('div');
              contextMenu.className = 'context-menu';
              contextMenu.style.display = 'none';
              contextMenu.innerHTML = '<ul><li id="copyTooltip">Copy ToolTip Text</li></ul>';
              document.body.appendChild(contextMenu);
 
              // Function to hide the context menu
              function hideContextMenu() {
                contextMenu.style.display = 'none';
              }
 
              // Function to show the custom context menu
              function showContextMenu(event, tooltipText) {
                event.preventDefault();
 
                // Position the menu at the mouse position
                contextMenu.style.left = event.pageX + 'px';
                contextMenu.style.top = event.pageY + 'px';
                contextMenu.style.display = 'block';
 
                // Copy tooltip text when clicking "Copy Tooltip"
                document.getElementById('copyTooltip').onclick = function () {
                  navigator.clipboard.writeText(tooltipText).then(() => {
                    alert('Tooltip text copied to clipboard!');
                  }).catch(err => {
                    alert('Failed to copy text: ' + err);
                  });
 
                  hideContextMenu();
                };
              }
 
              // Attach right-click context menu event to each row
              for (let row of table.rows) {
                if (row.cells[sourceColumnIndex] && row.cells[sourceColumnIndex].innerText.trim() !== "") {
                  let tooltipText = row.cells[sourceColumnIndex].innerText.trim();
 
                  row.addEventListener('contextmenu', function (event) {
                    showContextMenu(event, tooltipText);
                  });
                }
              }
 
              // Hide context menu when clicking outside of it
              document.addEventListener('click', function (event) {
                if (!contextMenu.contains(event.target)) {
                  hideContextMenu();
                }
              });
            }
 
 
        // Hide named columns
        function hideColumnsByNames(tableId, columnNames) {
            let table = document.getElementById(tableId);
            let headerCells = table.getElementsByTagName('th');
            let columnIndices = [];
 
            // Find the indices of the columns with the given names
            for (let i = 0; i < headerCells.length; i++) {
                if (columnNames.includes(headerCells[i].innerText.trim())) {
                    columnIndices.push(i);
                }
            }
 
            // If columns with the given names are found, hide them
            for (let index of columnIndices) {
                for (let row of table.rows) {
                    row.cells[index].style.display = 'none';
                }
            }
        }
 
        // This static approach is used
        // if we don't use column sorting which changes bolding also to sorted column
        function boldColumnValues(tableId, columnNames) {
            let table = document.getElementById(tableId);
            let headerCells = table.getElementsByTagName('th');
            let columnIndices = [];
 
            // Find the indices of the columns with the given names
            for (let i = 0; i < headerCells.length; i++) {
                if (columnNames.includes(headerCells[i].innerText.trim())) {
                    columnIndices.push(i);
                }
            }
 
            // If columns with the given names are found, set their text to bold
            for (let index of columnIndices) {
                for (let row of table.rows) {
                    let cell = row.cells[index];
                    if (cell) {
                        cell.style.fontWeight = 'bold';
                        // No need to change the background color, as it will be preserved.
                    }
                }
            }
        }
 
 
        // Change html table row background colors based on Color column value
        function adjustColumnColorsByAnotherColumn(tableId, colorColumnName, targetColumnNames) {
            let table = document.getElementById(tableId);
            let headerCells = table.getElementsByTagName('th');
            let colorColumnIndex = null;
            let targetColumnIndices = [];
 
            // Find the index of the "Color" column (or the column specified in colorColumnName)
            for (let i = 0; i < headerCells.length; i++) {
                if (headerCells[i].innerText.trim() === colorColumnName) {
                    colorColumnIndex = i;
                }
                if (targetColumnNames.includes(headerCells[i].innerText.trim())) {
                    targetColumnIndices.push(i);
                }
            }
 
            if (colorColumnIndex === null) {
                console.error(`Couldn't find the '${colorColumnName}' column.`);
                return;
            }
 
            // Iterate over each row in the table
            for (let row of table.rows) {
                let colorValue = row.cells[colorColumnIndex].innerText.trim();
                if (colorValue) {
                    for (let targetIndex of targetColumnIndices) {
                        row.cells[targetIndex].style.backgroundColor = colorValue;
                    }
                }
            }
        }
 
 
        // Change html table cell text colors based on Color column value
        function adjustColumnColorsByAnotherColumn(tableId, colorColumnName, targetColumnNames) {
            let table = document.getElementById(tableId);
            let headerCells = table.getElementsByTagName('th');
            let colorColumnIndex = null;
            let targetColumnIndices = [];
 
            // Find the index of the "Color" column (or the column specified in colorColumnName)
            for (let i = 0; i < headerCells.length; i++) {
                if (headerCells[i].innerText.trim() === colorColumnName) {
                    colorColumnIndex = i;
                }
                if (targetColumnNames.includes(headerCells[i].innerText.trim())) {
                    targetColumnIndices.push(i);
                }
            }
 
            if (colorColumnIndex === null) {
                console.error(`Couldn't find the '${colorColumnName}' column.`);
                return;
            }
 
            // Iterate over each row in the table
            for (let row of table.rows) {
                let colorValue = row.cells[colorColumnIndex].innerText.trim();
                if (colorValue) {
                    let fontColor = FONT_COLOR_CONSTANTS[colorValue] || colorValue;
                    for (let targetIndex of targetColumnIndices) {
                        row.cells[targetIndex].style.color = fontColor;
                    }
                }
            }
        }
         
         
        // Add HoverOn ToolTip from named ToolTip Column to named Target Column
        function addTooltipFromSourceColumnToTargetColumn(tableId, tooltipSourceColumnName, targetColumnNames) {
            let table = document.getElementById(tableId);
            let headerCells = table.getElementsByTagName('th');
            let sourceColumnIndex = null;
            let targetColumnIndices = [];
 
            // Find the index of the tooltip source column
            for (let i = 0; i < headerCells.length; i++) {
                if (headerCells[i].innerText.trim() === tooltipSourceColumnName) {
                    sourceColumnIndex = i;
                }
                if (targetColumnNames.includes(headerCells[i].innerText.trim())) {
                    targetColumnIndices.push(i);
                }
            }
 
            if (sourceColumnIndex === null) {
                console.error(`Couldn't find the '${tooltipSourceColumnName}' column.`);
                return;
            }
 
            // Iterate over each row in the table
            for (let row of table.rows) {
                if (row.cells[sourceColumnIndex] && row.cells[sourceColumnIndex].innerText.trim() !== "") {
                    let tooltipText = row.cells[sourceColumnIndex].innerText.trim();
                     
                    for (let targetIndex of targetColumnIndices) {
                        if (row.cells[targetIndex]) {
                            // Create the tooltip span element
                            let tooltipSpan = document.createElement('span');
                            tooltipSpan.className = 'tooltiptext';
                            tooltipSpan.innerText = tooltipText;
 
                            // Append the tooltip to the target cell and add the tooltip class to the cell
                            row.cells[targetIndex].classList.add('tooltip');
                            row.cells[targetIndex].appendChild(tooltipSpan);
                        }
                    }
                }
            }
        }
         
         
        window.onload = function() {
             
            // Call this function to populate the first dropdown with unique values from the specified table and column
            populateSelectWithUniqueColumnValues(HTML_TABLE_ID, COLUMN_INDEX_FOR_DROPDOWN1, "filterDropdown1");
 
            // Call this function to populate the second dropdown with unique values from the specified table and column
            populateSelectWithUniqueColumnValues(HTML_TABLE_ID, COLUMN_INDEX_FOR_DROPDOWN2, "filterDropdown2");
             
            // Call this function to populate the third dropdown with unique values from the specified table and column
            populateSelectWithUniqueColumnValues(HTML_TABLE_ID, COLUMN_INDEX_FOR_DROPDOWN3, "filterDropdown3");
 
            // Not needed anymore because sorting will also do this automatically
            //updateRowBackgroundOnColumnValueChange(HTML_TABLE_ID, 2);
 
            // Sort table by name so user knowns which column was sorted
            //sortTable(SORT_BY_COLUMN_INDEX, HTML_TABLE_ID, SORT_AS_INTEGER_COLUMNS);
             
            // Change Intent column background color
            colorCells(HTML_TABLE_ID, INTENT_COLUMN_INDEX);
             
            // Hide columns
            hideColumnsByNames(HTML_TABLE_ID, COLUMN_NAMES_TO_HIDE);
             
            // Bold named column(s)
            boldColumnValues(HTML_TABLE_ID, COLUMN_NAMES_TO_BOLD);
 
            // Change row background color value based on Color column value
            //adjustColumnColorsByAnotherColumn(HTML_TABLE_ID, COLOR_COLUMN_NAME, NAMED_COLUMNS_TO_COLOR);
             
            // Change cell text color value based on Color column value
            adjustColumnColorsByAnotherColumn(HTML_TABLE_ID, COLOR_COLUMN_NAME, NAMED_COLUMNS_TO_COLOR);
             
            // Add ToolTips to Detail column
            addTooltipFromSourceColumnToTargetColumn(HTML_TABLE_ID, 'DetailToolTip', 'Detail')
 
            // Add right-click "Copy Tooltip" functionality
            addRightClickCopyTooltip(HTML_TABLE_ID, 'DetailToolTip');
             
        };
        </script>
'@


        $AppDownloadPreContent = @"
        <p id="noResultsMessage" style="display: none;">No results found.</p>
        <h2 id=`"AppDownloadStatistics`">App Download Statistics</h2>
"@


        # Application Download Summary
        $ApplicationDownloadStatisticsHTML = $ApplicationDownloadStatistics | ConvertTo-Html -Fragment -PreContent $AppDownloadPreContent | Out-String

        # Add TableId
        $TableId = 'ApplicationDownloadStatistics'
        $ApplicationDownloadStatisticsHTML = $ApplicationDownloadStatisticsHTML.Replace('<table>',"<table id=`"$TableId`">")


        $Title = "Get-IntuneManagementExtensionDiagnostics Observed Timeline Report"
        ConvertTo-HTML -head $head -PostContent $observedTimelineHTML, $ApplicationDownloadStatisticsHTML, $JavascriptPostContent -PreContent $PreContent -Title $Title | Out-File "$ReportSavePath\$HTMLFileName"
        $Success = $?

        if (-not ($Success)) {
            Write-Error "Error creating HTML file."
            Write-Host "Script will exit..."
            Pause
            Exit 1
        }
        else {
            Write-Host "Get-IntuneManagementExtensionDiagnostics report HTML file created:`n`t$ReportSavePath\$HTMLFileName`n" -ForegroundColor Green
        }
            
        ############################################################
        # Open HTML file

        # Check file exists and is bigger than 0
        # File should exist already but years ago slow computer/disk caused some problems
        # so this is hopefully not needed workaround
        # Wait max. of 20 seconds

        if(-not $DoNotOpenReportAutomatically) {
            $i = 0
            $filesize = 0
            do {
                Write-Host "Double check HTML file creation is really done (round $i)"
                $filesize = 0
                Start-Sleep -Seconds 2
                try {
                    $HTMLFile = Get-ChildItem "$ReportSavePath\$HTMLFileName"
                    $filesize = $HTMLFile.Length
                }
                catch {
                    # Something went wrong, waiting for next round.
                    Write-Host "Trouble getting file size, waiting 2 seconds and trying again..."
                }
                if ($filesize -eq 0) { Write-Host "Filesize is 0kB so waiting for a while for file creation to finish" }

                $i += 1
            } while (($i -lt 10) -and ($filesize -eq 0))

            Write-Host "Opening created file:`n$ReportSavePath\$HTMLFileName`n"
            try {
                Invoke-Item "$ReportSavePath\$HTMLFileName"
            }
            catch {
                Write-Host "Error opening file automatically to browser. Open file manually:`n$ReportSavePath\$HTMLFileName`n" -ForegroundColor Red
            }
        } else {
            Write-Host "`nNote! Parameter -DoNotOpenReportAutomatically specified. Report was not opened automatically to web browser`n" -ForegroundColor Yellow
        }
        
        } # Try creation HTML report end
          catch {
            Write-Error "$($_.Exception.GetType().FullName)"
            Write-Error "$($_.Exception.Message)"
            Write-Error "Error creating HTML report: $ReportSavePath\$HTMLFileName"
        }
            
            
    } # If $ExportHTML end


    if($ShowLogViewerUI -or $LogViewerUI) {
        Write-Host "Show logs in Out-GridView"
        
        # Show log in Out-GridView
        if($SelectedLogFiles -is [Array]) {
            # Sort log entries and add index number
            # This is needed with multiple files so log entries
            # can be sorted based on this index column

            #$SelectedLines = $LogEntryList | Select-Object -Property Index, FileName, Line, DateTime, Multiline, ProcessRunTime, Message, Component, Context, Type, Thread, File | Sort-Object -Property Index | Out-GridView -Title "Intune IME Log Viewer $($SelectedLogFiles.Name -join " ")" -OutputMode Multiple
            
            $SelectedLines = $LogEntryList | Select-Object -Property Index, DateTime, Multiline, ProcessRunTime, Message, Component, Context, Type, Thread, File, FileName, Line | Sort-Object -Property Index | Out-GridView -Title "Intune IME Log Viewer $($SelectedLogFiles.Name -join " ")" -OutputMode Multiple
        } else {
            
            #$SelectedLines = $LogEntryList | Select-Object -Property Index, FileName, Line, DateTime, Multiline, ProcessRunTime, Message, Component, Context, Type, Thread, File | Out-GridView -Title "Intune IME Log Viewer $LogFilePath" -OutputMode Multiple
            
            $SelectedLines = $LogEntryList | Select-Object -Property Index, DateTime, Multiline, ProcessRunTime, Message, Component, Context, Type, Thread, File, FileName, Line | Out-GridView -Title "Intune IME Log Viewer $LogFilePath" -OutputMode Multiple
        }
    } else {

        Write-Host "`nTip: Use Parameter -ShowLogViewerUI to get LogViewerUI for graphical log viewing/debugging`n" -ForegroundColor Cyan
        
        # Not showing Out-GridView so we need to make sure Do-While loop will exit
        $SelectedLines = $null
        Break
    }
    
} While ($SelectedLines.Message -eq 'RELOAD LOG FILE(S). Select this line and OK button from right botton corner')

#Write-Host "Script end"