# Monitor-IntuneProgress.ps1 - Real-time status display for the SFLD enrollment
# lifecycle. Called from sync_intune.bat after admin elevation. Replaces the
# 3-step pass/fail polling that lived in the .bat with a richer status table
# spanning all 5 phases of the lifecycle.
#
# WHY THIS EXISTS:
#   The previous sync_intune.bat checked 3 things sequentially (SFLD reg key,
#   DSCInstall.log "Installation completed successfully", "SFLD - Consume
#   Credentials" task). Three problems with that:
#     1. The Consume Credentials task is created PRE-reboot in
#        SetupCredentials.log, not POST-reboot, so it isn't a "fully done"
#        signal - it's a pre-reboot signal that overlaps with the SFLD reg key.
#     2. There was no detection of the pre-reboot -> reboot -> post-reboot
#        transition. The script never read DSCDeployment.log, so it couldn't
#        tell the user "you need to reboot now".
#     3. No visibility into Phase 4 (per-script wrappers like Install-eDNC,
#        Install-UDC, Install-VCRedists, Install-OpenText). When something hung
#        you had to manually grep C:\Logs\SFLD\.
#
# WHAT THIS SCRIPT DOES:
#   - Renders a 5-phase status table that updates every $PollSecs (default 30s)
#   - Triggers an Intune sync at startup, re-triggers every $RetriggerMinutes
#     (default 3 min) until the device hits the final state
#   - Auto-discovers Phase 4 custom scripts by parsing "Downloading script:"
#     lines from DSCInstall.log AND scanning C:\Logs\SFLD\Install-*.log files
#   - Boot-loop-safe reboot detection: only signals "reboot needed" if
#     DSCDeployment.log was modified AFTER the last system boot
#   - Caches dsregcmd output and other monotonic Phase 1 indicators (once
#     true, they don't go back to false)
#
# EXIT CODES (consumed by sync_intune.bat):
#   0 = all done, lockdown landed, system rebooting to ShopFloor autologon
#   2 = PPKG-reboot required (DSC deployment phase done, install phase pending)
#   1 = error
#
# PHASE 6 (Lockdown) was added 2026-04-15 after a pre/post state capture
# showed that "DSCInstall.log complete" is NOT the true end-of-line. After
# DSC finishes, MDM/PolicyCSP continues delivering the kiosk baseline which
# ends with two observable machine-level signals:
#   - HKLM Winlogon DefaultUserName flipped from SupportUser -> ShopFloor
#   - Local 'Administrator' account renamed to 'SFLDAdmin'
# Once both land, the script writes sync-complete.txt, runs the tech-facing
# Configure-PC machine-number prompt, and issues shutdown /r. The next boot
# auto-logs in as ShopFloor, materializing its profile from Default User.
#
# DETECTION REFERENCES (decoded from a real run captured at /home/camp/pxe-images/Logs/):
#   Phase A (pre-reboot, ~08:35-08:52):
#     - enrollment.log              ppkg + computer name
#     - SetupCredentials.log        creates HKLM\SOFTWARE\GE\SFLD\DSC + Consume Credentials task
#     - DSCDeployment.log           ends "Deployment completed successfully"
#                                   writes C:\Logs\SFLD\version.txt
#                                   creates SFLD-ApplyDSCConfig scheduled task
#     [REBOOT]
#   Phase B (post-reboot, ~08:58):
#     - DSCInstall.log              downloads device-config.yaml from blob,
#                                   processes Applications + CustomScripts,
#                                   ends "Installation completed successfully"
#                                   writes C:\Logs\SFLD\DSCVersion.txt
#     - C:\Logs\SFLD\Install-*.log  one per CustomScript wrapper

[CmdletBinding()]
param(
    [int]$PollSecs = 30,
    [int]$RetriggerMinutes = 5,
    # -Unattended: exit with status code instead of prompting/rebooting.
    # Legacy flag kept for backward compatibility with manual testing.
    [switch]$Unattended,
    # -AsTask: runs as a persistent @logon scheduled task. On pre-reboot
    # complete: reboots directly. On post-reboot complete: unregisters own
    # task, launches ConfigureScript, exits. This is the primary mode for
    # the imaging chain (registered by run-enrollment.ps1).
    [switch]$AsTask,
    # Path to Configure-PC.ps1, launched after post-reboot completion in
    # -AsTask mode. Passed by the scheduled task's -ArgumentList.
    [string]$ConfigureScript = ''
)

# ============================================================================
# Transcript logging - writes EVERYTHING the script sees and writes to a log
# file so we can diagnose auto-close / crash issues after the fact. Stored
# under C:\Logs\SFLD\ alongside the DSC logs. If the dir doesn't exist yet
# we fall back to %TEMP% so logging never itself fails the run.
# ============================================================================
$logDir = 'C:\Logs\SFLD'
if (-not (Test-Path $logDir)) {
    try { New-Item -ItemType Directory -Path $logDir -Force | Out-Null } catch { $logDir = $env:TEMP }
}
$transcriptPath = Join-Path $logDir 'sync_intune_transcript.txt'
try { Start-Transcript -Path $transcriptPath -Append -Force | Out-Null } catch {}

Write-Host ""
Write-Host "=== Monitor-IntuneProgress.ps1 starting $(Get-Date -Format 'yyyy-MM-dd HH:mm:ss') ==="
Write-Host "Transcript: $transcriptPath"
Write-Host ""

