ict-autopilot.ps1


<#PSScriptInfo
 
.VERSION 4.1.3
 
.GUID 1522f19a-667a-4638-8def-7d5590a61094
 
.AUTHOR a.twist@imperial.ac.uk
 
.COMPANYNAME Imperial College London
 
.COPYRIGHT
 
.TAGS
 
.LICENSEURI
 
.PROJECTURI
 
.ICONURI
 
.EXTERNALMODULEDEPENDENCIES
 
.REQUIREDSCRIPTS
 
.EXTERNALSCRIPTDEPENDENCIES
 
.RELEASENOTES
 
 
#>


<#
 
.DESCRIPTION
Enrols an ICT laptop into Autopilot, creates a CSV file for the hardware hash with various other tools
 
#>
 
#####
## Imperial College London
## -----------------------
## Enrolement script
##
#####
###

# -- Bootstrap: ensure we are running under PowerShell 7+ --
if ($PSVersionTable.PSVersion.Major -lt 7) {
    $pwsh = "$env:ProgramFiles\PowerShell\7\pwsh.exe"
    if (-not (Test-Path $pwsh)) {
        Write-Host "PowerShell 7 not found - installing..." -ForegroundColor Cyan
        $msiPath = Join-Path $env:TEMP "PowerShell-7-win-x64.msi"
        $msiUrl  = "https://github.com/PowerShell/PowerShell/releases/download/v7.4.7/PowerShell-7.4.7-win-x64.msi"
        [Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12
        Invoke-WebRequest -Uri $msiUrl -OutFile $msiPath -UseBasicParsing
        $msiArgs = '/i', $msiPath, '/quiet', '/norestart',
                   'ADD_EXPLORER_CONTEXT_MENU_OPENPOWERSHELL=1',
                   'ADD_FILE_CONTEXT_MENU_RUNPOWERSHELL=1',
                   'ENABLE_PSREMOTING=0', 'REGISTER_MANIFEST=1', 'USE_MU=0', 'ENABLE_MU=0'
        Start-Process -FilePath 'msiexec.exe' -ArgumentList $msiArgs -Wait
        Remove-Item $msiPath -Force -ErrorAction SilentlyContinue
        if (-not (Test-Path $pwsh)) {
            Write-Host "ERROR: PowerShell 7 installation failed." -ForegroundColor Red
            $null = $Host.UI.RawUI.ReadKey("NoEcho,IncludeKeyDown")
            exit 1
        }
    }
    Start-Process -FilePath $pwsh -ArgumentList '-NoProfile','-ExecutionPolicy','Bypass','-File',"`"$PSCommandPath`"" -Wait
    exit $LASTEXITCODE
}

# -- WPF assemblies --
Add-Type -AssemblyName PresentationFramework
Add-Type -AssemblyName PresentationCore
Add-Type -AssemblyName WindowsBase
Add-Type -AssemblyName System.Xaml

# ============================================================================
# CONFIGURATION
# ============================================================================
$script:AppVersion = '4.1.3'
$script:AppTitle   = 'ICT Autopilot Toolkit'
$script:TempPath   = Join-Path $env:TEMP 'ICT-Autopilot'
$script:LogPath    = Join-Path $script:TempPath 'logs'

$script:Graph = @{
    TenantId = '2b897507-ee8c-4575-830b-4f8267c3d307'
    ClientId = '5fa15279-2ada-4a13-b288-76929759e805'
    # SECURITY: Hard-coded Entra app secret (OWASP A02:2021). Rotate and move
    # to Windows Credential Manager / DPAPI as soon as possible.
    AppSecret = 'cfw8Q~PVAvzVxXV2DrUswXZtYg3KHQD2~Dca4bWm'
}

$script:GroupTags = @{
    Production = 'PRODUCTION'
    Custom     = 'CUSTOM'
    Lapsafe    = 'LAPSAFE-DEVICE'
    SurfaceHub = 'MTR-ICT'
}

# ============================================================================
# WPF COLOUR / BRUSH HELPERS
# ============================================================================
function New-Brush {
    param([byte]$R,[byte]$G,[byte]$B)
    [System.Windows.Media.SolidColorBrush]::new(
        [System.Windows.Media.Color]::FromRgb($R,$G,$B))
}

$script:Palette = @{
    Bg        = New-Brush 243 243 243
    Sidebar   = New-Brush 255 255 255
    Canvas    = New-Brush 243 243 243
    Card      = New-Brush 255 255 255
    CardHover = New-Brush 249 249 249
    Accent    = New-Brush   0  95 184
    TextPri   = New-Brush  26  26  26
    TextSec   = New-Brush  93  93  93
    TextDim   = New-Brush 138 138 138
    Output    = New-Brush 255 255 255
    Divider   = New-Brush 229 229 229
    Ok        = New-Brush  15 123  15
    Warn      = New-Brush 157 109   0
    Bad       = New-Brush 196  43  28
    CatDeploy = New-Brush   0  95 184
    CatDiag   = New-Brush  13 148 136
    CatSystem = New-Brush 234  88  12
}

# ============================================================================
# UTILITIES
# ============================================================================
function Initialize-WorkingPaths {
    foreach ($p in @($script:TempPath, $script:LogPath)) {
        if (-not (Test-Path $p)) { [void](New-Item -Path $p -ItemType Directory -Force) }
    }
    $script:LogFile = Join-Path $script:LogPath ("autopilot-{0}.log" -f (Get-Date -Format 'yyyyMMdd'))
}

function Get-TempFilePath {
    param([Parameter(Mandatory)][string]$FileName)
    Join-Path $script:TempPath $FileName
}

function Test-IsAdministrator {
    ([Security.Principal.WindowsPrincipal][Security.Principal.WindowsIdentity]::GetCurrent()).IsInRole(
        [Security.Principal.WindowsBuiltInRole]::Administrator)
}

function Test-InternetConnectivity {
    try { (Test-NetConnection -ComputerName 'graph.microsoft.com' -Port 443 -WarningAction SilentlyContinue -InformationLevel Quiet) }
    catch { $false }
}

function Write-Log {
    param([string]$Message,[ValidateSet('INFO','WARN','FAIL','PASS','DEBUG')][string]$Level='INFO')
    if (-not $script:LogFile) { return }
    $line = "{0} [{1}] {2}" -f (Get-Date -Format 'yyyy-MM-dd HH:mm:ss'), $Level, $Message
    try { Add-Content -Path $script:LogFile -Value $line -ErrorAction SilentlyContinue } catch {}
}

# Bootstrap NuGet + PSGallery trust + scripts PATH once per session.
# Call before Install-Module or Install-Script to prevent interactive prompts
# that deadlock in a WPF host (no console) or Constrained Language Mode.
function Initialize-PSGallery {
    if (-not (Get-PackageProvider -Name NuGet -ErrorAction SilentlyContinue)) {
        Install-PackageProvider -Name NuGet -Force -Scope CurrentUser | Out-Null
    }
    if ((Get-PSRepository -Name PSGallery -ErrorAction SilentlyContinue).InstallationPolicy -ne 'Trusted') {
        Set-PSRepository -Name PSGallery -InstallationPolicy Trusted
    }
    # Ensure the scripts directory exists and is on PATH so Install-Script
    # never prompts "Add to PATH?" (that prompt hangs in a WPF host).
    $sd = Join-Path ([Environment]::GetFolderPath('MyDocuments')) 'PowerShell\Scripts'
    if (-not (Test-Path $sd)) { New-Item -Path $sd -ItemType Directory -Force | Out-Null }
    if ($env:PATH -notlike "*$sd*") { $env:PATH += ";$sd" }
}

function Ensure-Module {
    param([Parameter(Mandatory)][string]$Name,[switch]$AllowClobber)
    if (-not (Get-Module -ListAvailable -Name $Name)) {
        Initialize-PSGallery
        $p = @{ Name=$Name; Force=$true; Scope='CurrentUser'; Confirm=$false; ErrorAction='Stop'; AcceptLicense=$true }
        if ($AllowClobber) { $p.AllowClobber = $true }
        Install-Module @p
    }
    Import-Module $Name -Force -Global -ErrorAction Stop | Out-Null
}

function Connect-IntuneGraph {
    Ensure-Module -Name Microsoft.Graph.Authentication
    Ensure-Module -Name Microsoft.Graph.Intune
    Ensure-Module -Name Microsoft.Graph.Identity.DirectoryManagement
    Ensure-Module -Name WindowsAutopilotIntune -AllowClobber
    Connect-MgGraph -ClientId $script:Graph.ClientId -TenantId $script:Graph.TenantId
}

# ============================================================================
# OUTPUT HELPERS (WPF RichTextBox / FlowDocument)
# ============================================================================
function Invoke-OnUI {
    param([scriptblock]$Action)
    if ($script:Window -and $script:Window.Dispatcher) {
        $script:Window.Dispatcher.Invoke([Action]$Action)
    } else { & $Action }
}

# Pump the dispatcher so long-running synchronous loops still repaint.
function Update-UI {
    if ($script:Window -and $script:Window.Dispatcher) {
        # Invoke an empty action at Background priority — forces all higher-priority
        # queued work (render, data-bind, input) to complete first.
        $script:Window.Dispatcher.Invoke(
            [Action]{},
            [System.Windows.Threading.DispatcherPriority]::Background)
    }
}

function Update-OutputColored {
    param(
        [string]$Message,
        [System.Windows.Media.Brush]$Color = $script:Palette.TextPri,
        [switch]$Clear,
        [switch]$NoNewline
    )
    Write-Log -Message $Message
    if (-not $script:OutputBox) { Write-Host $Message; return }

    Invoke-OnUI {
        if ($Clear) {
            $script:OutputBox.Document.Blocks.Clear()
            $script:OutputPara = New-Object System.Windows.Documents.Paragraph
            $script:OutputPara.Margin = [System.Windows.Thickness]::new(0)
            $script:OutputBox.Document.Blocks.Add($script:OutputPara)
        }
        if (-not $script:OutputPara) {
            $script:OutputPara = New-Object System.Windows.Documents.Paragraph
            $script:OutputPara.Margin = [System.Windows.Thickness]::new(0)
            $script:OutputBox.Document.Blocks.Add($script:OutputPara)
        }
        # Add leading newline if box already has content and caller didn't ask NoNewline
        if (-not $NoNewline -and $script:OutputPara.Inlines.Count -gt 0) {
            $script:OutputPara.Inlines.Add((New-Object System.Windows.Documents.LineBreak))
        }
        $run = New-Object System.Windows.Documents.Run($Message)
        $run.Foreground = $Color
        $script:OutputPara.Inlines.Add($run)
        $script:OutputBox.ScrollToEnd()
    }
    Update-UI
}

function Out-Pass    { param([string]$m) Update-OutputColored -Message " [PASS] $m" -Color $script:Palette.Ok }
function Out-Fail    { param([string]$m) Update-OutputColored -Message " [FAIL] $m" -Color $script:Palette.Bad }
function Out-Warn    { param([string]$m) Update-OutputColored -Message " [WARN] $m" -Color $script:Palette.Warn }
function Out-Info    { param([string]$m) Update-OutputColored -Message " [INFO] $m" -Color $script:Palette.CatDeploy }
function Out-Dim     { param([string]$m) Update-OutputColored -Message " $m" -Color $script:Palette.TextDim }
function Out-Header  {
    param([string]$m)
    $clean = $m.Trim()
    Update-OutputColored -Message " == $clean ==" -Color $script:Palette.Accent
}
function Out-Section { param([string]$m) Update-OutputColored -Message $m -Color $script:Palette.TextDim }
function Update-Output { param([string]$message,[switch]$Clear) Update-OutputColored -Message $message -Clear:$Clear }

function Clear-Output {
    Invoke-OnUI {
        $script:OutputBox.Document.Blocks.Clear()
        $script:OutputPara = New-Object System.Windows.Documents.Paragraph
        $script:OutputPara.Margin = [System.Windows.Thickness]::new(0)
        $script:OutputBox.Document.Blocks.Add($script:OutputPara)
    }
}

function Get-OutputText {
    $doc = $script:OutputBox.Document
    $range = New-Object System.Windows.Documents.TextRange($doc.ContentStart, $doc.ContentEnd)
    return $range.Text
}

# ============================================================================
# PROGRESS BAR HELPERS
# ============================================================================
function Start-Busy {
    if ($script:ProgressBar) {
        Invoke-OnUI { $script:ProgressBar.Visibility = 'Visible' }
    }
}
function Stop-Busy {
    if ($script:ProgressBar) {
        Invoke-OnUI { $script:ProgressBar.Visibility = 'Collapsed' }
    }
}

# ============================================================================
# MESSAGE BOX HELPERS (WPF)
# ============================================================================
function Show-Confirm {
    param([string]$Text,[string]$Caption='Confirm')
    [System.Windows.MessageBox]::Show(
        $script:Window, $Text, $Caption,
        [System.Windows.MessageBoxButton]::YesNo,
        [System.Windows.MessageBoxImage]::Warning) -eq [System.Windows.MessageBoxResult]::Yes
}
function Show-Info {
    param([string]$Text,[string]$Caption='Information')
    [void][System.Windows.MessageBox]::Show(
        $script:Window, $Text, $Caption,
        [System.Windows.MessageBoxButton]::OK,
        [System.Windows.MessageBoxImage]::Information)
}

# ============================================================================
# ACTION FUNCTIONS (UI-agnostic except for Show-Confirm / Show-Info)
# ============================================================================
function Get-BitLockerRecoveryKey {
    Out-Header "BITLOCKER RECOVERY KEY"
    if (-not (Test-IsAdministrator)) { Out-Fail "Please run as Administrator to retrieve the BitLocker recovery key."; return }
    try {
        Out-Dim "Querying BitLocker volume..."
        $key = Get-BitLockerVolume -MountPoint "C:" |
               Select-Object -ExpandProperty KeyProtector |
               Where-Object { $_.KeyProtectorType -eq 'RecoveryPassword' }
        if ($key) { Out-Pass "BitLocker Recovery Key found for C:"; Out-Info "Key: $($key.RecoveryPassword)" }
        else      { Out-Warn "No BitLocker recovery key found for C: drive." }
    } catch { Out-Fail "Error retrieving BitLocker recovery key: $($_.Exception.Message)" }
}

function Get-AutopilotStatus {
    try {
        Clear-Output
        Out-Header "AUTOPILOT ENROLMENT STATUS - LOCAL DEVICE"
        Start-Busy
        $serial = (Get-CimInstance -ClassName Win32_BIOS -ErrorAction Stop).SerialNumber
        Out-Info "Computer Name : $env:COMPUTERNAME"
        Out-Info "Serial Number : $serial"

        Out-Dim "Loading required modules..."
        Connect-IntuneGraph
        Out-Pass "Connected to Microsoft Graph"

        Out-Dim "Querying Autopilot for serial '$serial'..."
        $apDevice = Get-AutopilotDevice -serial $serial -ErrorAction Stop

        if (-not $apDevice) {
            Out-Section "-----------------------------------------------"
            Out-Fail "NOT ENROLLED - this device is not registered in Autopilot."
            Out-Section "-----------------------------------------------"
            return
        }

        Out-Pass "ENROLLED IN AUTOPILOT"

        Out-Header "IDENTIFIERS"
        $apId         = if ($apDevice.id) { $apDevice.id } else { "N/A" }
        $apManagedId  = if ($apDevice.managedDeviceId -and $apDevice.managedDeviceId -ne '00000000-0000-0000-0000-000000000000') { $apDevice.managedDeviceId } else { "Not managed" }
        $apEntraId    = if ($apDevice.azureAdDeviceId -and $apDevice.azureAdDeviceId -ne '00000000-0000-0000-0000-000000000000') { $apDevice.azureAdDeviceId } else { "Not joined" }

        Out-Info "Autopilot Device ID : $apId"
        if ($apManagedId -eq 'Not managed') { Out-Warn "Managed Device ID : $apManagedId" } else { Out-Info "Managed Device ID : $apManagedId" }
        if ($apEntraId  -eq 'Not joined')   { Out-Warn "Entra (AAD) Device ID : $apEntraId" }   else { Out-Info "Entra (AAD) Device ID : $apEntraId" }

        Out-Header "ENROLMENT DETAILS"
        $groupTag = if ($apDevice.groupTag) { $apDevice.groupTag } else { "(none)" }
        if ($groupTag -eq '(none)') { Out-Warn "Group Tag : $groupTag" } else { Out-Pass "Group Tag : $groupTag" }
        $po = if ($apDevice.purchaseOrderIdentifier) { $apDevice.purchaseOrderIdentifier } else { "(none)" }
        Out-Info "Purchase Order : $po"
        $enrolState = if ($apDevice.enrollmentState) { $apDevice.enrollmentState } else { "Unknown" }
        if ($enrolState -eq 'enrolled') { Out-Pass "Enrolment State : $enrolState" } else { Out-Warn "Enrolment State : $enrolState" }
        $lastContact = if ($apDevice.lastContactedDateTime) { $apDevice.lastContactedDateTime } else { "Never" }
        Out-Info "Last Contacted : $lastContact"

        Out-Header "DEPLOYMENT PROFILE"
        $profileName   = if ($apDevice.deploymentProfileAssignmentStatus)        { $apDevice.deploymentProfileAssignmentStatus }        else { "Not assigned" }
        $profileDate   = if ($apDevice.deploymentProfileAssignedDateTime)        { $apDevice.deploymentProfileAssignedDateTime }        else { "N/A" }
        $profileStatus = if ($apDevice.deploymentProfileAssignmentDetailedStatus){ $apDevice.deploymentProfileAssignmentDetailedStatus }else { "N/A" }
        $displayName   = if ($apDevice.displayName) { $apDevice.displayName } else { $env:COMPUTERNAME }
        Out-Info "Device Display Name : $displayName"
        if     ($profileName -match 'assigned')                                    { Out-Pass "Profile Assignment Status : $profileName" }
        elseif ($profileName -in @('Not assigned','notAssigned'))                  { Out-Fail "Profile Assignment Status : $profileName" }
        else                                                                       { Out-Warn "Profile Assignment Status : $profileName" }
        Out-Info "Profile Assigned Date : $profileDate"
        Out-Info "Profile Detailed Status : $profileStatus"

        Out-Header "HARDWARE"
        Out-Info ("Manufacturer : {0}" -f ($(if ($apDevice.manufacturer) { $apDevice.manufacturer } else { "Unknown" })))
        Out-Info ("Model : {0}" -f ($(if ($apDevice.model)        { $apDevice.model        } else { "Unknown" })))

        Out-Header "SUMMARY"
        $issues = @()
        if ($groupTag -eq '(none)')                                              { $issues += "No Group Tag assigned" }
        if ($profileName -in @('Not assigned','notAssigned'))                    { $issues += "No Deployment Profile assigned" }
        if ($apManagedId -eq 'Not managed')                                      { $issues += "Device not yet Intune-managed" }
        if ($apEntraId  -eq 'Not joined')                                        { $issues += "Device not yet Entra-joined" }
        if ($issues.Count -eq 0) { Out-Pass "All looks good - device is enrolled with a profile and group tag." }
        else { Out-Fail "Potential issues found:"; foreach ($i in $issues) { Out-Warn " - $i" } }
        Out-Section "==============================================="
    } catch { Out-Fail "Error querying Autopilot status: $($_.Exception.Message)" }
    finally  { Stop-Busy }
}

function Test-Windows11Compatibility {
    try {
        Out-Header "WINDOWS 11 COMPATIBILITY CHECK"
        Out-Dim  "Some brand new CPUs may incorrectly flag as Not Compatible"
        Start-Busy
        $cpuSources = @{
            'AMD-CPU.txt'   = 'https://ictautopilot.blob.core.windows.net/win11compat/AMD-CPU.txt'
            'INTEL-CPU.txt' = 'https://ictautopilot.blob.core.windows.net/win11compat/Intel-CPU.txt'
            'SNAP-CPU.txt'  = 'https://ictautopilot.blob.core.windows.net/win11compat/snap-CPU.txt'
        }
        foreach ($e in $cpuSources.GetEnumerator()) {
            $tp = Get-TempFilePath -FileName $e.Key
            $needs = -not (Test-Path $tp) -or ((Get-Date) - (Get-Item $tp).LastWriteTime).TotalDays -gt 7
            if ($needs) { Invoke-WebRequest -Uri $e.Value -OutFile $tp -ErrorAction Stop }
        }
        $listIntel = Get-Content (Get-TempFilePath 'INTEL-CPU.txt')
        $listAMD   = Get-Content (Get-TempFilePath 'AMD-CPU.txt')
        $listSNAP  = Get-Content (Get-TempFilePath 'SNAP-CPU.txt')
        $procs = (Get-CimInstance -ClassName Win32_Processor | Select-Object -ExpandProperty Name)
        $cpuOk = $false
        foreach ($p in $procs) {
            $list = if ($p -like '*Intel*') { $listIntel } elseif ($p -like '*AMD*') { $listAMD } else { $listSNAP }
            if ($list | Where-Object { $p -like "*$_*" } | Select-Object -First 1) { $cpuOk = $true; break }
        }

        Out-Header "RESULTS"
        if ($cpuOk) { Out-Pass "CPU: $($procs -join '; ')" } else { Out-Fail "CPU: $($procs -join '; ')" }
        $ramGB = [math]::Round((Get-CimInstance Win32_ComputerSystem).TotalPhysicalMemory / 1GB, 2)
        if ($ramGB -ge 4) { Out-Pass "RAM: $ramGB GB (Min: 4 GB)" } else { Out-Fail "RAM: $ramGB GB (Min: 4 GB)" }
        $disk  = Get-CimInstance Win32_LogicalDisk -Filter "DeviceID='C:'"
        $diskGB = [math]::Round($disk.Size / 1GB, 2)
        if ($diskGB -ge 64) { Out-Pass "Storage: $diskGB GB (Min: 64 GB)" } else { Out-Fail "Storage: $diskGB GB (Min: 64 GB)" }
        $tpmOk = $false
        try {
            $tpm = Get-CimInstance -Namespace "root\CIMV2\Security\MicrosoftTpm" -ClassName Win32_Tpm
            $tpmOk = $tpm.SpecVersion -match "2.0"
            if ($tpmOk) { Out-Pass "TPM: 2.0" } else { Out-Fail "TPM: $($tpm.SpecVersion) (Required: 2.0)" }
        } catch { Out-Fail "TPM: Not detected" }
        try { $sb = Confirm-SecureBootUEFI -ErrorAction Stop } catch { $sb = $false }
        if ($sb) { Out-Pass "Secure Boot: Enabled" } else { Out-Fail "Secure Boot: Disabled" }

        Out-Header "OVERALL"
        if ($cpuOk -and ($ramGB -ge 4) -and ($diskGB -ge 64) -and $tpmOk -and $sb) {
            Out-Pass "Compatible with Windows 11"
        } else { Out-Fail "Not Compatible with Windows 11" }
    } catch { Out-Fail "Error checking Windows 11 compatibility: $_" }
    finally  { Stop-Busy }
}

function Remove-DeviceFromCloud {
    try {
        Out-Header "REMOVE DEVICE FROM CLOUD"
        if (-not (Test-IsAdministrator)) { Out-Fail "Please run as Administrator to remove device from cloud services"; return }
        if (-not (Show-Confirm "Are you sure you want to remove this device from Autopilot, Intune, and Entra ID?`n`nThis action cannot be undone." "Confirm Device Removal")) {
            Out-Warn "Device removal cancelled by user."; return
        }

        Start-Busy
        Out-Dim "Starting device removal process..."
        $serial = (Get-CimInstance Win32_BIOS).SerialNumber
        $name   = $env:COMPUTERNAME

        Out-Dim "Installing required PowerShell modules..."
        Connect-IntuneGraph
        Out-Pass "Connected"

        Out-Header "STEP 1/3: AUTOPILOT"
        try {
            $ap = Get-AutopilotDevice -serial $serial
            if ($ap) { Remove-AutopilotDevice -id $ap.id; Out-Pass "Successfully removed from Autopilot" }
            else { Out-Warn "Device not found in Autopilot" }
        } catch { Out-Fail "Error removing from Autopilot: $_" }

        Out-Header "STEP 2/3: INTUNE"
        try {
            $intune = Get-MgDeviceManagementManagedDevice -Filter "serialNumber eq '$serial'"
            if ($intune) { Remove-MgDeviceManagementManagedDevice -ManagedDeviceId $intune.Id; Out-Pass "Successfully removed from Intune" }
            else { Out-Warn "Device not found in Intune" }
        } catch { Out-Fail "Error removing from Intune: $_" }

        Out-Header "STEP 3/3: ENTRA ID"
        try {
            $aad = Get-MgDevice -Filter "displayName eq '$name'"
            if ($aad) { Remove-MgDevice -DeviceId $aad.Id; Out-Pass "Successfully removed from Entra ID" }
            else { Out-Warn "Device not found in Entra ID" }
        } catch { Out-Fail "Error removing from Entra ID: $_" }

        Out-Header "LOCAL CLEANUP"
        try {
            $ds = Start-Process "dsregcmd.exe" -ArgumentList "/leave" -PassThru -Wait
            if ($ds.ExitCode -eq 0) { Out-Pass "Local device state cleaned"; Out-Info "It will take around 5 minutes to show this deletion in Intune" }
            else                    { Out-Warn "Local device state cleanup may not be complete" }
        } catch { Out-Fail "Error cleaning local device state: $_" }

        Show-Info "Device removal process completed. Please restart your device to complete the process." "Removal Complete"
    } catch { Out-Fail "Error in device removal process: $_" }
    finally  { Stop-Busy }
}

function Install-PowerShell7 {
    try {
        Out-Header "INSTALL POWERSHELL 7"
        Start-Busy
        Out-Dim "Downloading PowerShell 7 installer..."
        $installer = Get-TempFilePath "PowerShell-7.msi"
        Invoke-WebRequest -Uri "https://aka.ms/powershell-release?tag=stable" -OutFile $installer
        Out-Dim "Running installer..."
        Start-Process msiexec.exe -ArgumentList "/i `"$installer`" /qn" -Wait
        Out-Pass "PowerShell 7 installation completed."
    } catch { Out-Fail "Error installing PowerShell 7: $_" }
    finally  { Stop-Busy }
}

