<#
.SYNOPSIS
Set of commonly used custom functions and constants for inclusion.

.DESCRIPTION
Set of commonly used custom functions and constants for inclusion.

.EXAMPLE
.($PSScriptRoot+"\AOFLibrary.ps1")

.NOTES
Authors: 
    Markos Paraskevopulos
    Richard Winter
Changelog:
    - 20220406: (MPA) initial version
    - 2024- : taken over and heavily revamped for AOFG
#>

#<VMC-Start-AOFLibrary>
#Logging function (output to file or pipeline)
Function Write-Log {
    Param(
        [Parameter(Mandatory = $False)]
        [ValidateSet("INFO", "WARN", "ERROR", "FATAL", "DEBUG")]
        [String]$Level = "INFO",
        [Parameter(Mandatory = $True)]
        [string]$Message,
        [Parameter(Mandatory = $False)]
        [string]$Log,
        [Parameter(Mandatory = $False)][ValidateSet('File', 'Console', 'Both')]
        [string]$LogType = 'Both'
    )

    $Stamp = (Get-Date).toString("dd-MM-yyyy HH:mm:ss")
    $Line = "$Stamp $Level $Message"
    switch ($LogType) {
        'File' {
            if ($Log) {
                $Line | Out-File -FilePath $Log -Encoding Ascii -Append
            }
            else {
                Write-Host 'Unable to save output to log file, no log file specified'
            }
        }
        'Console' {
            switch ($Level) {
                'INFO' { Write-Host $Line -ForegroundColor Green }
                'WARN' { Write-Host $Line -ForegroundColor Yellow }
                'ERROR' { Write-Host $Line -ForegroundColor Red }
                'FATAL' { Write-Host $Line -ForegroundColor Red }
                'DEBUG' { Write-Host $Line -ForegroundColor Cyan }
            }
        }
        'Both' {
            if ($Log) {
                $Line | Out-File -FilePath $Log -Encoding Ascii -Append
            }
            else {
                Write-Host 'Unable to save output to log file, no log file specified'
            }
            switch ($Level) {
                'INFO' { Write-Host $Line -ForegroundColor Green }
                'WARN' { Write-Host $Line -ForegroundColor Yellow }
                'ERROR' { Write-Host $Line -ForegroundColor Red }
                'FATAL' { Write-Host $Line -ForegroundColor Red }
                'DEBUG' { Write-Host $Line -ForegroundColor Cyan }
            }
        }
    }
}

# Creates a log folder and file if they don't exist, starts the transcript and returns the path to the log file
function BeginScriptLogging(
    [Parameter(Mandatory = $True)]
    [string]$ScriptName,
    [Parameter(Mandatory = $False)]
    [string]$LogDir = "C:\AOFG\Logs"
    )
{
    $LogFile = $LogDir + '\' + $vmName + "_" + $ScriptName + '_' + (Get-FormattedDate) + ".log"
    $LogTranscriptFile = $LogDir + '\' + $vmName + "_" + $ScriptName + "_" + (Get-FormattedDate) + '_transcript' + ".log"
    if (-not (Test-Path -LiteralPath $LogDir)) {
        New-Item -Path $LogDir -ItemType Directory -ErrorAction Stop
    }
    Start-Transcript -Path $LogTranscriptFile | Out-Null
    Write-Log -Message "Script started" -Log $LogFile -Level INFO
    return $LogFile
}

# Writes the message specified to the log file specified, stops transcript and throws an exception
function HandleScriptExecutionError(
    [Parameter(Mandatory = $False)]
    [ValidateSet("INFO", "WARN", "ERROR", "FATAL", "DEBUG")]
    [String]$Level = "ERROR",
    [Parameter(Mandatory = $True)]
    [string]$Message,
    [Parameter(Mandatory = $False)]
    [string]$Log
    )
{
    Write-Log -Message $Message -Level $Level -Log $Log
    Stop-Transcript
    throw $Message
}

# Writes successful output to logfile, ends the transcript and exits with code 0
function Write-ScriptSuccess(
    [Parameter(Mandatory = $False)]
    [string]$Log
    )
{
    Write-Log -Message "Script finished successfully" -Level INFO -Log $LogFile
    Stop-Transcript
    exit 0
}