# ============================================================================
# Re-enable any wired NICs that Order 5 (migrate-to-wifi.ps1) disabled during
# first-logon. Previously Run-ShopfloorSetup.ps1 did this with a blocking
# interactive "unplug PXE cable, press any key" prompt. By moving it here
# - to the start of sync_intune, which runs AFTER PPKG reboot + auto-login
# - the tech has had ample wall-clock time (minutes) to physically unplug
# the PXE cable and plug into the production ethernet without us blocking
# the imaging chain on a keypress. Tower (no WiFi) case is a no-op because
# Order 5's WiFi detection left the wired NIC enabled to begin with.
# ============================================================================
# Wired NIC re-enable is handled by the 'GE Re-enable Wired NICs' SYSTEM
# task (registered by Run-ShopfloorSetup.ps1) which polls for SFLD creds
# and re-enables once they appear. This script (Limited principal) can't
# call Enable-NetAdapter itself.
Write-Host ""

# ============================================================================
# Console resize - the QR code is ~30 lines and the 5-phase status table is
# ~25 lines. Default cmd window is 25 rows, so the QR scrolls off the top.
# Bump the window (and buffer) to ~58 rows so the whole frame fits at once.
# Wrapped in try/catch because some hosts (Windows Terminal, ISE) don't honor
# WindowSize/BufferSize changes - in that case we just live with the default.
# ============================================================================
try {
    $rui = $Host.UI.RawUI
    $maxH = $rui.MaxPhysicalWindowSize.Height
    $targetWindow = [Math]::Min(58, [int]$maxH)
    $targetBuffer = [Math]::Max($targetWindow, 200)

    # Buffer must be set FIRST and must be >= window. Width must not shrink
    # below current window width or SetBufferSize throws.
    $bs = $rui.BufferSize
    if ($bs.Height -lt $targetBuffer) {
        $bs.Height = $targetBuffer
        $rui.BufferSize = $bs
    }

    $ws = $rui.WindowSize
    if ($ws.Height -lt $targetWindow) {
        $ws.Height = $targetWindow
        $rui.WindowSize = $ws
    }
} catch {}

# ============================================================================
# Helpers
# ============================================================================

function Read-RegValue {
    param([string]$Path, [string]$Name)
    try { (Get-ItemProperty -Path $Path -Name $Name -ErrorAction Stop).$Name }
    catch { $null }
}

function Get-EnrollmentId {
    Get-ChildItem 'HKLM:\SOFTWARE\Microsoft\Enrollments' -ErrorAction SilentlyContinue |
        Where-Object { (Get-ItemProperty $_.PSPath -ErrorAction SilentlyContinue).ProviderID -eq 'MS DM Server' } |
        Select-Object -First 1 -ExpandProperty PSChildName
}

function Format-Age {
    param($Seconds)
    if ($null -eq $Seconds) { return "??" }
    $s = [double]$Seconds
    if ($s -lt 0) { return "now" }
    if ($s -lt 60)   { return "{0:N0}s" -f $s }
    if ($s -lt 3600) { return "{0:N0}m {1:N0}s" -f [math]::Floor($s / 60), ($s % 60) }
    return "{0:N1}h" -f ($s / 3600)
}

# ============================================================================
# Monotonic Phase 1 cache - dsregcmd is slow (~1-2s) and these flags don't
# revert once true, so we only re-check until they pass.
# ============================================================================
$script:cache = @{
    AzureAdJoined  = $false
    IntuneEnrolled = $false
    EmTaskExists   = $false
    EnrollmentId   = $null
}

function Get-Phase1 {
    if (-not $script:cache.AzureAdJoined) {
        try {
            $dsreg = dsregcmd /status 2>&1
            if ($dsreg -match 'AzureAdJoined\s*:\s*YES') {
                $script:cache.AzureAdJoined = $true
            }
        } catch {}
    }

    if (-not $script:cache.IntuneEnrolled) {
        $eid = Get-EnrollmentId
        if ($eid) {
            $script:cache.EnrollmentId   = $eid
            $script:cache.IntuneEnrolled = $true
        }
    }

    if (-not $script:cache.EmTaskExists -and $script:cache.EnrollmentId) {
        try {
            $tp = "\Microsoft\Windows\EnterpriseMgmt\$($script:cache.EnrollmentId)\"
            $tasks = Get-ScheduledTask -TaskPath $tp -ErrorAction SilentlyContinue
            if ($tasks) { $script:cache.EmTaskExists = $true }
        } catch {}
    }

    $policiesArriving = $false
    try {
        $children = Get-ChildItem 'HKLM:\SOFTWARE\Microsoft\PolicyManager\current\device' -ErrorAction SilentlyContinue
        $policiesArriving = (($children | Measure-Object).Count -gt 0)
    } catch {}

    return @{
        AzureAdJoined    = $script:cache.AzureAdJoined
        IntuneEnrolled   = $script:cache.IntuneEnrolled
        EmTaskExists     = $script:cache.EmTaskExists
        PoliciesArriving = $policiesArriving
        EnrollmentId     = $script:cache.EnrollmentId
    }
}