function Restart-PCWithCountdown {
    try {
        Out-Header "RESTART PC"
        if (-not (Test-IsAdministrator)) { Out-Fail "Please run as Administrator to restart the PC"; return }
        Out-Warn "System will restart in 5 seconds..."
        for ($i = 5; $i -gt 0; $i--) { Out-Warn "Restarting in $i seconds..."; Start-Sleep -Seconds 1 }
        Restart-Computer -Force
    } catch { Out-Fail "Error initiating restart: $_" }
}

function Test-AutopilotRequirements {
    [CmdletBinding()] param()
    $req = @{ OSVersion=$false; OSEdition=$false; TPM=$false; AllPassed=$false }
    try {
        Out-Header "Checking Windows version..."
        $os = Get-CimInstance Win32_OperatingSystem
        $build = [int]$os.BuildNumber
        if ($build -ge 17134) { Out-Pass "Windows build $build meets minimum (17134)"; $req.OSVersion=$true }
        else { Out-Fail "Windows build $build does not meet minimum (17134)" }

        Out-Header "`nChecking Windows Edition..."
        if ($os.Caption -match "(Enterprise|Professional|Education|Pro)") { Out-Pass "Windows edition ($($os.Caption)) is supported"; $req.OSEdition=$true }
        else { Out-Fail "Windows edition ($($os.Caption)) is not supported" }

        Out-Header "`nChecking TPM 2.0..."
        try {
            $tpm = Get-Tpm
            if ($tpm.TpmPresent -and $tpm.TpmReady -and -not ($tpm.ManufacturerVersionFull20 -match "not supported")) {
                Out-Pass "TPM 2.0 is present and ready"; $req.TPM=$true
            } else { Out-Fail "TPM is not present, not ready, or not 2.0" }
        } catch { Out-Fail "Error checking TPM status: $_" }

        $req.AllPassed = $req.OSVersion -and $req.OSEdition -and $req.TPM
        Out-Section "`nRequirements Summary:"
        if ($req.OSVersion) { Out-Pass "Windows Version: True" } else { Out-Fail "Windows Version: False" }
        if ($req.OSEdition) { Out-Pass "Windows Edition: True" } else { Out-Fail "Windows Edition: False" }
        if ($req.TPM)       { Out-Pass "TPM 2.0: True" }         else { Out-Fail "TPM 2.0: False" }
        if ($req.AllPassed) { Out-Pass "`nAll requirements met. Device is ready for Autopilot enrollment." }
        else { Out-Fail "`nDevice does not meet all requirements for Autopilot enrollment." }
        return $req.AllPassed
    } catch { Out-Fail "Error checking requirements: $_"; return $false }
}