# Connects to a VM and changes the password of the Administrator account
function ChangeAdminPassword(
    [Parameter(Mandatory = $true)]
    [string]$VMName,
    [Parameter(Mandatory = $true)]
    [string]$CurrentPassword,
    [Parameter(Mandatory = $true)]
    [string]$NewPassword,
    [Parameter(Mandatory = $false)]
    [string]$LogFile
    )
{
    $AdministratorUserName = "Administrator"
    $AdministratorPwd = ConvertTo-SecureString -String $CurrentPassword -AsPlainText -Force
    $AdministratorCred = New-Object -TypeName System.Management.Automation.PSCredential -ArgumentList $AdministratorUserName, $AdministratorPwd

    #change local administrator password to randomly generated one when SetVMPassword parameter is specified
    Write-Log -Message "[$VMName]: Changing local administrators' passwords" -Log $LogFile
    Invoke-Command -VMName "$VMName" -Credential $AdministratorCred -ErrorVariable connVM3 -ScriptBlock {
        try {
            Set-LocalUser -Name $using:AdministratorUserName -Password (ConvertTo-SecureString -String $using:NewPassword -AsPlainText -Force)
        }
        catch {
            throw "Password change FAILED: $($_.Exception.Message)"
        }

        # In addition to changing the password of Administrator, autologon password must be changed also
        function Set-RegistryValue {
            param (
                [string]$Path,
                [string]$Name,
                [string]$Value
            )

            if (-not (Test-Path $Path)) {
                New-Item -Path $Path -Force | Out-Null
            }

            if (-not (Get-ItemProperty $Path -Name $Name -ErrorAction SilentlyContinue)) {
                New-ItemProperty -Path $Path -Name $Name -Value $Value -PropertyType String -Force | Out-Null
            } else {
                Set-ItemProperty -Path $Path -Name $Name -Value $Value
            }
        }

        $regPath = "HKLM:\SOFTWARE\Microsoft\Windows NT\CurrentVersion\Winlogon"
        Set-RegistryValue -Path $regPath -Name "DefaultUsername" -Value "Administrator"
        Set-RegistryValue -Path $regPath -Name "DefaultPassword" -Value $using:NewPassword
        Set-RegistryValue -Path $regPath -Name "AutoAdminLogon" -Value "1"
    }

    #check if it was possible to connect to the VM
    if ($connVM3) {
        HandleScriptExecutionError -Message "Failed to connect to '$VMName' or to change the password`r`nError: $connVM3" -Level ERROR -Log $LogFile
    }
    else {
        Write-Log -Message "[$VMName]: Passwords changed successfully" -Level INFO -Log $LogFile
    }
}

$dateFormat = "yyyy-MM-dd-HH-mm-ss"
# Returns the current date based on a predefined format
function Get-FormattedDate() {
    return (Get-Date -Format $dateFormat)
}

# Fetches friendly device name from Device Hunt
function Get-DeviceFriendlyName([string]$vendorId, [string]$deviceId) {
    $url = "https://devicehunt.com/view/type/pci/vendor/$vendorId/device/$deviceId"
    $webContent = Invoke-WebRequest -Uri $url -UseBasicParsing

    $pattern = "<h3 class='details__heading'>([^<]+)<\/h3>"
    $result = [regex]::Matches($webContent.Content, $pattern)
    if($result.Count -ne 2) {
        return "parse error"
    }
    $deviceName = $result.Groups[1].Value

    return $deviceName.Trim()
}

# Checks if a GPU is already assigned to any VM on the current system
function IsGpuAlreadyAssigned([string]$GpuLocationPath) {
    $VMs = Get-VM

    # Loop through each VM and get assigned devices
    foreach ($vm in $VMs) {
    # Get the assigned devices for the current VM
        $assignedDevices = Get-VMAssignableDevice -vmName $vm.Name
        if ($assignedDevices) {
            foreach ($device in $assignedDevices) {
                if($device.LocationPath -eq $GpuLocationPath)
                {
                    return $true
                }
            }
        }
    }
    return $false
}  

# Checks if the instance ID satisfies a pattern for a valid Instace PCI ID
function Is-PCIInstanceId([string]$instanceId) {
    return $instanceId -match "PCI\\VEN_[0-9A-F]{4}&DEV_[0-9A-F]{4}"
}

# Checks if the vendor is in the list of currently supported GPU vendors
function Is-SupportedVendor([string]$vendorId) {
    $supportedVendors = @("10DE") # 10DE: Nvidia, 1002: AMD, 8086: Intel
    return $supportedVendors -contains $vendorId
}

# Extracts vendor ID from instanceId
function Get-VendorId([string]$instanceId) {
    if ($instanceId -match "VEN_([0-9A-F]{4})") {
        return $matches[1]
    }
    return $null
}

# Extracts device ID from instanceId
function Get-DeviceId([string]$instanceId) {
    if ($instanceId -match "DEV_([0-9A-F]{4})") {
        return $matches[1]
    }
    return $null
}

# Gets a device's LocationPath from the device's InstanceID 
function Get-LocationPath([string]$instanceId) {
    $output = Get-PnpDeviceProperty -KeyName DEVPKEY_Device_LocationPaths -InstanceId $instanceId | Select-Object -ExpandProperty Data
    return $output[0]
}