# ============================================================================
# Phase 4: auto-discover custom scripts. Parse DSCInstall.log for
# "Downloading script: <name>" lines to know what's EXPECTED, then look at
# C:\Logs\SFLD\Install-*.log to know what's DONE/RUNNING/FAILED. Anything
# expected but missing a log file is "pending".
# ============================================================================
function Get-CustomScriptStatuses {
    param([string]$DscInstallLog)

    $logDir = 'C:\Logs\SFLD'
    if (-not (Test-Path $logDir)) { return @() }

    # Discover existing Install-*.log files
    $now = Get-Date
    $existing = @{}
    Get-ChildItem $logDir -Filter 'Install-*.log' -ErrorAction SilentlyContinue | ForEach-Object {
        $age = ($now - $_.LastWriteTime).TotalSeconds
        $tail = Get-Content $_.FullName -Tail 30 -ErrorAction SilentlyContinue
        $hasError = $false
        if ($tail) {
            # Filter out benign matches that come from summary/total lines like
            # "  Failed:    0" or "Errors: 0" or "Exceptions=0" -- the keyword
            # is there but with a zero/empty value. Look for real failures:
            # the keyword followed by content that is NOT just zero.
            $errMatch = $tail | Where-Object {
                $_ -match '(?i)\b(ERROR|Failed|exception)\b' -and
                $_ -notmatch '(?i)\b(ERROR|Failed|Failures|Errors|Exceptions?)\s*[:=]\s*0\b'
            } | Select-Object -First 1
            if ($errMatch) { $hasError = $true }
        }
        $status = if ($age -lt 30)    { 'running' }
                  elseif ($hasError)  { 'failed' }
                  else                { 'done' }
        $existing[$_.BaseName] = @{
            Name    = $_.BaseName
            Status  = $status
            Age     = $age
            LogFile = $_.FullName
        }
    }

    # Parse DSCInstall.log for the expected script list
    $expected = @()
    if (Test-Path $DscInstallLog) {
        try {
            $hits = Select-String -Path $DscInstallLog -Pattern 'Downloading script:\s*(\S+)' -ErrorAction SilentlyContinue
            foreach ($h in $hits) {
                $name = $h.Matches[0].Groups[1].Value
                if ($name -notlike 'Install-*') { $name = "Install-$name" }
                if ($expected -notcontains $name) { $expected += $name }
            }
        } catch {}
    }

    # Build final ordered list: expected scripts first (in DSC order), then any
    # log files we found that weren't in the expected list (legacy / orphaned).
    $result = @()
    foreach ($name in $expected) {
        if ($existing.ContainsKey($name)) {
            $result += $existing[$name]
        } else {
            $result += @{ Name = $name; Status = 'pending'; Age = $null; LogFile = $null }
        }
    }
    foreach ($name in $existing.Keys) {
        if ($expected -notcontains $name) {
            $result += $existing[$name]
        }
    }
    return $result
}

# ============================================================================
# Boot-loop-safe reboot check.
# Returns:
#   'none'         - install already complete OR pre-reboot phase not done
#   'needed'       - pre-reboot done AND we haven't rebooted since
#   'in-progress'  - pre-reboot done AND we already rebooted (just waiting for
#                    post-reboot DSCInstall.log to finish)
# ============================================================================
function Get-LockdownState {
    # Lockdown is applied by an Intune Remediation script (not the PPKG
    # directly). The remediation runs Autologon.exe to configure ShopFloor
    # autologon and writes two IME logs under
    # C:\ProgramData\Microsoft\IntuneManagementExtension\Logs\:
    #   Autologon_Remediation.log - "Autologon set for ShopFloor user ..."
    #   Autologon_Detection.log  - "... matches the expected value: 1"
    #
    # These are the TRUE end-of-chain signals. The DefaultUserName flip and
    # admin rename that we previously checked land at PPKG time (too early);
    # the IME logs only appear after Intune enrollment + category + DSC +
    # Remediation cycle, which is the actual lockdown completion.
    $imeLogs = 'C:\ProgramData\Microsoft\IntuneManagementExtension\Logs'

    $remLog = Join-Path $imeLogs 'Autologon_Remediation.log'
    $remDone = $false
    if (Test-Path $remLog) {
        try {
            $content = Get-Content $remLog -Raw -ErrorAction Stop
            $remDone = ($content -match 'Autologon set for ShopFloor')
        } catch {}
    }

    $detLog = Join-Path $imeLogs 'Autologon_Detection.log'
    $detDone = $false
    if (Test-Path $detLog) {
        try {
            $content = Get-Content $detLog -Raw -ErrorAction Stop
            $detDone = ($content -match 'matches the expected value:\s*1')
        } catch {}
    }

    return @{
        RemediationApplied = $remDone
        DetectionPassed    = $detDone
        Complete           = ($remDone -and $detDone)
    }
}

function Test-RebootState {
    $deployLog  = 'C:\Logs\SFLD\DSCDeployment.log'
    $installLog = 'C:\Logs\SFLD\DSCInstall.log'

    if (Test-Path $installLog) {
        $tail = Get-Content $installLog -Tail 10 -ErrorAction SilentlyContinue
        if ($tail -match 'Installation completed successfully') { return 'none' }
    }

    if (Test-Path $deployLog) {
        $tail = Get-Content $deployLog -Tail 10 -ErrorAction SilentlyContinue
        if ($tail -match 'Deployment completed successfully') {
            try {
                $lastBoot   = (Get-CimInstance Win32_OperatingSystem -ErrorAction Stop).LastBootUpTime
                $deployTime = (Get-Item $deployLog -ErrorAction Stop).LastWriteTime
                if ($deployTime -gt $lastBoot) { return 'needed' }
                return 'in-progress'
            } catch {
                return 'needed'
            }
        }
    }
    return 'none'
}

