# 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 = '',
    # -PostPpkg: invoked immediately after PPKG install completes, before
    # PPKG's auto-reboot fires. Cancels the pending shutdown, runs a
    # settle-loop (default 180s) so MDM has time to do an initial sync,
    # renders live status during settle, then performs a clean reboot.
    # The persistent @logon sync_intune task takes over after reboot.
    [switch]$PostPpkg,
    # -PostPpkgSettleSec: how long to wait before the clean reboot when
    # in -PostPpkg mode. 60s empirically gives MDM enough time to push
    # the baseline policy (4 -> ~30 PolicyManager subkeys) so when techs
    # see sync_intune resume after reboot, the readiness signals are
    # already meaningful instead of "policy still pulling".
    [int]$PostPpkgSettleSec = 60
)

# ============================================================================
# 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
    DeviceId                 = $null
    DeviceIdReported         = $false
    LockdownCompletePushed   = $false
    InternetAccessDeleted    = $false
}

# Load Send-PxeStatus at SCRIPT scope (not inside a function). A dot-source
# inside a function defines the imported function in that function's local
# scope only, so callers from other functions can't see it and Get-Command
# Send-PxeStatus returns nothing. Loading at the top of the script puts
# Send-PxeStatus in script scope where every function can find it.
$pxeStatusLib = Join-Path $PSScriptRoot 'Send-PxeStatus.ps1'
if (Test-Path $pxeStatusLib) {
    try { . $pxeStatusLib } catch { Write-Warning "Send-PxeStatus dot-source failed: $_" }
}
function Ensure-SendPxeStatus {
    # Kept as a no-op shim for any callers that still invoke it. Real load
    # happens at script init above.
}