# gets all valid GPUs in the system, dismounted or not, filtered by currently supported
# graphics drivers are not required
# instanceId of each device contains everything we need to know
# https://learn.microsoft.com/en-us/windows-hardware/drivers/install/identifiers-for-pci-devices
# https://devicehunt.com/search/type/pci/vendor/10DE/device/any (GPUs of the vendor NVIDIA)
<#
EXAMPLES:
    Nvidia 3060 Ti:
        instance id:  PCI\VEN_10DE&DEV_2489&SUBSYS_C9721462&REV_A1\4&15A5C264&0&000B
        instance id:  PCI\VEN_10DE&DEV_2489&SUBSYS_C9721462&REV_A1\6&9197FF2&0&00000011
        instance id:  PCI\VEN_10DE&DEV_2489&SUBSYS_C9721462&REV_A1\4&1BABDF5B&0&0009
    Nvidia 3060 Laptop:
        instance id:  PCI\VEN_10DE&DEV_2560&SUBSYS_3A8117AA&REV_A1\4&1D71CFEE&0&0009
    Nvidia 2060 Laptop:  
        instance id:  PCI\VEN_10DE&DEV_1F15&SUBSYS_3A4717AA&REV_A1\4\u00262DE7D7FD&0&0009
    AMD 7950X iGPU:
        instance id:  PCI\VEN_1002&DEV_164E&SUBSYS_88771043&REV_C1\4&1EBE6A9C&0&0041
    AMD 6800U iGPU:
        instance id:  PCI\VEN_1002&DEV_1681&SUBSYS_01241002&REV_C1\4&31F4EC98&0&0041
#>
function Get-GPUs()
{
    $devices =  [array](
        Get-PnpDevice -Class Display -PresentOnly | 
            Select-Object -Property InstanceId, @{Name='Dismounted'; Expression={ $false }}
    )

    # Dismounted devices' InstanceId starts with PCIP and not PCI, so remove the extra P
    $dismountedDevices = (
        Get-VMHostAssignableDevice | Select-Object -Property InstanceID | 
        ForEach-Object { 
            return [PSCustomObject]@{ 
                InstanceId = $_.InstanceId.Substring(0, 3) + $_.InstanceId.Substring(4) 
                Dismounted = $true
            } 
        }
    )
    $allDevices = Get-PnpDevice -Class Display | Select-Object -Property InstanceId

    # Check if the InstanceId from Get-VMHostAssignableDevice is a real InstanceId
    foreach($device in $dismountedDevices)
    {
        if ($allDevices | Where-Object { $_.InstanceId -eq $device.InstanceId }) {
            $devices += $device
        }
    }

    $gpus = @()

    foreach ($device in $devices) {
        $instanceId = $device.InstanceId
        if (Is-PCIInstanceId -instanceId $instanceId) {
            $vendorId = Get-VendorId -instanceId $instanceId
            $deviceId = Get-DeviceId -instanceId $instanceId

            if ($vendorId -and $deviceId -and (Is-SupportedVendor -vendorId $vendorId)) {
                $gpus += @{
                    "InstanceId" = $instanceId
                    "LocationPath" = Get-LocationPath -instanceId $instanceId
                }
            }
        }
    }

    return $gpus
}

# Matches a device's instanceId to its locationPath
# InstanceId looks like this: "PCI\\VEN_10DE\u0026DEV_2489\u0026SUBSYS_C9721462\u0026REV_A1\\6\u00269197FF2\u00260\u002600000011"
# but hyper-v reports instanceId when GPU is mounted as: "PCIP\\VEN_10DE\u0026DEV_2489\u0026SUBSYS_C9721462\u0026REV_A1\\6\u00269197FF2\u00260\u002600000011"
function Get-GpuInstanceId {
    param (
        [Parameter(Mandatory=$true)]
        [string]$GpuLocationPath
    )

    $gpu = Get-GPUs | 
        Where-Object -FilterScript { $_.LocationPath -eq $GpuLocationPath }
    return $gpu.InstanceId
}

# Checks if there's a GPU on the system with the specified instanceId and locationPath
function GpuExists([string]$GpuInstanceId, [string]$GpuLocationPath)
{
    $gpu = Get-GPUs | 
        Where-Object -FilterScript { $_.LocationPath -eq $GpuLocationPath -and $_.InstanceId -eq $gpuInstanceId}
    if($gpu) { return $true }

    return $false
}