# ============================================================================
# Snapshot - one call collects everything we need to render
# ============================================================================
function Get-Snapshot {
    $p1 = Get-Phase1
    $function = Read-RegValue 'HKLM:\SOFTWARE\GE\SFLD\DSC' 'Function'
    $sasToken = Read-RegValue 'HKLM:\SOFTWARE\GE\SFLD\DSC' 'SasToken'

    $deployLog  = 'C:\Logs\SFLD\DSCDeployment.log'
    $installLog = 'C:\Logs\SFLD\DSCInstall.log'

    $deployExists = Test-Path $deployLog
    $deployComplete = $false
    if ($deployExists) {
        $t = Get-Content $deployLog -Tail 10 -ErrorAction SilentlyContinue
        if ($t -match 'Deployment completed successfully') { $deployComplete = $true }
    }

    $installExists = Test-Path $installLog
    $installComplete = $false
    if ($installExists) {
        $t = Get-Content $installLog -Tail 10 -ErrorAction SilentlyContinue
        if ($t -match 'Installation completed successfully') { $installComplete = $true }
    }

    $consumeCredsTask = $false
    try {
        $task = Get-ScheduledTask -TaskName 'SFLD - Consume Credentials' -ErrorAction SilentlyContinue
        if ($task) { $consumeCredsTask = $true }
    } catch {}

    # Real completion signal: creds actually landed in HKLM. The scheduled
    # task existing just means DSC scheduled it; this checks it RAN and
    # populated the share creds that the logon enforcers depend on.
    $credsPopulated = $false
    $credsBase = 'HKLM:\SOFTWARE\GE\SFLD\Credentials'
    if (Test-Path $credsBase) {
        foreach ($entry in (Get-ChildItem -Path $credsBase -ErrorAction SilentlyContinue)) {
            $p = Get-ItemProperty -Path $entry.PSPath -ErrorAction SilentlyContinue
            if ($p -and $p.TargetHost -and $p.Username -and $p.Password) {
                $credsPopulated = $true
                break
            }
        }
    }

    $customScripts = Get-CustomScriptStatuses -DscInstallLog $installLog
    $lockdown = Get-LockdownState

    return [PSCustomObject]@{
        Function = $function
        Phase1 = $p1
        Phase2 = @{
            SfldRoot   = (Test-Path 'HKLM:\SOFTWARE\GE\SFLD')
            FunctionOk = (-not [string]::IsNullOrWhiteSpace($function))
            SasTokenOk = (-not [string]::IsNullOrWhiteSpace($sasToken))
        }
        Phase3 = @{
            DeployLogExists  = $deployExists
            DeployComplete   = $deployComplete
            InstallLogExists = $installExists
            InstallComplete  = $installComplete
        }
        Phase4 = $customScripts
        Phase5 = @{
            ConsumeCredsTask = $consumeCredsTask
            CredsPopulated   = $credsPopulated
        }
        Phase6 = $lockdown
        DscInstallComplete = $installComplete
        LockdownComplete   = $lockdown.Complete
    }
}

# ============================================================================
# Sync trigger (Schedule #3 task per Intune enrollment)
# ============================================================================
function Invoke-IntuneSync {
    try {
        $enrollPath = 'HKLM:\SOFTWARE\Microsoft\Enrollments'
        Get-ChildItem $enrollPath -ErrorAction SilentlyContinue | ForEach-Object {
            $provider = (Get-ItemProperty $_.PSPath -ErrorAction SilentlyContinue).ProviderID
            if ($provider -eq 'MS DM Server') {
                $id = $_.PSChildName
                $tp = "\Microsoft\Windows\EnterpriseMgmt\$id\"
                Get-ScheduledTask -TaskPath $tp -ErrorAction SilentlyContinue |
                    Where-Object { $_.TaskName -match 'Schedule #3' } |
                    ForEach-Object { Start-ScheduledTask -InputObject $_ -ErrorAction SilentlyContinue }
            }
        }
    } catch {}
}

# ============================================================================
# QR code (cached as text - generate once, re-print on every redraw)
#
# Half-block renderer: each output character represents 1 QR module wide and
# 2 QR modules tall, using Unicode half-block characters:
#   U+2588 ([block]) = both top and bottom modules set
#   U+2580 ([upper]) = only top module set
#   U+2584 ([lower]) = only bottom module set
#   space            = neither set
# Cuts QR height roughly in half vs AsciiQRCode full-block rendering. May
# render slightly non-square on fonts where char cell aspect isn't exactly
# 1:2, but it's half the real estate and the user accepted that trade-off.
# ============================================================================
function Build-QRCodeText {
    $lines = @()
    try {
        $dsreg = dsregcmd /status 2>&1
        $line  = $dsreg | Select-String 'DeviceId' | Select-Object -First 1
    } catch {
        $line = $null
    }
    if (-not $line) {
        $lines += "Device not yet Azure AD joined."
        return ($lines -join "`n")
    }
    $deviceId = $line.ToString().Split(':')[1].Trim()
    $lines += "Intune Device ID: $deviceId"
    $lines += ""

    $dllPath = 'C:\Enrollment\shopfloor-setup\Shopfloor\QRCoder.dll'
    if (-not (Test-Path $dllPath)) {
        $lines += "(QRCoder.dll not found at $dllPath - skipping QR code)"
        return ($lines -join "`n")
    }

    try {
        Add-Type -Path $dllPath
        $gen  = New-Object QRCoder.QRCodeGenerator
        $data = $gen.CreateQrCode($deviceId, [QRCoder.QRCodeGenerator+ECCLevel]::L)

        # Walk the module matrix. QRCoder's ModuleMatrix does NOT include a
        # quiet zone by default (confirmed empirically - for a UUID payload
        # at ECC L, ModuleMatrix is 29x29 for QR3 data only). We add a
        # 4-module quiet zone manually via $pad below.
        $matrix = $data.ModuleMatrix
        $size   = $matrix.Count
        $pad    = 4
        $total  = $size + 2 * $pad

        $upper = [char]0x2580
        $lower = [char]0x2584
        $full  = [char]0x2588
        $left  = '        '  # 8-space indent matches the old layout

        for ($y = 0; $y -lt $total; $y += 2) {
            $sb = New-Object System.Text.StringBuilder
            [void]$sb.Append($left)
            for ($x = 0; $x -lt $total; $x++) {
                $mx  = $x - $pad
                $my1 = $y - $pad
                $my2 = $y + 1 - $pad

                $top = ($my1 -ge 0 -and $my1 -lt $size -and $mx -ge 0 -and $mx -lt $size -and $matrix[$my1].Get($mx))
                $bot = ($my2 -ge 0 -and $my2 -lt $size -and $mx -ge 0 -and $mx -lt $size -and $matrix[$my2].Get($mx))

                if     ($top -and $bot) { [void]$sb.Append($full) }
                elseif ($top)           { [void]$sb.Append($upper) }
                elseif ($bot)           { [void]$sb.Append($lower) }
                else                    { [void]$sb.Append(' ') }
            }
            $lines += $sb.ToString()
        }
    } catch {
        $lines += "(QR code generation failed: $($_.Exception.Message))"
    }
    return ($lines -join "`n")
}