function Reset-Device {
    try {
        if (-not (Test-IsAdministrator)) { Out-Warn "Please run as Administrator to reset the device"; return }
        Out-Info "Resetting device..."
        if (Show-Confirm "Are you sure you want to reset the device?" "Confirm Reset") { systemreset -factoryreset }
        else { Out-Dim "Device reset cancelled." }
    } catch { Out-Fail "Error resetting device: $_" }
}

function Test-Enrolment {
    $status = @{ IsEnrolled=$false; SerialNumber=$null; Error=$null }
    try {
        Out-Info "Checking required modules..."
        Connect-IntuneGraph
        Out-Info "Getting device serial number..."
        $serial = (Get-CimInstance Win32_BIOS -ErrorAction Stop).SerialNumber
        if ([string]::IsNullOrEmpty($serial)) { throw "Unable to retrieve device serial number" }
        $status.SerialNumber = $serial
        Out-Info "Checking Autopilot enrollment status..."
        if (Get-AutopilotDevice -serial $serial -ErrorAction Stop) {
            $status.IsEnrolled = $true
            Out-Pass "Device $serial is already enrolled in Autopilot"
        } else {
            Out-Warn "Device $serial is not enrolled in Autopilot"
        }
        return $status
    } catch {
        $status.Error = "Error in Test-Enrolment: $($_.Exception.Message)"
        Out-Fail $status.Error
        return $status
    }
}