function Get-Phase1 {
    # Re-run dsregcmd while EITHER AzureAdJoined OR DeviceId still missing.
    # Previously the dsregcmd call was nested under -not AzureAdJoined alone;
    # once AAD joined the block stopped running, but DeviceId only appears in
    # dsregcmd output AFTER AzureAdJoined flips, so the capture never fired.
    # Bay-side Build-QRCodeText calls dsregcmd each render which is why the
    # on-screen QR works but the dashboard QR did not.
    if (-not $script:cache.AzureAdJoined -or -not $script:cache.DeviceId) {
        try {
            $dsreg = dsregcmd /status 2>&1
            if (-not $script:cache.AzureAdJoined -and $dsreg -match 'AzureAdJoined\s*:\s*YES') {
                $script:cache.AzureAdJoined = $true
            }
            if (-not $script:cache.DeviceId -and $dsreg -match 'DeviceId\s*:\s*([0-9a-fA-F-]{30,})') {
                $script:cache.DeviceId = $matches[1]
            }
        } catch {}
    }

    # Report IP log presence drives two independent actions that USED to be
    # bundled inside the DeviceId-push gate. Splitting them so re-enable
    # fires even if DeviceId hasn't been captured yet (e.g. AAD join lag,
    # dsregcmd parse miss):
    #
    # 1. Re-enable wired NICs as soon as the log lands + state file exists.
    # 2. Push idx=7 once DeviceId is captured AND the log exists.
    $reportIpLog = Get-ChildItem -Path 'C:\Logs\GE_Report_IP_Address*' -ErrorAction SilentlyContinue |
                   Select-Object -First 1
    $nicListFile = 'C:\Enrollment\disabled-wired-nics.txt'
    $justReEnabled = $false
    if ($reportIpLog -and (Test-Path $nicListFile)) {
        try {
            $nicNames = Get-Content $nicListFile -ErrorAction Stop
            foreach ($n in $nicNames) {
                if ([string]::IsNullOrWhiteSpace($n)) { continue }
                try { Enable-NetAdapter -Name $n -Confirm:$false -ErrorAction Stop }
                catch { Write-Warning "Enable-NetAdapter '$n' failed: $_" }
            }
            # Wait for DHCP renewal + route table update + reachability to
            # PXE server. 1 second wasn't enough in field testing - the
            # subsequent idx=7 push fired into the void before the wired
            # NIC was carrying traffic.
            Start-Sleep -Seconds 5
            Remove-Item $nicListFile -Force -ErrorAction SilentlyContinue
            $justReEnabled = $true
        } catch {
            Write-Warning "Re-enable wired NICs failed: $_"
        }
    }

    # Push DeviceId / idx=7 once, when both DeviceId is captured and the
    # Report IP log has landed (dashboard QR renders from DeviceId).
    # Retry up to 6x with backoff because the imminent LAPS-prompt reboot
    # gives us only seconds and the wired NIC may still be settling.
    if ($script:cache.DeviceId -and -not $script:cache.DeviceIdReported -and $reportIpLog) {
        Ensure-SendPxeStatus
        if (Get-Command Send-PxeStatus -ErrorAction SilentlyContinue) {
            $attempts = if ($justReEnabled) { 6 } else { 1 }
            for ($i = 0; $i -lt $attempts; $i++) {
                $err = $null
                try {
                    Send-PxeStatus -Stage 'Monitor-IntuneProgress: Intune Device ID captured' `
                                   -StageIndex 7 -StageTotal 8 `
                                   -IntuneDeviceId $script:cache.DeviceId -ErrorAction Stop
                    $script:cache.DeviceIdReported = $true
                    break
                } catch { $err = $_ }
                if ($i -lt $attempts - 1) { Start-Sleep -Seconds 2 }
            }
            if (-not $script:cache.DeviceIdReported -and $err) {
                Write-Warning "idx=7 push failed after $attempts attempts: $err"
            }
        }
    }

    # Lockdown-applied auto-completion. Fleet-wide reality: bays use a LOCAL
    # ShopFloor account, so AzureAdPrt stays NO and user-scoped Intune policies
    # never deliver. The natural completion gate (baseline policies >= 5, DSC
    # Installation completed successfully, all Phase 4 wrappers) therefore
    # never closes - leaves the dashboard stuck at 7/8 = 87.5% forever.
    #
    # Real-world definition of "done" for these bays is lockdown applied:
    # Winlogon DefaultUserName flipped to ShopFloor + AutoAdminLogon=1, OR
    # the force-lockdown marker has been written by sfld_autologon.ps1.
    # When either signal is present, push idx=8 / status=succeeded so the
    # dashboard card flips to 100% green.
    if (-not $script:cache.LockdownCompletePushed) {
        $lockdownApplied = $false
        try {
            $wl = Get-ItemProperty 'HKLM:\SOFTWARE\Microsoft\Windows NT\CurrentVersion\Winlogon' -ErrorAction Stop
            if ([string]$wl.AutoAdminLogon -eq '1' -and $wl.DefaultUserName -like 'ShopFloor*') {
                $lockdownApplied = $true
            }
        } catch {}
        if (-not $lockdownApplied -and (Test-Path 'C:\Enrollment\force-lockdown-applied.txt')) {
            $lockdownApplied = $true
        }
        if ($lockdownApplied) {
            Ensure-SendPxeStatus
            if (Get-Command Send-PxeStatus -ErrorAction SilentlyContinue) {
                try {
                    $params = @{
                        Stage       = 'Monitor-IntuneProgress: lockdown applied (imaging complete)'
                        StageIndex  = 8
                        StageTotal  = 8
                        Status      = 'succeeded'
                    }
                    if ($script:cache.DeviceId) { $params.IntuneDeviceId = $script:cache.DeviceId }
                    Send-PxeStatus @params
                    $script:cache.LockdownCompletePushed = $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 {}
    }

    # Two-level policy-arrival signal:
    #   PoliciesArriving       = >0  subkeys (Intune started pushing baseline)
    #   PoliciesBaselineReady  = >=5 subkeys (MDM has done its first real
    #                                 incremental sync past the raw post-PPKG
    #                                 baseline of 4)
    # Empirical thresholds from 5-stage snapshot data:
    #   01-post-ppkg  ~3-4 subkeys (raw enrollment, MDM hasn't synced yet)
    #   02-pre-rb-2   ~30 subkeys  (after first reboot + 10-20 min sync wait)
    #   04-pre-lockd  ~70 subkeys  (post-category-driven payload)
    # Threshold was originally 15 but fleet evidence shows many PCs stall at
    # 3 subkeys for long periods (initial sync blocked / slow). 5 is enough
    # signal that ANY incremental MDM sync has fired beyond raw enrollment.
    $subkeyCount = 0
    $policiesArriving      = $false
    $policiesBaselineReady = $false
    try {
        $children = Get-ChildItem 'HKLM:\SOFTWARE\Microsoft\PolicyManager\current\device' -ErrorAction SilentlyContinue
        $subkeyCount = ($children | Measure-Object).Count
        $policiesArriving      = ($subkeyCount -gt 0)
        $policiesBaselineReady = ($subkeyCount -ge 5)
    } catch {}

    # Once Intune registration is fully landed (AAD-joined + Intune-enrolled
    # + EnterpriseMgmt task present + baseline policies arrived), three
    # things must happen together:
    #   1. Delete INTERNETACCESS WiFi profile (gets bay off 172.16.x)
    #   2. Connect AESFMA (gets bay onto corp 10.x via EAP-TLS - cert is
    #      already in LocalMachine\My thanks to Intune SCEP)
    #   3. Push idx=7 to the PXE dashboard with the captured DeviceId so
    #      the dashboard card shows the QR for the Intune device id.
    # All three fire in one shot per Monitor lifetime via cache flags.
    $phase1Essential = ($script:cache.AzureAdJoined -and
                        $script:cache.IntuneEnrolled -and
                        $script:cache.EmTaskExists -and
                        $policiesBaselineReady)
    if ($phase1Essential -and -not $script:cache.InternetAccessDeleted) {
        try {
            Write-Host "Intune registration complete - deleting INTERNETACCESS profile + reconnecting to AESFMA..."
            $delOut = netsh wlan delete profile name="INTERNETACCESS" 2>&1 | Out-String
            Write-Host $delOut
            Start-Sleep -Seconds 2
            $conOut = netsh wlan connect name="AESFMA" ssid="AESFMA" 2>&1 | Out-String
            Write-Host $conOut
            $script:cache.InternetAccessDeleted = $true
        } catch {
            Write-Warning "WiFi swap (INTERNETACCESS -> AESFMA) failed: $_"
        }
    }
    if ($phase1Essential -and $script:cache.DeviceId -and -not $script:cache.DeviceIdReported) {
        if (Get-Command Send-PxeStatus -ErrorAction SilentlyContinue) {
            try {
                Send-PxeStatus -Stage 'Monitor-IntuneProgress: Intune registration complete' `
                               -StageIndex 7 -StageTotal 8 `
                               -IntuneDeviceId $script:cache.DeviceId -ErrorAction Stop
                $script:cache.DeviceIdReported = $true
                Write-Host "Pushed idx=7 with DeviceId $($script:cache.DeviceId)."
            } catch {
                Write-Warning "idx=7 Send-PxeStatus failed: $_"
            }
        }
    }

    return @{
        AzureAdJoined         = $script:cache.AzureAdJoined
        IntuneEnrolled        = $script:cache.IntuneEnrolled
        EmTaskExists          = $script:cache.EmTaskExists
        PoliciesArriving      = $policiesArriving
        PoliciesBaselineReady = $policiesBaselineReady
        PolicySubkeyCount     = $subkeyCount
        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"
    #
    # IME logs are append-only - they never rotate or clear. Earlier logic
    # used `-match` against the whole raw file, which stayed true forever
    # once success was ever recorded, even if the policy later changed and
    # the current state drifted. The fix below:
    #   (a) Walks lines from END to find the MOST RECENT outcome per log
    #       (latest attempt wins, stale successes are ignored)
    #   (b) Cross-checks Winlogon registry for ground truth - catches the
    #       case where IME logs say success but the reg state got rolled
    #       back. All three must agree for Complete to be true.
    $imeLogs = 'C:\ProgramData\Microsoft\IntuneManagementExtension\Logs'

    # --- Remediation: scan from tail for the LATEST result line
    $remLog = Join-Path $imeLogs 'Autologon_Remediation.log'
    $remDone = $false
    if (Test-Path $remLog) {
        try {
            $lines = Get-Content $remLog -ErrorAction Stop
            for ($i = $lines.Count - 1; $i -ge 0; $i--) {
                $ln = $lines[$i]
                if ($ln -match 'Autologon set for ShopFloor') {
                    $remDone = $true
                    break
                }
                if ($ln -match 'Autologon could not be set|Remediation failed|FATAL|Exception') {
                    $remDone = $false
                    break
                }
            }
        } catch {}
    }

    # --- Detection: scan from tail for the LATEST "matches the expected value: <N>"
    $detLog = Join-Path $imeLogs 'Autologon_Detection.log'
    $detDone = $false
    if (Test-Path $detLog) {
        try {
            $lines = Get-Content $detLog -ErrorAction Stop
            for ($i = $lines.Count - 1; $i -ge 0; $i--) {
                if ($lines[$i] -match 'matches the expected value:\s*(\d+)') {
                    $detDone = ($matches[1] -eq '1')
                    break
                }
            }
        } catch {}
    }

    # --- Ground truth: does Winlogon actually reflect ShopFloor autologon?
    # This catches the case where IME logs report historical success but the
    # reg state has since been changed back (manual tech intervention, policy
    # rollback, GPO override, etc).
    $registryMatches = $false
    try {
        $wl = Get-ItemProperty 'HKLM:\SOFTWARE\Microsoft\Windows NT\CurrentVersion\Winlogon' -ErrorAction Stop
        $registryMatches = (
            [string]$wl.AutoAdminLogon -eq '1' -and
            $wl.DefaultUserName -and
            $wl.DefaultUserName -like 'ShopFloor*'
        )
    } catch {}

    # --- Manual override marker written by Force-Lockdown.bat.
    # When a tech runs sfld_autologon.ps1 directly (vendor-documented escape
    # hatch when the Intune push hasn't applied within ~30 minutes), only
    # Winlogon flips - the IME Remediation log never gets a new success
    # entry because Detection now passes and Remediation isn't invoked.
    # Without this marker, the strict (rem AND det AND reg) gate stays
    # false forever even though the device is fully locked down.
    $forceMarker  = 'C:\Enrollment\force-lockdown-applied.txt'
    $forceApplied = (Test-Path -LiteralPath $forceMarker)

    return @{
        RemediationApplied = $remDone
        DetectionPassed    = $detDone
        RegistryMatches    = $registryMatches
        ForceApplied       = $forceApplied
        # Two paths to Complete:
        #   1. Normal Intune-driven: all three signals present (closes the
        #      append-only-log false-positive AND rollback-without-log case).
        #   2. Manual escape hatch:  marker file + ground-truth registry
        #      match. Marker is the audit-trail substitute for the missing
        #      Remediation log entry; registry is still the gate.
        Complete           = (
            ($remDone -and $detDone -and $registryMatches) -or
            ($forceApplied -and $registryMatches)
        )
    }
}

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)

    # Cache identity strings so we don't re-poll WMI on every redraw (Win32_BIOS
    # is cheap but Get-CimInstance still adds ~50ms per call). Filled on first
    # call, reused thereafter.
    if (-not $script:cache.Hostname) {
        try { $script:cache.Hostname = [System.Environment]::MachineName } catch { $script:cache.Hostname = $env:COMPUTERNAME }
    }
    if (-not $script:cache.SerialNumber) {
        try {
            $sn = (Get-CimInstance Win32_BIOS -ErrorAction Stop).SerialNumber
            if ($sn) { $script:cache.SerialNumber = $sn.Trim() }
        } catch { $script:cache.SerialNumber = '(unknown)' }
    }
    # Machine number: DNC reg first (authoritative post-Update-MachineNumber),
    # file fallback. Treats '9999' as unset (placeholder used during early
    # imaging). Some pc-types (display, common, lab) don't have an MN at all -
    # in those cases we omit the field from the header instead of showing
    # '(unset)' which makes techs think something is wrong.
    $machineNumber = $null
    try {
        foreach ($rp in @(
            'HKLM:\SOFTWARE\WOW6432Node\GE Aircraft Engines\Dnc\General',
            'HKLM:\SOFTWARE\GE Aircraft Engines\Dnc\General')) {
            if (Test-Path $rp) {
                $v = (Get-ItemProperty -Path $rp -Name MachineNo -ErrorAction SilentlyContinue).MachineNo
                if ($v -and $v -ne '9999') { $machineNumber = "$v"; break }
            }
        }
        if (-not $machineNumber -and (Test-Path 'C:\Enrollment\machine-number.txt')) {
            $f = (Get-Content 'C:\Enrollment\machine-number.txt' -First 1 -ErrorAction SilentlyContinue).Trim()
            if ($f -and $f -ne '9999') { $machineNumber = $f }
        }
    } catch {}

    $lines = @()
    $lines += ""
    $lines += "  GE Aerospace  --  Shopfloor Device Setup"
    $lines += ""
    if ($machineNumber) {
        $lines += ("  Hostname: {0}    Serial: {1}    MN: {2}" -f $script:cache.Hostname, $script:cache.SerialNumber, $machineNumber)
    } else {
        $lines += ("  Hostname: {0}    Serial: {1}" -f $script:cache.Hostname, $script:cache.SerialNumber)
    }
    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
    # "Done" = baseline policy delivered (>=15 PolicyManager\current\device subkeys),
    # not just "arriving". Stops the category prompt firing pre-first-reboot
    # when only ~4 subkeys are present (we tested this empirically; clicking
    # "assign category" at 4 subkeys = imaging stalls + re-image required).
    # Report IP log presence is part of Phase 1 completion. Without that log
    # we know GE's Proactive-Remediation script hasn't fired on WiFi-only
    # yet, which means the SFLD ConfigurationProfile assignment filter still
    # sees a leaked 10.9.100.x IP and Phase 2 won't unblock. Don't call
    # registration "done" until Report IP has cleared.
    $reportIpDone = [bool](Get-ChildItem -Path 'C:\Logs\GE_Report_IP_Address*' -ErrorAction SilentlyContinue | Select-Object -First 1)
    $p1Done = ($Snap.Phase1.AzureAdJoined -and $Snap.Phase1.IntuneEnrolled -and
               $Snap.Phase1.EmTaskExists -and $Snap.Phase1.PoliciesBaselineReady -and
               $reportIpDone)
    $p1Status = Get-PhaseStatus @(
        @{ Ok = $Snap.Phase1.AzureAdJoined;          Failed = $false },
        @{ Ok = $Snap.Phase1.IntuneEnrolled;          Failed = $false },
        @{ Ok = $Snap.Phase1.EmTaskExists;            Failed = $false },
        @{ Ok = $Snap.Phase1.PoliciesBaselineReady;   Failed = $false },
        @{ Ok = $reportIpDone;                        Failed = $false }
    )

    # Phase 6 / Lockdown (shared by both flows, rendered last).
    # RegistryMatches is the ground-truth check - if it flips to false after a
    # historical success, something rolled back Winlogon and lockdown has drifted.
    # If the manual Force-Lockdown marker is present, treat the row as COMPLETE
    # to match the Get-LockdownState gate (avoids the table reading IN PROGRESS
    # while the script is about to advance to setup-complete on this same tick).
    if ($Snap.Phase6.Complete) {
        $p6Status = 'COMPLETE'
    } else {
        $p6Status = Get-PhaseStatus @(
            @{ Ok = $Snap.Phase6.RemediationApplied; Failed = $false },
            @{ Ok = $Snap.Phase6.DetectionPassed;    Failed = $false },
            @{ Ok = $Snap.Phase6.RegistryMatches;    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 }
        )
        # Phase 4 (per-script Application Install) was retired 2026-04-28:
        # apps now deploy via the GE-Enforce manifest engine off
        # \\tsgwp00525\...\machineapps\, gated on AESFMA WiFi + S: mount
        # which both happen post-reboot. The monitor's responsibility ends
        # at "DSC bootstrap + creds + lockdown landed, time to reboot."
        # The manifest engine takes over from there on the next ShopFloor
        # logon.

        $p4Done = ($Snap.Phase5.ConsumeCredsTask -and $Snap.Phase5.CredsPopulated)
        $p4Status = 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 ($Snap.Phase1.PoliciesArriving -and -not $Snap.Phase1.PoliciesBaselineReady) {
            $cnt = $Snap.Phase1.PolicySubkeyCount
            Write-Host ("     >> Wait - policy still pulling ({0}/5 subkeys). Do NOT assign category yet." -f $cnt) -ForegroundColor DarkYellow
        }
        if ($p1Done -and -not $p2Done) {
            Write-Host '     >> READY: 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. Credential Setup       ' -NoNewline; Format-StatusTag $p4Status; Write-Host ''
        # Mid-flow reboot prompt: DSC deployment finished, install phase requires
        # a reboot to advance. Test-RebootState returns 'needed' only when
        # DSCDeployment.log was modified after last boot (boot-loop-safe).
        $rebootHint = Test-RebootState
        if ($rebootHint -eq 'needed' -and $p6Status -ne 'COMPLETE') {
            Write-Host '     >> REBOOT NOW to advance install phase' -ForegroundColor Red
        }
        if ($p4Done -and $p6Status -ne 'COMPLETE' -and $rebootHint -ne 'needed') {
            Write-Host '     >> READY: Initiate ARTS Lockdown request' -ForegroundColor Yellow
        }
        Write-Host '  5. 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 ($Snap.Phase1.PoliciesArriving -and -not $Snap.Phase1.PoliciesBaselineReady) {
            $cnt = $Snap.Phase1.PolicySubkeyCount
            Write-Host ("     >> Wait - policy still pulling ({0}/5 subkeys). Do NOT assign category yet." -f $cnt) -ForegroundColor DarkYellow
        }
        if ($p1Done -and -not $p2DisplayDone) {
            Write-Host '     >> READY: 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 '     >> READY: 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: $_"
        }

        # Final dashboard tick: idx=8 / status=succeeded. Marks the session
        # 100% on /imaging. Belt-and-braces re-load of helper in case the
        # main script's dot-source got lost across the DSC reboot.
        Ensure-SendPxeStatus
        if (Get-Command Send-PxeStatus -ErrorAction SilentlyContinue) {
            try {
                Send-PxeStatus -Stage 'Monitor-IntuneProgress: imaging complete' `
                               -StageIndex 8 -StageTotal 8 -Status 'succeeded' `
                               -IntuneDeviceId $script:cache.DeviceId
            } catch { }
        }

        # 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
}

# ============================================================================
# Post-PPKG settle mode: cancel pending shutdown, settle for N seconds while
# rendering live status, then perform a clean reboot. Persistent @logon
# sync_intune scheduled task takes over after reboot.
# ============================================================================
if ($PostPpkg) {
    Write-Host ""
    Write-Host "=== POST-PPKG SETTLE MODE ===" -ForegroundColor Cyan
    Write-Host "Cancelling any pending PPKG-scheduled shutdown..."
    cmd /c "shutdown /a 2>nul" | Out-Null
    Start-Sleep -Seconds 1

    # Aggressive sync triggering: hammer Schedule #3 every 30s during settle.
    # Intune's natural sync interval is 8h baseline, dropping to ~3min after
    # an explicit Schedule #3 trigger - but only if the device has done its
    # first real sync. PPKG-fresh devices need a manual nudge before any
    # automatic interval is even running. Forcing a trigger every 30s
    # accelerates the 4 -> 5+ subkey transition that gates "ready for category".
    $endTime         = (Get-Date).AddSeconds($PostPpkgSettleSec)
    $nextSyncTrigger = Get-Date
    $syncInterval    = [TimeSpan]::FromSeconds(30)
    $triggerCount    = 0

    $earlyExit = $false
    while ((Get-Date) -lt $endTime) {
        $now = Get-Date
        if ($now -ge $nextSyncTrigger) {
            Invoke-IntuneSync
            $triggerCount++
            $nextSyncTrigger = $now.Add($syncInterval)
        }
        $remaining = [int]($endTime - $now).TotalSeconds
        Clear-Host
        $snap = Get-Snapshot
        Format-Snapshot -Snap $snap -LastSync $now -NextRetrigger $endTime | Out-Null
        Write-Host ""
        Write-Host ("  POST-PPKG SETTLE  -  rebooting in {0,3}s   (Ctrl+C to abort)" -f $remaining) -ForegroundColor Yellow
        Write-Host ("  Sync triggers fired: {0}   subkeys: {1}" -f $triggerCount, $snap.Phase1.PolicySubkeyCount)
        if ($snap.Phase1.PoliciesBaselineReady) {
            Write-Host "  Subkeys past baseline (>=5) - settle complete, rebooting early." -ForegroundColor Green
            $earlyExit = $true
            break
        }
        Write-Host "  Tech: do NOT touch the PC until reboot fires."
        Start-Sleep -Seconds 5
    }

    Write-Host ""
    Write-Host "Settle complete. Performing clean reboot..." -ForegroundColor Green
    try { Stop-Transcript | Out-Null } catch {}
    cmd /c "shutdown /a 2>nul" | Out-Null
    Start-Sleep -Seconds 1
    shutdown /r /t 5 /c "Post-PPKG settle complete - rebooting"
    [Environment]::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', 'gea-shopfloor-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)

    $filteredReportIpSucceeded = $false
    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')
        }

        # Filtered Report IP shim - POSTs this bay's GE corp IP to the
        # Tines webhook directly with a tight subnet filter. Fires each
        # tick until it succeeds once (script's own status-file dedup
        # prevents duplicate POSTs). Bypasses the timing window where
        # GE's Intune-deployed Report IP misses (it may run before the
        # SCEP cert + AESFMA join land).
        if (-not $filteredReportIpSucceeded) {
            $filteredScript = Join-Path $PSScriptRoot 'Invoke-FilteredReportIP.ps1'
            if (Test-Path $filteredScript) {
                $rc = & powershell.exe -NoProfile -ExecutionPolicy Bypass -File $filteredScript 2>&1 | Out-Null
                if ($LASTEXITCODE -eq 0) { $filteredReportIpSucceeded = $true }
            }
        }

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

        # Final state: every phase landed. Apps are no longer in the gate -
        # they deploy via the GE-Enforce manifest engine post-reboot, gated
        # on AESFMA WiFi + S: drive mount which only happen after we hand
        # off to the ShopFloor autologon session. So this gate is now:
        #   - CredsPopulated:   SFLD share creds in HKLM (Machine-Enforce,
        #                       Acrobat-Enforce, CMM-Enforce, manifest
        #                       engine all need these)
        #   - LockdownComplete: kiosk policy baseline + Winlogon flipped to
        #                       ShopFloor autologon + admin renamed
        # DscInstallComplete used to be required, but device-config.yaml
        # is becoming a no-op as apps migrate to the manifest engine; a
        # 403 on the YAML download (e.g. expired SAS token) shouldn't
        # block the imaging workflow if creds + lockdown are otherwise OK.
        # Display PCs skip this whole branch via $skipDsc above.
        if ($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.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 {}
}