# ============================================================================
# Renderer
# ============================================================================
function Get-PhaseStatus {
    param([hashtable[]]$Checks)
    $total   = $Checks.Count
    $passed  = ($Checks | Where-Object { $_.Ok }).Count
    $failed  = ($Checks | Where-Object { $_.Failed }).Count
    if ($failed -gt 0)        { return 'FAILED' }
    if ($passed -eq $total)   { return 'COMPLETE' }
    if ($passed -gt 0)        { return 'IN PROGRESS' }
    return 'WAITING'
}

function Format-StatusTag {
    param([string]$Status)
    switch ($Status) {
        'COMPLETE'    { Write-Host ('[COMPLETE]'.PadLeft(14)) -ForegroundColor Green -NoNewline }
        'IN PROGRESS' { Write-Host ('[IN PROGRESS]'.PadLeft(14)) -ForegroundColor Cyan -NoNewline }
        'WAITING'     { Write-Host ('[WAITING]'.PadLeft(14)) -ForegroundColor DarkGray -NoNewline }
        'FAILED'      { Write-Host ('[FAILED]'.PadLeft(14)) -ForegroundColor Red -NoNewline }
        default       { Write-Host ('[UNKNOWN]'.PadLeft(14)) -ForegroundColor Yellow -NoNewline }
    }
}