# Connects to a VM and checks whether there are any problems reported with the first Nvidia GPU
function EnsureGpuRunningWithoutErrors([string]$vmName, [string]$adminPassword, [string]$LogFile)
{
    $AdministratorUserName = "Administrator"
    $AdministratorPwd = ConvertTo-SecureString -String $adminPassword -AsPlainText -Force
    $AdministratorCred = New-Object -TypeName System.Management.Automation.PSCredential -ArgumentList $AdministratorUserName, $AdministratorPwd

    try {
        $result = Invoke-Command -VMName $vmName -Credential $AdministratorCred -ErrorAction Stop -ScriptBlock {
            # Ensure GPU is set to restart if it fails on VM restart by user
            Move-Item -Path "C:\AOFG\RestartGPU.ps1" -Destination "C:\AOFG\Autorun\RestartGPU.ps1" -Force -ErrorAction SilentlyContinue

            # Check GPU status, restart if needed, report the result
            $gpu = Get-PnpDevice -Class Display | Where-Object -Property InstanceId -like "PCI*" | Select-Object -Property Status, Problem, InstanceId
            if ($gpu -and $gpu.Status -ne "OK")
            {
                $pnputiloutput = pnputil /restart-device $gpu.InstanceId
                Start-Sleep -Seconds 3 # Arbitrary delay for restart
                $gpu = Get-PnpDevice -Class Display | Where-Object -Property InstanceId -like "PCI*" | Select-Object -Property Status, Problem
                return $gpu.Problem.ToString() + ": " + $pnputiloutput
            }
            else
            {
                return $gpu.Problem.ToString()
            }
        }
    }
    catch {
        HandleScriptExecutionError -Message "Failed to connect to '$VMName'`n`n$($PSItem.Exception.message)" -Level ERROR -Log $LogFile
    }

    if($result -inotmatch "CM_PROB_NONE")
    {
        HandleScriptExecutionError -Message "GPU Error: $result" -Level ERROR -Log $LogFile
    }
}

function TestVMParsecConnection([string]$vmName, [string]$adminPassword, [string]$LogFile)
{
    $AdministratorUserName = "Administrator"
    $AdministratorPwd = ConvertTo-SecureString -String $adminPassword -AsPlainText -Force
    $AdministratorCred = New-Object -TypeName System.Management.Automation.PSCredential -ArgumentList $AdministratorUserName, $AdministratorPwd

    try {
        $result = Invoke-Command -VMName $vmName -Credential $AdministratorCred -ErrorAction Stop -ScriptBlock {
            if (-not (Test-NetConnection "parsec.app"))
            {
                return "CONNECTION ERROR"
            }
            else
            {
                return "OK"
            }
        }
    }
    catch {
        HandleScriptExecutionError -Message "Failed to connect to '$VMName'`n`n$($PSItem.Exception.message)" -Level ERROR -Log $LogFile
    }

    if ($result -ne "OK")
    {
        HandleScriptExecutionError -Message "VM cannot reach Parsec" -Level ERROR -Log $LogFile
    }
}

# Connects to a VM and attempts to install the pre-downloaded NVIDIA driver
function InstallSuppliedNvidiaDriver([string]$vmName, [string]$adminPassword, [string]$LogFile)
{
    $AdministratorUserName = "Administrator"
    $AdministratorPwd = ConvertTo-SecureString -String $adminPassword -AsPlainText -Force
    $AdministratorCred = New-Object -TypeName System.Management.Automation.PSCredential -ArgumentList $AdministratorUserName, $AdministratorPwd

    try {
        $result = Invoke-Command -VMName $vmName -Credential $AdministratorCred -ErrorAction Stop -ScriptBlock {
            $process = Start-Process -FilePath "C:\AOFG\NVIDIA\setup.exe" -ArgumentList "-passive -noreboot -noeula -nofinish -s" -Wait -PassThru
            if($process.ExitCode -eq 0)
            {
                "OK"
            }
        }
    }
    catch {
        HandleScriptExecutionError -Message "Failed to connect to '$VMName'`n`n$($PSItem.Exception.message)" -Level ERROR -Log $LogFile
    }

    if($result -ne "OK")
    {
        HandleScriptExecutionError -Message "NVIDIA Driver installer failed" -Level ERROR -Log $LogFile
    }
}

# Waits until a VM is considered 'ready'
function Wait-ForVMReady {
    param (
        [Parameter(Mandatory = $true)]
        [string]$VMName,

        [Parameter(Mandatory = $false)]
        [int]$MaxRetries = 5,

        [Parameter(Mandatory = $false)]
        [int]$SleepSeconds = 30
    )

    $retryCount = 0

    while ($retryCount -lt $MaxRetries) {
        $heartbeatStatus = (Get-VMIntegrationService -VMName $VMName | Where-Object Name -eq "Heartbeat").PrimaryStatusDescription

        if ($heartbeatStatus -eq "OK") {
            Write-Host "VM '$VMName' is fully started and responsive."
            return $true
        }

        Write-Host "Waiting for VM '$VMName' to be ready... (Attempt $($retryCount+1)/$MaxRetries)"
        Start-Sleep -Seconds $SleepSeconds
        $retryCount++
    }

    HandleScriptExecutionError -Message "VM '$VMName' did not become ready after $MaxRetries attempts." -Level ERROR -Log $LogFile
}