function Invoke-AutopilotEnrolment {
    param(
        [Parameter(Mandatory)][ValidateNotNullOrEmpty()][string]$GroupTag,
        [string]$FriendlyName = 'Device'
    )
    if (-not (Test-AutopilotRequirements)) { Out-Fail "Cannot proceed - requirements not met."; return }
    $st = Test-Enrolment
    if ($st.Error)      { Out-Fail "Error checking enrollment: $($st.Error)"; return }
    if ($st.IsEnrolled) { Out-Warn "Device $($st.SerialNumber) is already enrolled."; return }
    if (-not (Test-IsAdministrator)) { Out-Warn "Please run as Administrator to enroll in Autopilot"; return }
    try {
        Start-Busy
        Out-Info "Installing Autopilot PowerShell module..."
        Update-UI
        Initialize-PSGallery
        Install-Script -Name Get-WindowsAutoPilotInfo -Force -AcceptLicense -Scope CurrentUser -ErrorAction Stop
        Update-UI
        Out-Info "Enrolling $FriendlyName (Group Tag: $GroupTag)..."
        Update-UI
        Get-WindowsAutoPilotInfo `
            -tenantid  $script:Graph.TenantId `
            -appid     $script:Graph.ClientId `
            -appsecret $script:Graph.AppSecret `
            -GroupTag  $GroupTag -online -assign
        Out-Pass "$FriendlyName successfully enrolled in Autopilot. Allow ~15 minutes to register."
    } catch { Out-Fail "Error enrolling device: $_" }
    finally  { Stop-Busy }
}

function New-AutopilotCsv {
    try {
        Out-Info "[DEBUG] Entry"
        if (-not (Test-IsAdministrator)) { Out-Warn "Please run as Administrator to create an Autopilot CSV"; return }
        Out-Info "[DEBUG] Start-Busy"
        Start-Busy
        $serial = (Get-CimInstance Win32_BIOS -ErrorAction Stop).SerialNumber
        $csv = Get-TempFilePath "AutopilotHWID_$serial.csv"
        Out-Info "[DEBUG] About to install script"
        Out-Info "Installing Autopilot PowerShell script (please wait)..."
        Update-UI
        Out-Info "[DEBUG] Initialize-PSGallery"
        Initialize-PSGallery
        Out-Info "[DEBUG] Downloading Get-WindowsAutoPilotInfo.ps1 directly..."
        $scriptName = 'Get-WindowsAutoPilotInfo.ps1'
        $scriptsDir = Join-Path ([Environment]::GetFolderPath('MyDocuments')) 'PowerShell\Scripts'
        $scriptPath = Join-Path $scriptsDir $scriptName
        $galleryUrl = 'https://www.powershellgallery.com/api/v2/package/Get-WindowsAutoPilotInfo'
        $tmpZip = [System.IO.Path]::GetTempFileName() + '.nupkg'
        try {
            # Download the NuPkg (zip) from PSGallery
            Invoke-WebRequest -Uri $galleryUrl -OutFile $tmpZip -UseBasicParsing -ErrorAction Stop
            Out-Info "[DEBUG] Downloaded NuPkg"
            # Extract the script from the NuPkg (search all entries)
            Add-Type -AssemblyName System.IO.Compression.FileSystem
            $zip = [System.IO.Compression.ZipFile]::OpenRead($tmpZip)
            $entry = $zip.Entries | Where-Object { $_.FullName -imatch "$scriptName$" }
            if (-not $entry) {
                $all = $zip.Entries | ForEach-Object { $_.FullName }
                Out-Fail "Could not find $scriptName in NuPkg. Entries: $($all -join ', ')"
                $zip.Dispose()
                Remove-Item $tmpZip -Force
                return
            }
            # CLM-safe extraction: copy stream manually
            $inStream = $entry.Open()
            $outStream = [System.IO.File]::Open($scriptPath, [System.IO.FileMode]::Create)
            $buffer = New-Object byte[] 8192
            while (($read = $inStream.Read($buffer, 0, $buffer.Length)) -gt 0) {
                $outStream.Write($buffer, 0, $read)
            }
            $outStream.Close()
            $inStream.Close()
            $zip.Dispose()
            Remove-Item $tmpZip -Force
            Unblock-File -Path $scriptPath -ErrorAction SilentlyContinue
            Out-Info "[DEBUG] Script downloaded to $scriptPath"
        } catch {
            Out-Fail "Failed to download $($scriptName): $($_)"
            return
        }
        Update-UI
        Out-Info "[DEBUG] Before Get-WindowsAutoPilotInfo"
        Out-Info "Creating Autopilot CSV at $csv ..."
        Update-UI
        . $scriptPath
        Get-WindowsAutoPilotInfo -OutputFile $csv
        Out-Info "[DEBUG] After Get-WindowsAutoPilotInfo"
        Out-Pass "Hardware hash saved to $csv"
        # Add whitespace and accent color for the tip
        Update-OutputColored -Message "`n`nYou can open the folder containing the CSV by clicking the 'Open Temp' button at the top." -Color $script:Palette.Accent
    } catch { Out-Fail "Error creating CSV: $_" }
    finally  { Stop-Busy }
}