function Format-Snapshot {
    param($Snap, $LastSync, $NextRetrigger)

    $lines = @()
    $lines += ""
    $lines += "  GE Aerospace  --  Shopfloor Device Setup"
    $lines += ""
    if ($Snap.Function) {
        $lines += "  Category: $($Snap.Function)"
    }
    $lines += "  $(Get-Date -Format 'yyyy-MM-dd HH:mm:ss')"
    $lines += ""
    foreach ($l in $lines) { Write-Host $l }
    Write-Host "  ============================================"

    # Phase 1: Intune Registration
    $p1Done = ($Snap.Phase1.AzureAdJoined -and $Snap.Phase1.IntuneEnrolled -and
               $Snap.Phase1.EmTaskExists -and $Snap.Phase1.PoliciesArriving)
    $p1Status = Get-PhaseStatus @(
        @{ Ok = $Snap.Phase1.AzureAdJoined;  Failed = $false },
        @{ Ok = $Snap.Phase1.IntuneEnrolled;  Failed = $false },
        @{ Ok = $Snap.Phase1.EmTaskExists;    Failed = $false },
        @{ Ok = $Snap.Phase1.PoliciesArriving; Failed = $false }
    )

    # Phase 6 / Lockdown (shared by both flows, rendered last)
    $p6Status = Get-PhaseStatus @(
        @{ Ok = $Snap.Phase6.RemediationApplied; Failed = $false },
        @{ Ok = $Snap.Phase6.DetectionPassed;    Failed = $false }
    )

    if (-not $skipDsc) {
        # ---- Standard / CMM / etc. (DSC flow) ----
        # Phases: 1 Registration -> 2 Config -> 3 Deployment -> 4 Apps -> 5 Creds -> 6 Lockdown

        $p2Done = ($Snap.Phase2.SfldRoot -and $Snap.Phase2.FunctionOk -and $Snap.Phase2.SasTokenOk)
        $p2Status = Get-PhaseStatus @(
            @{ Ok = $Snap.Phase2.SfldRoot;   Failed = $false },
            @{ Ok = $Snap.Phase2.FunctionOk; Failed = $false },
            @{ Ok = $Snap.Phase2.SasTokenOk; Failed = $false }
        )
        $p3Status = Get-PhaseStatus @(
            @{ Ok = $Snap.Phase3.DeployComplete;  Failed = $false },
            @{ Ok = $Snap.Phase3.InstallComplete; Failed = $false }
        )
        $p4HasFailed = $false; $p4AllDone = $true; $p4AnyStarted = $false
        $p4HasScripts = ($Snap.Phase4 -and $Snap.Phase4.Count -gt 0)
        if ($p4HasScripts) {
            foreach ($s in $Snap.Phase4) {
                if ($s.Status -eq 'failed')  { $p4HasFailed = $true }
                if ($s.Status -ne 'done')    { $p4AllDone = $false }
                if ($s.Status -ne 'pending') { $p4AnyStarted = $true }
            }
        } else {
            # No scripts discovered. If DSC install is already complete,
            # there are simply no custom scripts for this image type --
            # that's COMPLETE, not WAITING.
            $p4AllDone = $Snap.Phase3.InstallComplete
        }
        $p4Status = if ($p4HasFailed) { 'FAILED' } elseif ($p4AllDone) { 'COMPLETE' } elseif ($p4AnyStarted) { 'IN PROGRESS' } else { 'WAITING' }

        $p5Done = ($Snap.Phase5.ConsumeCredsTask -and $Snap.Phase5.CredsPopulated)
        $p5Status = Get-PhaseStatus @(
            @{ Ok = $Snap.Phase5.ConsumeCredsTask; Failed = $false },
            @{ Ok = $Snap.Phase5.CredsPopulated;   Failed = $false }
        )

        # Render
        Write-Host '  1. Intune Registration    ' -NoNewline; Format-StatusTag $p1Status; Write-Host ''
        if ($p1Done -and -not $p2Done) {
            Write-Host '     >> Select Device Category in Intune portal' -ForegroundColor Yellow
        }
        Write-Host '  2. Device Configuration   ' -NoNewline; Format-StatusTag $p2Status; Write-Host ''
        Write-Host '  3. Software Deployment    ' -NoNewline; Format-StatusTag $p3Status; Write-Host ''
        Write-Host '  4. Application Install    ' -NoNewline; Format-StatusTag $p4Status; Write-Host ''
        Write-Host '  5. Credential Setup       ' -NoNewline; Format-StatusTag $p5Status; Write-Host ''
        if ($p5Done -and $p6Status -ne 'COMPLETE') {
            Write-Host '     >> Initiate ARTS Lockdown request' -ForegroundColor Yellow
        }
        Write-Host '  6. Lockdown               ' -NoNewline; Format-StatusTag $p6Status; Write-Host ''
    } else {
        # ---- Display (no DSC, no credentials) ----
        # Phases: 1 Registration -> 2 Device Configuration -> 3 Lockdown
        # Device Configuration for Display = policies arriving from Intune.
        $p2DisplayDone = $Snap.Phase1.PoliciesArriving
        $p2DisplayStatus = if ($p2DisplayDone) { 'COMPLETE' }
                           elseif ($p1Done) { 'IN PROGRESS' }
                           else { 'WAITING' }

        Write-Host '  1. Intune Registration    ' -NoNewline; Format-StatusTag $p1Status; Write-Host ''
        if ($p1Done -and -not $p2DisplayDone) {
            Write-Host '     >> Select Device Category in Intune portal' -ForegroundColor Yellow
        }
        Write-Host '  2. Device Configuration   ' -NoNewline; Format-StatusTag $p2DisplayStatus; Write-Host ''
        if ($p2DisplayDone -and $p6Status -ne 'COMPLETE') {
            Write-Host '     >> Initiate ARTS Lockdown request' -ForegroundColor Yellow
        }
        Write-Host '  3. Lockdown               ' -NoNewline; Format-StatusTag $p6Status; Write-Host ''
    }

    Write-Host "  ============================================"
    Write-Host ""
    $sinceSync = ((Get-Date) - $LastSync).TotalSeconds
    $untilNext = ($NextRetrigger - (Get-Date)).TotalSeconds
    Write-Host "  Last sync: $(Format-Age $sinceSync) ago  |  Next: $(Format-Age $untilNext)"

    return @()
}

# ============================================================================
# Key buffer drain + single-keypress reader - used before final prompts so
# any keystrokes the user tapped during the polling loop don't satisfy the
# prompt prematurely. Wrapped in try/catch because [Console]::KeyAvailable
# throws in non-interactive hosts (ISE, remoting).
# ============================================================================
function Clear-KeyBuffer {
    try {
        while ([Console]::KeyAvailable) { [void][Console]::ReadKey($true) }
    } catch {}
}

function Read-SingleKey {
    param([string[]]$ValidKeys)
    Clear-KeyBuffer
    while ($true) {
        try {
            $k = [Console]::ReadKey($true)
            $char = ([string]$k.KeyChar).ToUpper()
            if ($ValidKeys -contains $char) { return $char }
        } catch {
            # Fallback for hosts that don't expose ReadKey - use Read-Host.
            $ans = (Read-Host ("Enter one of: " + ($ValidKeys -join '/'))).Trim().ToUpper()
            if ($ValidKeys -contains $ans) { return $ans }
        }
    }
}

function Wait-ForAnyKey {
    param([string]$Prompt = 'Press any key to close this window...')
    Write-Host ""
    Write-Host $Prompt
    Clear-KeyBuffer
    try {
        [void][Console]::ReadKey($true)
    } catch {
        [void](Read-Host)
    }
}