function Invoke-WindowsUpdateRun {
    try {
        if (-not (Test-IsAdministrator)) { Out-Warn "Please run as Administrator to perform Windows Update"; return }
        Start-Busy
        Out-Info "Installing required PowerShell modules..."
        Ensure-Module -Name PSWindowsUpdate
        Import-Module PSWindowsUpdate
        Out-Info "Checking for Windows Updates..."
        $updates = @(Get-WindowsUpdate)
        if ($updates.Count -eq 0) { Out-Pass "No Windows Updates are available."; return }
        Out-Warn "Found $($updates.Count) update(s). Starting installation..."
        Get-WindowsUpdate -Install -AcceptAll -AutoReboot | ForEach-Object {
            Out-Info "Installing update: $($_.Title)`nStatus: $($_.Status)"
        }
        Out-Pass "Windows Update completed."
    } catch { Out-Fail "Error running Windows Update: $_" }
    finally  { Stop-Busy }
}

function Get-SystemInfo {
        Out-Info "Collecting system information, please wait..."
    try {
        $ci  = Get-ComputerInfo
        $os  = (Get-CimInstance Win32_OperatingSystem).Caption
        $cpu = Get-CimInstance Win32_Processor
        $ram = [math]::Round((Get-CimInstance Win32_ComputerSystem).TotalPhysicalMemory / 1GB, 2)
        $d   = Get-CimInstance Win32_LogicalDisk -Filter "DeviceID='C:'"
        $free= [math]::Round($d.FreeSpace / 1GB, 2)
        Out-Header "System Information"
        Out-Info "CPU: $($cpu.Name)"
        Out-Info "RAM: $ram GB"
        Out-Info "Free Disk (C:): $free GB"
        Out-Info "OS: $os"
        Out-Info "Model: $($ci.CsModel)"
        Out-Info "BIOS Version: $($ci.BiosSMBIOSBIOSVersion)"
        Out-Info "Serial Number: $($ci.BiosSerialNumber)"
        Out-Info "Locale: $($ci.OsLanguage)"
    } catch { Out-Fail "Error getting system information: $_" }
}

function Invoke-HPImageAssistant {
    try {
        Clear-Output
        Out-Header "HP IMAGE ASSISTANT"
        if (-not (Test-IsAdministrator)) { Out-Fail "Please run as Administrator to use HP Image Assistant."; return }
        Start-Busy

        $reportDir  = "C:\temp\HPIA"
        $installDir = "C:\temp\HPIA\Agent"
        $hpiaExe    = Join-Path $installDir "HPImageAssistant.exe"

        Out-Info "Checking for latest HP Image Assistant version..."
        try {
            $page = Invoke-WebRequest -Uri "https://ftp.hp.com/pub/caps-softpaq/cmit/HPIA.html" -UseBasicParsing -ErrorAction Stop
            $hpiaUrl = ($page.Links | Where-Object { $_.href -match "hp-hpia-.*\.exe$" } | Select-Object -First 1).href
            if (-not $hpiaUrl) { Out-Fail "Could not find HPIA download link."; return }
            $ver = if ($hpiaUrl -match "hp-hpia-([\d.]+)\.exe") { $Matches[1] } else { "unknown" }
            $download = Join-Path "C:\temp" ([System.IO.Path]::GetFileName($hpiaUrl))
            Out-Pass "Latest version: $ver"
        } catch { Out-Fail "Failed to query HP: $($_.Exception.Message)"; return }

        if (-not (Test-Path "C:\temp")) { New-Item -Path "C:\temp" -ItemType Directory -Force | Out-Null }

        Out-Info "Downloading HP Image Assistant $ver..."
        try { Invoke-WebRequest -Uri $hpiaUrl -OutFile $download -UseBasicParsing -ErrorAction Stop; Out-Pass "Downloaded to $download" }
        catch { Out-Fail "Failed to download: $($_.Exception.Message)"; return }

        Out-Info "Installing HP Image Assistant..."
        try {
            $ip = Start-Process -FilePath $download -ArgumentList "/s","/e","/f `"$installDir`"" -Wait -PassThru -ErrorAction Stop
            if ($ip.ExitCode -ne 0) { Out-Warn "Installer exited with code $($ip.ExitCode)" }
            if (Test-Path $hpiaExe) { Out-Pass "Installed to $installDir" }
            else { Out-Fail "HPImageAssistant.exe not found at $hpiaExe"; return }
        } catch { Out-Fail "Installation failed: $($_.Exception.Message)"; return }

        Out-Info "Running HP Image Assistant (silent mode)..."
        Out-Dim  "Report folder: $reportDir"
        try {
            $hp = Start-Process -FilePath $hpiaExe `
                -ArgumentList "/Operation:Analyze","/Category:All","/Selection:All","/Action:Install","/Silent","/ReportFolder:$reportDir" `
                -PassThru -ErrorAction Stop
            $sw = [System.Diagnostics.Stopwatch]::StartNew()
            $lastLogSize = 0; $lastLogFile = $null; $lastElapsed = ''; $lastStatus = ''; $logLineCount = 0
            while (-not $hp.HasExited) {
                Update-UI
                $elapsed = $sw.Elapsed.ToString('mm\:ss')
                if ($elapsed -ne $lastElapsed) {
                    $lastElapsed = $elapsed
                    $cur = Get-ChildItem -Path $reportDir -Filter "*.log" -ErrorAction SilentlyContinue |
                           Sort-Object LastWriteTime -Descending | Select-Object -First 1
                    $newLines = @()
                    if ($cur) {
                        if ($cur.FullName -ne $lastLogFile) { $lastLogFile = $cur.FullName; $lastLogSize = 0 }
                        try {
                            $st = [System.IO.File]::Open($cur.FullName,'Open','Read','ReadWrite')
                            if ($st.Length -gt $lastLogSize) {
                                $st.Seek($lastLogSize,'Begin') | Out-Null
                                $r = New-Object System.IO.StreamReader($st)
                                $newLines = $r.ReadToEnd() -split "`r?`n" | Where-Object { $_.Trim() }
                                $lastLogSize = $st.Length; $r.Dispose()
                            }
                            $st.Dispose()
                        } catch {}
                    }
                    if ($newLines.Count -gt 0) {
                        $logLineCount += $newLines.Count
                        foreach ($ln in $newLines) {
                            $t = $ln.Trim()
                            if ($t -ne $lastStatus) { $lastStatus = $t; Out-Dim "[HPIA $elapsed] $t" }
                        }
                    } else {
                        Out-Dim "[HPIA $elapsed] Working... ($logLineCount log entries so far)"
                    }
                }
                Start-Sleep -Milliseconds 250
            }
            $sw.Stop()
            Out-Info "HPIA finished in $($sw.Elapsed.ToString('mm\:ss'))"
            switch ($hp.ExitCode) {
                0       { Out-Pass "HPIA completed - no actions required" }
                256     { Out-Pass "HPIA completed - updates applied" }
                257     { Out-Warn "HPIA completed - reboot required" }
                3010    { Out-Warn "HPIA completed - reboot required" }
                4096    { Out-Warn "HPIA completed - one or more updates failed" }
                default { Out-Info "HPIA exited with code $($hp.ExitCode)" }
            }
        } catch { Out-Fail "Error running HPIA: $($_.Exception.Message)"; return }

        Remove-Item $download -Force -ErrorAction SilentlyContinue
        Out-Header "COMPLETE"
        Out-Pass "Report saved to: $reportDir"
    } catch { Out-Fail "Error: $($_.Exception.Message)" }
    finally  { Stop-Busy }
}

# ============================================================================
# ACTION DISPATCH
# ============================================================================
function Invoke-ActionByKey {
    param([string]$Key)
    if (-not $Key) { return }
    Clear-Output

    # Suppress progress bars and confirmation prompts that freeze the
    # WPF dispatcher (Install-Script / Install-Module write progress that
    # blocks when there's no console host).
    $savedProgress = $global:ProgressPreference
    $savedConfirm  = $global:ConfirmPreference
    $global:ProgressPreference = 'SilentlyContinue'
    $global:ConfirmPreference  = 'None'

    try {
        switch ($Key) {
            'enrol-prod'    { Invoke-AutopilotEnrolment -GroupTag $script:GroupTags.Production -FriendlyName 'Device' }
            'enrol-csv'     { New-AutopilotCsv }
            'enrol-custom'  { Invoke-AutopilotEnrolment -GroupTag $script:GroupTags.Custom     -FriendlyName 'Custom Device' }
            'enrol-mtr'     { Invoke-AutopilotEnrolment -GroupTag $script:GroupTags.SurfaceHub -FriendlyName 'Surface Hub' }
            'enrol-lapsafe' { Invoke-AutopilotEnrolment -GroupTag $script:GroupTags.Lapsafe    -FriendlyName 'Lapsafe Laptop' }
            'win11-check'   { Test-Windows11Compatibility }
            'sysinfo'       { Get-SystemInfo }
            'bitlocker'     { Get-BitLockerRecoveryKey }
            'wu-run'        { Invoke-WindowsUpdateRun }
            'ap-status'     { Get-AutopilotStatus }
            'hpia'          { Invoke-HPImageAssistant }
            'remove-cloud'  { Remove-DeviceFromCloud }
            'reset'         { Reset-Device }
            'install-ps7'   { Install-PowerShell7 }
            'restart'       { Restart-PCWithCountdown }
            default         { Out-Warn "Unknown action: $Key" }
        }
    } finally {
        $global:ProgressPreference = $savedProgress
        $global:ConfirmPreference  = $savedConfirm
    }
}

# ============================================================================
# ACTION CATALOG
# ============================================================================
$script:Categories = @(
    @{ Label='Deployment';      ColorKey='CatDeploy'; Items=@(
        @{ Key='enrol-prod';    Text='Enrol Autopilot';    Tip='Enrol this device with the PRODUCTION group tag';  Icon=[char]0xE753 }
        @{ Key='enrol-csv';     Text='Create CSV';          Tip='Export hardware hash to a CSV file';              Icon=[char]0xE8A5 }
        @{ Key='enrol-custom';  Text='Enrol Custom Device'; Tip='Enrol with the CUSTOM group tag';                 Icon=[char]0xE836 }
        @{ Key='enrol-mtr';     Text='Enrol Surface Hub';   Tip='Enrol with the MTR-ICT group tag';                Icon=[char]0xE7F4 }
        @{ Key='enrol-lapsafe'; Text='Enrol Lapsafe';       Tip='Enrol with the LAPSAFE-DEVICE group tag';         Icon=[char]0xE770 }
    )}
    @{ Label='Diagnostics';     ColorKey='CatDiag'; Items=@(
        @{ Key='win11-check'; Text='Win 11 Compat';     Tip='Check Windows 11 hardware compatibility'; Icon=[char]0xE8E8 }
        @{ Key='sysinfo';     Text='System Info';       Tip='Display CPU, RAM, disk, OS, BIOS, serial'; Icon=[char]0xE946 }
        @{ Key='bitlocker';   Text='BitLocker Key';      Tip='Retrieve the local BitLocker recovery key'; Icon=[char]0xE8D7 }
        @{ Key='wu-run';      Text='Windows Update';     Tip='Run Windows Update non-interactively';      Icon=[char]0xE777 }
        @{ Key='ap-status';   Text='Autopilot Status';   Tip='Query Autopilot enrolment status from Graph'; Icon=[char]0xE8EA }
        @{ Key='hpia';        Text='HP Image Assistant'; Tip='Download and run HP Image Assistant';       Icon=[char]0xE835 }
    )}
    @{ Label='System';          ColorKey='CatSystem'; Items=@(
        @{ Key='remove-cloud'; Text='Remove from Cloud'; Tip='Remove device from Autopilot, Intune and Entra'; Icon=[char]0xE74D }
        @{ Key='reset';        Text='Reset Device';       Tip='Factory-reset this device';                    Icon=[char]0xE72C }
        @{ Key='install-ps7';  Text='Install PS 7';       Tip='Install / update PowerShell 7';                Icon=[char]0xE756 }
        @{ Key='restart';      Text='Restart PC';         Tip='Restart this device after a 5s countdown';     Icon=[char]0xE7E8 }
    )}
)

# ============================================================================
# XAML (Windows 11 Settings-style layout)
# ============================================================================
$xaml = @'
<Window xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
        xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
        Title="ICT Autopilot Toolkit"
        Width="1100" Height="760" MinWidth="860" MinHeight="600"
        WindowStartupLocation="CenterScreen"
        Background="#F3F3F3"
        FontFamily="Segoe UI Variable Display, Segoe UI, sans-serif" FontSize="14">
  <Window.Resources>
    <SolidColorBrush x:Key="BgBrush" Color="#F3F3F3"/>
    <SolidColorBrush x:Key="NavBgBrush" Color="#F3F3F3"/>
    <SolidColorBrush x:Key="CardBrush" Color="#FFFFFF"/>
    <SolidColorBrush x:Key="CardHoverBrush" Color="#F9F9F9"/>
    <SolidColorBrush x:Key="AccentBrush" Color="#005FB8"/>
    <SolidColorBrush x:Key="AccentLightBrush" Color="#E8F0FE"/>
    <SolidColorBrush x:Key="TextPriBrush" Color="#1A1A1A"/>
    <SolidColorBrush x:Key="TextSecBrush" Color="#5D5D5D"/>
    <SolidColorBrush x:Key="TextDimBrush" Color="#8A8A8A"/>
    <SolidColorBrush x:Key="DividerBrush" Color="#E5E5E5"/>
    <SolidColorBrush x:Key="OkBrush" Color="#0F7B0F"/>
    <SolidColorBrush x:Key="BadBrush" Color="#C42B1C"/>
    <SolidColorBrush x:Key="NavHoverBrush" Color="#E9E9E9"/>
    <SolidColorBrush x:Key="NavSelectedBrush" Color="#FFFFFF"/>
 
    <!-- Toolbar button (small, rounded) -->
    <Style x:Key="ToolBtn" TargetType="Button">
      <Setter Property="Background" Value="{StaticResource CardBrush}"/>
      <Setter Property="Foreground" Value="{StaticResource TextSecBrush}"/>
      <Setter Property="BorderBrush" Value="{StaticResource DividerBrush}"/>
      <Setter Property="BorderThickness" Value="1"/>
      <Setter Property="Padding" Value="12,5"/>
      <Setter Property="FontSize" Value="12"/>
      <Setter Property="Cursor" Value="Hand"/>
      <Setter Property="Margin" Value="4,0,0,0"/>
      <Setter Property="Template">
        <Setter.Value>
          <ControlTemplate TargetType="Button">
            <Border Background="{TemplateBinding Background}"
                    BorderBrush="{TemplateBinding BorderBrush}"
                    BorderThickness="{TemplateBinding BorderThickness}"
                    CornerRadius="4">
              <ContentPresenter HorizontalAlignment="Center" VerticalAlignment="Center"
                                Margin="{TemplateBinding Padding}"/>
            </Border>
            <ControlTemplate.Triggers>
              <Trigger Property="IsMouseOver" Value="True">
                <Setter Property="Background" Value="{StaticResource NavHoverBrush}"/>
              </Trigger>
            </ControlTemplate.Triggers>
          </ControlTemplate>
        </Setter.Value>
      </Setter>
    </Style>
 
    <!-- Nav item (Settings-style: transparent bg, rounded hover, left accent on selected) -->
    <Style x:Key="NavItem" TargetType="Button">
      <Setter Property="Background" Value="Transparent"/>
      <Setter Property="Foreground" Value="{StaticResource TextPriBrush}"/>
      <Setter Property="HorizontalContentAlignment" Value="Stretch"/>
      <Setter Property="Cursor" Value="Hand"/>
      <Setter Property="Height" Value="38"/>
      <Setter Property="Margin" Value="4,1,4,1"/>
      <Setter Property="FontSize" Value="14"/>
      <Setter Property="Template">
        <Setter.Value>
          <ControlTemplate TargetType="Button">
            <Border x:Name="bd" Background="{TemplateBinding Background}" CornerRadius="4"
                    Padding="4,0">
              <Grid>
                <!-- Left accent indicator -->
                <Border x:Name="accent" Width="3" Height="16" HorizontalAlignment="Left"
                        VerticalAlignment="Center" CornerRadius="1.5"
                        Background="{StaticResource AccentBrush}" Visibility="Collapsed"/>
                <ContentPresenter Margin="10,0,8,0" VerticalAlignment="Center"/>
              </Grid>
            </Border>
            <ControlTemplate.Triggers>
              <Trigger Property="IsMouseOver" Value="True">
                <Setter TargetName="bd" Property="Background" Value="{StaticResource NavHoverBrush}"/>
                <Setter TargetName="accent" Property="Visibility" Value="Visible"/>
              </Trigger>
            </ControlTemplate.Triggers>
          </ControlTemplate>
        </Setter.Value>
      </Setter>
    </Style>
 
    <!-- Search box (rounded, Settings-like) -->
    <Style x:Key="SearchBox" TargetType="TextBox">
      <Setter Property="Background" Value="{StaticResource CardBrush}"/>
      <Setter Property="BorderBrush" Value="{StaticResource DividerBrush}"/>
      <Setter Property="BorderThickness" Value="1"/>
      <Setter Property="Padding" Value="10,6"/>
      <Setter Property="FontSize" Value="14"/>
      <Setter Property="Height" Value="36"/>
      <Setter Property="Margin" Value="12,0,12,12"/>
      <Setter Property="VerticalContentAlignment" Value="Center"/>
      <Setter Property="Template">
        <Setter.Value>
          <ControlTemplate TargetType="TextBox">
            <Border Background="{TemplateBinding Background}"
                    BorderBrush="{TemplateBinding BorderBrush}"
                    BorderThickness="{TemplateBinding BorderThickness}"
                    CornerRadius="4">
              <Grid>
                <ScrollViewer x:Name="PART_ContentHost" Margin="{TemplateBinding Padding}"
                              VerticalAlignment="Center"/>
              </Grid>
            </Border>
          </ControlTemplate>
        </Setter.Value>
      </Setter>
    </Style>
  </Window.Resources>
 
  <Grid>
    <Grid.ColumnDefinitions>
      <ColumnDefinition Width="280"/>
      <ColumnDefinition Width="*"/>
    </Grid.ColumnDefinitions>
 
    <!-- ===== LEFT NAV (Settings sidebar) ===== -->
    <DockPanel Grid.Column="0" Background="{StaticResource NavBgBrush}">
      <!-- App title area (like account/profile in Settings) -->
      <StackPanel DockPanel.Dock="Top" Margin="16,20,16,12">
        <StackPanel Orientation="Horizontal" Margin="8,0,0,8">
          <TextBlock x:Name="AppIcon" FontFamily="Segoe Fluent Icons" FontSize="22"
                     Foreground="{StaticResource AccentBrush}" VerticalAlignment="Center"
                     Text="&#xE770;"/>
          <StackPanel Margin="12,0,0,0">
            <TextBlock x:Name="TitleLabel" FontSize="16" FontWeight="SemiBold"
                       Foreground="{StaticResource TextPriBrush}"/>
            <TextBlock x:Name="VersionLabel" FontSize="11"
                       Foreground="{StaticResource TextDimBrush}"/>
          </StackPanel>
        </StackPanel>
        <!-- Search -->
        <TextBox x:Name="SearchBox" Style="{StaticResource SearchBox}"/>
      </StackPanel>
 
      <!-- Nav items scroll area -->
      <ScrollViewer VerticalScrollBarVisibility="Auto" HorizontalScrollBarVisibility="Disabled"
                    Padding="0,0,0,12">
        <StackPanel x:Name="NavHost"/>
      </ScrollViewer>
    </DockPanel>
 
    <!-- ===== MAIN CONTENT (right side) ===== -->
    <Grid Grid.Column="1" Background="{StaticResource BgBrush}" Margin="0">
      <Grid.RowDefinitions>
        <RowDefinition Height="Auto"/>
        <RowDefinition Height="*"/>
      </Grid.RowDefinitions>
 
      <!-- Device info card (like Settings Home header) -->
      <Border Grid.Row="0" Background="{StaticResource CardBrush}" CornerRadius="8"
              Margin="24,20,24,0" Padding="20,14">
        <Grid>
          <Grid.ColumnDefinitions>
            <ColumnDefinition Width="*"/>
            <ColumnDefinition Width="Auto"/>
            <ColumnDefinition Width="Auto"/>
          </Grid.ColumnDefinitions>
 
          <!-- Device name + model -->
          <StackPanel Grid.Column="0" VerticalAlignment="Center">
            <TextBlock x:Name="DeviceName" FontSize="18" FontWeight="SemiBold"
                       Foreground="{StaticResource TextPriBrush}"/>
            <TextBlock x:Name="DeviceModel" FontSize="13"
                       Foreground="{StaticResource TextSecBrush}" Margin="0,2,0,0"/>
          </StackPanel>
 
          <!-- Status chips -->
          <StackPanel Grid.Column="1" Orientation="Horizontal" VerticalAlignment="Center"
                      Margin="16,0">
            <Border Background="{StaticResource AccentLightBrush}" CornerRadius="4"
                    Padding="10,6" Margin="0,0,8,0">
              <StackPanel Orientation="Horizontal">
                <TextBlock FontFamily="Segoe Fluent Icons" Text="&#xE839;"
                           Foreground="{StaticResource AccentBrush}" FontSize="14"
                           VerticalAlignment="Center" Margin="0,0,6,0"/>
                <TextBlock x:Name="ChipAdminValue" FontSize="12"
                           Foreground="{StaticResource TextPriBrush}" VerticalAlignment="Center"/>
              </StackPanel>
            </Border>
            <Border Background="{StaticResource AccentLightBrush}" CornerRadius="4"
                    Padding="10,6" Margin="0,0,8,0">
              <StackPanel Orientation="Horizontal">
                <TextBlock FontFamily="Segoe Fluent Icons" Text="&#xE774;"
                           Foreground="{StaticResource AccentBrush}" FontSize="14"
                           VerticalAlignment="Center" Margin="0,0,6,0"/>
                <TextBlock x:Name="ChipNetValue" Text="Checking..."
                           FontSize="12" Foreground="{StaticResource TextPriBrush}"
                           VerticalAlignment="Center"/>
              </StackPanel>
            </Border>
          </StackPanel>
 
          <!-- Clock -->
          <TextBlock Grid.Column="2" x:Name="StatusClock" FontSize="12"
                     Foreground="{StaticResource TextDimBrush}" VerticalAlignment="Center"/>
        </Grid>
      </Border>
 
      <!-- Output panel (white card with toolbar inside) -->
      <Border Grid.Row="1" Background="{StaticResource CardBrush}" CornerRadius="8"
              Margin="24,12,24,20">
        <Grid>
          <Grid.RowDefinitions>
            <RowDefinition Height="Auto"/>
            <RowDefinition Height="3"/>
            <RowDefinition Height="*"/>
          </Grid.RowDefinitions>
 
          <!-- Output toolbar -->
          <Grid Grid.Row="0" Margin="16,10,16,6">
            <TextBlock Text="Output" FontSize="13" FontWeight="SemiBold"
                       Foreground="{StaticResource TextSecBrush}" VerticalAlignment="Center"/>
            <StackPanel Orientation="Horizontal" HorizontalAlignment="Right">
              <Button x:Name="BtnFolder" Content="Open Temp" Style="{StaticResource ToolBtn}" ToolTip="Open the working temp folder"/>
              <Button x:Name="BtnSave" Content="Save Log" Style="{StaticResource ToolBtn}" ToolTip="Save current output to a file"/>
              <Button x:Name="BtnCopy" Content="Copy" Style="{StaticResource ToolBtn}" ToolTip="Copy all output to clipboard"/>
              <Button x:Name="BtnClear" Content="Clear" Style="{StaticResource ToolBtn}" ToolTip="Clear output (Esc)"/>
            </StackPanel>
          </Grid>
 
          <!-- Progress bar -->
          <ProgressBar Grid.Row="1" x:Name="ProgressBar" IsIndeterminate="True"
                       Visibility="Collapsed" Height="3" BorderThickness="0"
                       Background="Transparent" Foreground="{StaticResource AccentBrush}"/>
 
          <!-- Output RichTextBox -->
          <RichTextBox Grid.Row="2" x:Name="OutputBox" IsReadOnly="True" BorderThickness="0"
                       Background="Transparent" Padding="18,8,18,14"
                       VerticalScrollBarVisibility="Auto"
                       FontFamily="Cascadia Mono, Consolas, Courier New" FontSize="12.5">
            <FlowDocument PageWidth="2000"/>
          </RichTextBox>
        </Grid>
      </Border>
    </Grid>
  </Grid>
</Window>
'@


# ============================================================================
# WINDOW BUILD
# ============================================================================
function New-MainWindow {
    $reader = New-Object System.Xml.XmlNodeReader ([xml]$xaml)
    $window = [Windows.Markup.XamlReader]::Load($reader)

    # ---- Resolve named elements ------------------------------------------
    $script:Window       = $window
    $script:OutputBox    = $window.FindName('OutputBox')
    $script:ProgressBar  = $window.FindName('ProgressBar')
    $script:SearchBox    = $window.FindName('SearchBox')
    $script:NavHost      = $window.FindName('NavHost')
    $script:StatusClock  = $window.FindName('StatusClock')
    $script:ChipNetValue = $window.FindName('ChipNetValue')

    $window.Title                           = "$script:AppTitle v$script:AppVersion"
    $window.FindName('TitleLabel').Text     = $script:AppTitle
    $window.FindName('VersionLabel').Text   = "v$script:AppVersion"

    # Window icon
    try {
        $iconPath = Get-TempFilePath 'win11.ico'
        if (-not (Test-Path $iconPath)) {
            Invoke-WebRequest -Uri 'https://ictautopilot.blob.core.windows.net/assets/win11.ico' `
                -OutFile $iconPath -UseBasicParsing -TimeoutSec 3 -ErrorAction Stop
        }
        if (Test-Path $iconPath) {
            $window.Icon = New-Object System.Windows.Media.Imaging.BitmapImage([Uri]$iconPath)
        }
    } catch {}

    # Pick best icon font
    $script:IconFont = 'Segoe Fluent Icons'

    # ---- Device facts ----------------------------------------------------
    try { $script:DevSerial = (Get-CimInstance Win32_BIOS).SerialNumber } catch { $script:DevSerial = 'N/A' }
    try { $script:DevModel  = (Get-CimInstance Win32_ComputerSystem).Model } catch { $script:DevModel = 'Unknown' }
    try { $script:DevName   = $env:COMPUTERNAME } catch { $script:DevName = 'PC' }
    try { $script:DevOS     = ((Get-CimInstance Win32_OperatingSystem).Caption -replace 'Microsoft ','') } catch { $script:DevOS = 'Unknown' }
    $script:IsAdmin   = Test-IsAdministrator
    $script:AdminText = if ($script:IsAdmin) { 'Administrator' } else { 'Standard User' }

    $window.FindName('DeviceName').Text  = "$script:DevName"
    $window.FindName('DeviceModel').Text = "$script:DevModel · $script:DevSerial"
    $window.FindName('ChipAdminValue').Text = $script:AdminText
    $script:StatusClock.Text = (Get-Date -Format 'HH:mm:ss')

    # ---- Build nav items (Settings style: icon + text, flat list) --------
    $script:AllCards = @()
    foreach ($cat in $script:Categories) {
        $catBrush = $script:Palette[$cat.ColorKey]

        # Category header (small, dim label)
        $catLabel = New-Object System.Windows.Controls.TextBlock
        $catLabel.Text       = $cat.Label
        $catLabel.FontSize   = 12
        $catLabel.FontWeight = 'SemiBold'
        $catLabel.Foreground = $script:Palette.TextDim
        $catLabel.Margin     = [System.Windows.Thickness]::new(20,14,0,4)
        [void]$script:NavHost.Children.Add($catLabel)

        foreach ($item in $cat.Items) {
            # Button content: icon + label
            $contentGrid = New-Object System.Windows.Controls.Grid
            $col1 = New-Object System.Windows.Controls.ColumnDefinition; $col1.Width = '30'
            $col2 = New-Object System.Windows.Controls.ColumnDefinition; $col2.Width = '*'
            $contentGrid.ColumnDefinitions.Add($col1); $contentGrid.ColumnDefinitions.Add($col2)

            $iconTb = New-Object System.Windows.Controls.TextBlock
            $iconTb.Text       = [string]$item.Icon
            $iconTb.FontFamily = New-Object System.Windows.Media.FontFamily($script:IconFont)
            $iconTb.FontSize   = 16
            $iconTb.Foreground = $catBrush
            $iconTb.HorizontalAlignment = 'Center'
            $iconTb.VerticalAlignment   = 'Center'
            [System.Windows.Controls.Grid]::SetColumn($iconTb, 0)
            [void]$contentGrid.Children.Add($iconTb)

            $textTb = New-Object System.Windows.Controls.TextBlock
            $textTb.Text              = $item.Text
            $textTb.VerticalAlignment = 'Center'
            $textTb.TextTrimming      = 'CharacterEllipsis'
            $textTb.Margin            = [System.Windows.Thickness]::new(8,0,0,0)
            [System.Windows.Controls.Grid]::SetColumn($textTb, 1)
            [void]$contentGrid.Children.Add($textTb)

            $btn = New-Object System.Windows.Controls.Button
            $btn.Style   = $window.FindResource('NavItem')
            $btn.Content = $contentGrid
            $btn.ToolTip = $item.Tip
            $btn.Tag     = $item.Key

            $btn.Add_Click({
                param($sender, $e)
                Invoke-ActionByKey -Key $sender.Tag
            })

            [void]$script:NavHost.Children.Add($btn)

            $script:AllCards += [pscustomobject]@{
                Button        = $btn
                Category      = $catLabel
                CategoryLabel = $cat.Label
                Text          = $item.Text
                Tip           = $item.Tip
            }
        }
    }

    # ---- Search filter ---------------------------------------------------
    $script:SearchBox.Add_TextChanged({
        $q = $script:SearchBox.Text
        if ($null -eq $q) { $q = '' }
        $q = $q.Trim().ToLower()

        $shownByCat = @{}
        foreach ($c in $script:AllCards) {
            $match = ($q -eq '') -or ($c.Text.ToLower().Contains($q)) -or ($c.Tip.ToLower().Contains($q))
            $c.Button.Visibility = if ($match) { 'Visible' } else { 'Collapsed' }
            if ($match) { $shownByCat[$c.CategoryLabel] = $true }
        }
        foreach ($cat in $script:Categories) {
            $catCtrl = ($script:AllCards | Where-Object CategoryLabel -eq $cat.Label | Select-Object -First 1).Category
            $catCtrl.Visibility = if ($shownByCat.ContainsKey($cat.Label)) { 'Visible' } else { 'Collapsed' }
        }
    })

    # ---- Toolbar handlers ------------------------------------------------
    $window.FindName('BtnClear').Add_Click({ Clear-Output })
    $window.FindName('BtnCopy').Add_Click({
        $text = Get-OutputText
        if ($text) { [System.Windows.Clipboard]::SetText($text) }
    })
    $window.FindName('BtnSave').Add_Click({
        $sfd = New-Object Microsoft.Win32.SaveFileDialog
        $sfd.Filter   = 'Log files (*.log)|*.log|Text files (*.txt)|*.txt|All files (*.*)|*.*'
        $sfd.FileName = "ictautopilot-$(Get-Date -Format 'yyyyMMdd-HHmmss').log"
        if ($sfd.ShowDialog($script:Window)) {
            Set-Content -Path $sfd.FileName -Value (Get-OutputText) -Encoding UTF8
        }
    })
    $window.FindName('BtnFolder').Add_Click({ Start-Process explorer.exe $script:TempPath })

    # ---- Keyboard shortcuts ----------------------------------------------
    $window.Add_PreviewKeyDown({
        param($s,$e)
        if ($e.Key -eq 'Escape')                { Clear-Output; $e.Handled = $true }
        elseif ($e.Key -eq 'F' -and ([System.Windows.Input.Keyboard]::Modifiers -band [System.Windows.Input.ModifierKeys]::Control)) {
            $script:SearchBox.Focus(); $e.Handled = $true
        }
    })

    # ---- Clock + connectivity timer --------------------------------------
    $timer = New-Object System.Windows.Threading.DispatcherTimer
    $timer.Interval = [TimeSpan]::FromSeconds(1)
    $script:tickCount = 0
    $timer.Add_Tick({
        $script:StatusClock.Text = (Get-Date -Format 'HH:mm:ss')
        $script:tickCount++
        if ($script:tickCount % 30 -eq 1) {
            try {
                $ok = Test-Connection -ComputerName 'graph.microsoft.com' -Count 1 -Quiet -ErrorAction SilentlyContinue
                if ($ok) { $script:ChipNetValue.Text = 'Online'; $script:ChipNetValue.Foreground = $script:Palette.Ok }
                else     { $script:ChipNetValue.Text = 'Offline'; $script:ChipNetValue.Foreground = $script:Palette.Bad }
            } catch     { $script:ChipNetValue.Text = 'Offline'; $script:ChipNetValue.Foreground = $script:Palette.Bad }
        }
    })
    $timer.Start()

    # ---- Welcome screen --------------------------------------------------
    $window.Add_ContentRendered({
        Out-Header "WELCOME"
        Out-Info  "Hello! This is ICT Autopilot Toolkit v$script:AppVersion."
        Out-Dim   "Pick an action from the sidebar, or press Ctrl+F to search."
        Out-Dim   ""
        if (-not $script:IsAdmin) { Out-Warn "You are not running as Administrator. Most actions will fail." }
        Out-Header "DEVICE"
        Out-Info "Serial Number : $script:DevSerial"
        Out-Info "Model : $script:DevModel"
        Out-Info "OS : $script:DevOS"
        Out-Info "Logged in as : $env:USERNAME ($script:AdminText)"
        Out-Info "Log file : $script:LogFile"
    })

    return $window
}

# ============================================================================
# ENTRY POINT
# ============================================================================
Initialize-WorkingPaths
Write-Log -Message "Starting $script:AppTitle v$script:AppVersion"

$window = New-MainWindow
[void]$window.ShowDialog()