# ============================================================================
# Final-state handlers - called from the main loop when either the
# post-reboot install has completed, or the pre-reboot deployment is done
# and a reboot is required. Each handler draws its own final frame, prompts
# the user, then exits.
# ============================================================================
function Invoke-SetupComplete {
    Write-Host ""
    Write-Host "========================================" -ForegroundColor Green
    Write-Host "  Setup + lockdown complete"             -ForegroundColor Green
    Write-Host "========================================" -ForegroundColor Green
    Write-Host ""
    Write-Host "DSC install is finished and the kiosk baseline has fully landed"
    Write-Host "(Winlogon flipped to ShopFloor autologon, Administrator renamed)."

    if ($AsTask) {
        # Write completion marker so future logon-triggered runs exit
        # immediately. We can't Unregister-ScheduledTask ourselves because
        # this task runs as BUILTIN\Users (Limited) which lacks permission
        # to delete tasks. 06-OrganizeDesktop.ps1 (which runs as SYSTEM via
        # its own sweep task on every logon) watches for this marker and
        # deletes the stale Shopfloor Intune Sync task when it sees us.
        try {
            Set-Content -LiteralPath $syncCompleteMarker -Value (Get-Date -Format 'o') -Force
            Write-Host "Sync complete marker written." -ForegroundColor Green
        } catch {
            Write-Warning "Failed to write completion marker: $_"
        }

        # Machine number prompt only (startup items are auto-applied by
        # 06-OrganizeDesktop from the PC profile). Runs in SupportUser
        # session while tech is still near the PC; skipped silently if
        # UDC/eDNC already hold a real number (tech typed at PXE or
        # restore-from-.reg already populated them).
        if ($ConfigureScript -and (Test-Path -LiteralPath $ConfigureScript)) {
            try { & $ConfigureScript -MachineNumberOnly } catch { Write-Warning "Configure-PC failed: $_" }
        }

        # Reboot so Winlogon's new DefaultUserName=ShopFloor kicks in -
        # autologon only fires at the logon boundary. Next boot brings up
        # a clean ShopFloor session; this task will fire again for that
        # user, see the marker, and exit in <1s (via [Environment]::Exit).
        Write-Host ""
        Write-Host "Rebooting in 10 seconds for ShopFloor autologon..." -ForegroundColor Yellow
        & shutdown.exe /r /t 10
        exit 0
    }

    if ($Unattended) {
        Write-Host "(Unattended mode - exiting; reboot left to caller)"
        exit 0
    }
    Wait-ForAnyKey
    exit 0
}

function Invoke-RebootPrompt {
    Write-Host ""
    Write-Host "========================================" -ForegroundColor Yellow
    Write-Host "  REBOOT REQUIRED"                        -ForegroundColor Yellow
    Write-Host "========================================" -ForegroundColor Yellow
    Write-Host ""
    Write-Host "The pre-reboot deployment phase is complete. You must reboot now"
    Write-Host "to start the post-reboot DSC install phase, which downloads"
    Write-Host "device-config.yaml and runs the per-app wrappers: Install-eDNC,"
    Write-Host "Install-UDC, Install-VCRedists, Install-OpenText, and so on."
    Write-Host ""

    if ($AsTask -or $Unattended) {
        # Task/unattended mode: reboot directly, no prompt.
        # The task persists across the reboot -- it fires again on next
        # logon and picks up the post-reboot monitoring phase.
        Write-Host "Pre-reboot complete. Rebooting in 5 seconds..." -ForegroundColor Yellow
        shutdown /r /t 5
        exit 0
    }

    # Interactive mode (manual sync_intune.bat): prompt and reboot directly.
    Write-Host "Press Y to reboot now, or N to cancel: " -NoNewline -ForegroundColor Cyan
    $ans = Read-SingleKey -ValidKeys @('Y', 'N')
    Write-Host $ans
    if ($ans -eq 'Y') {
        Write-Host ""
        Write-Host "Rebooting in 5 seconds..." -ForegroundColor Cyan
        Start-Sleep -Seconds 5
        try {
            Restart-Computer -Force -ErrorAction Stop
        } catch {
            Write-Host "Restart-Computer failed: $($_.Exception.Message)" -ForegroundColor Red
            Write-Host "Falling back to shutdown.exe /r /t 5..."
            & shutdown.exe /r /t 5
        }
        exit 0
    }
    Write-Host ""
    Write-Host "Cancelled - reboot manually when ready." -ForegroundColor Yellow
    Wait-ForAnyKey
    exit 0
}

# ============================================================================
# Main loop
#
# Display order is intentional: status table FIRST, QR LAST. The cursor
# ends below the QR, so the viewport follows it and keeps the QR visible
# regardless of window size. This works in classic conhost and Windows
# Terminal - neither reliably honors programmatic window resize, so we
# solve it by controlling cursor position instead.
# ============================================================================
$syncCompleteMarker = 'C:\Enrollment\sync-complete.txt'

# If running as a scheduled task and sync already completed on a prior run,
# exit immediately. The task stays registered (BUILTIN\Users can't delete
# tasks) but does nothing -- fires at logon, sees marker, exits in <1s.
if ($AsTask -and (Test-Path -LiteralPath $syncCompleteMarker)) {
    try { Stop-Transcript | Out-Null } catch {}
    # [Environment]::Exit forcefully terminates the PowerShell host,
    # defeating -NoExit so the ShopFloor desktop doesn't show a
    # dangling PowerShell window on every logon.
    [Environment]::Exit(0)
}

# PC types that don't receive DSC (no SAS token, no DSCInstall.log).
# For these types, enrollment is "done" once Phase 1 (Identity) completes
# and policies start arriving - there is no Phase 2-5 to wait for.
$pcTypeFile = 'C:\Enrollment\pc-type.txt'
$noDscTypes = @('Display')
$skipDsc = $false
if (Test-Path $pcTypeFile) {
    $pcType = (Get-Content $pcTypeFile -First 1).Trim()
    if ($noDscTypes -contains $pcType) {
        $skipDsc = $true
        Write-Host "PC type '$pcType' does not use DSC - will complete after Phase 1."
    }
}

try {
    $qrText = Build-QRCodeText
    $qrRefreshed = [bool]($qrText -notmatch 'not yet Azure AD joined')
    Invoke-IntuneSync
    $lastSync      = Get-Date
    $nextRetrigger = $lastSync.AddMinutes($RetriggerMinutes)

    while ($true) {
        $snap = Get-Snapshot

        # Retry QR code every cycle until it actually renders. dsregcmd
        # may report AzureAdJoined=YES before DeviceId is populated, so
        # a single-shot refresh misses the window.
        if (-not $qrRefreshed) {
            $qrText = Build-QRCodeText
            $qrRefreshed = [bool]($qrText -notmatch 'not yet Azure AD joined')
        }

        Clear-Host
        Format-Snapshot -Snap $snap -LastSync $lastSync -NextRetrigger $nextRetrigger
        Write-Host ""
        Write-Host $qrText

        # Final state: every phase landed. The gate is intentionally strict
        # because each piece is needed for the device to function:
        #   - DscInstallComplete: device-config.yaml apps + custom scripts ran
        #   - CredsPopulated:     SFLD share creds in HKLM (Machine-Enforce,
        #                         Acrobat-Enforce, CMM-Enforce all need these)
        #   - LockdownComplete:   kiosk policy baseline + Winlogon flipped to
        #                         ShopFloor autologon + admin renamed
        # Display PCs skip this whole branch via $skipDsc above.
        if ($snap.DscInstallComplete -and
            $snap.Phase5.CredsPopulated -and
            $snap.LockdownComplete) {
            Invoke-SetupComplete
        }

        # No-DSC types (Display): wait for lockdown just like DSC types.
        # Display skips Phases 3-5 (no DSC/creds) but still needs the
        # Intune Remediation lockdown to land before we reboot.
        if ($skipDsc -and $snap.LockdownComplete) {
            Invoke-SetupComplete
        }

        # Reboot check (boot-loop-safe). Only prompt once Phase 1 (Identity),
        # Phase 2 (SFLD config), and Phase 3's first two items (deploy log
        # present + pre-reboot deployment complete) are all green. Don't rush
        # the user into rebooting before the enrollment pipeline has finished
        # its pre-reboot work.
        $rebootState = Test-RebootState
        if ($rebootState -eq 'needed') {
            $preRebootReady = (
                $snap.Phase1.AzureAdJoined -and
                $snap.Phase1.IntuneEnrolled -and
                $snap.Phase1.EmTaskExists -and
                $snap.Phase1.PoliciesArriving -and
                $snap.Phase2.SfldRoot -and
                $snap.Phase2.FunctionOk -and
                $snap.Phase2.SasTokenOk -and
                $snap.Phase3.DeployLogExists -and
                $snap.Phase3.DeployComplete
            )
            if ($preRebootReady) {
                Invoke-RebootPrompt
            }
        }

        # Re-trigger sync periodically. Throttle to 15 min once we're
        # only waiting on lockdown -- the 5 min cycle can interrupt the
        # Intune Remediation script mid-execution and delay lockdown.
        $waitingForLockdownOnly = $false
        if (-not $skipDsc) {
            $waitingForLockdownOnly = ($snap.DscInstallComplete -and
                                      $snap.Phase5.CredsPopulated -and
                                      -not $snap.LockdownComplete)
        } else {
            $waitingForLockdownOnly = ($snap.Phase1.AzureAdJoined -and
                                      $snap.Phase1.PoliciesArriving -and
                                      -not $snap.LockdownComplete)
        }
        $currentInterval = if ($waitingForLockdownOnly) { 15 } else { $RetriggerMinutes }

        # If we just entered lockdown-wait and the existing countdown is
        # shorter than 15 min, push it out immediately so we don't fire
        # a stale 5-min retrigger mid-Remediation.
        $minNext = $lastSync.AddMinutes($currentInterval)
        if ($minNext -gt $nextRetrigger) { $nextRetrigger = $minNext }

        if ((Get-Date) -ge $nextRetrigger) {
            Write-Host ""
            Write-Host "Re-triggering Intune sync..." -ForegroundColor Cyan
            Invoke-IntuneSync
            $lastSync      = Get-Date
            $nextRetrigger = $lastSync.AddMinutes($currentInterval)
        }

        Start-Sleep -Seconds $PollSecs
    }
}
catch {
    # Any unhandled exception in the main loop lands here. Write the error
    # into the transcript and then block on a keypress so the PS window
    # doesn't auto-close before the user can read it.
    Write-Host ""
    Write-Host "=====================================================" -ForegroundColor Red
    Write-Host "  UNHANDLED ERROR in Monitor-IntuneProgress.ps1"       -ForegroundColor Red
    Write-Host "=====================================================" -ForegroundColor Red
    Write-Host ""
    Write-Host "Message : $($_.Exception.Message)"       -ForegroundColor Red
    Write-Host "Type    : $($_.Exception.GetType().FullName)" -ForegroundColor Red
    Write-Host "At      : $($_.InvocationInfo.PositionMessage)" -ForegroundColor Red
    Write-Host ""
    Write-Host "Stack trace:" -ForegroundColor Red
    Write-Host $_.ScriptStackTrace -ForegroundColor Red
    Write-Host ""
    Write-Host "Full transcript: $transcriptPath"
    Wait-ForAnyKey
    try { Stop-Transcript | Out-Null } catch {}
    exit 1
}
finally {
    try { Stop-Transcript | Out-Null } catch {}
}
