# 00-PreInstall-MachineApps.ps1 - Install pre-staged apps from C:\PreInstall (Shopfloor baseline)
#
# Reads C:\PreInstall\preinstall.json (staged by startnet.cmd during WinPE phase) and
# installs each app whose PCTypes filter matches the current PC type. Detection runs first
# so already-installed apps are skipped. Numbered 00- so it runs before all other Shopfloor
# baseline scripts - apps need to exist before later scripts (e.g. 01-eDNC.ps1) reference
# them or rely on their state.
#
# Failure mode: log + continue. Intune DSC (Simple-Install.ps1) is the safety net for any
# install that fails here.
#
# Logs to C:\Logs\PreInstall\install.log (wiped each run).

$preInstallDir = "C:\PreInstall"
$jsonPath      = Join-Path $preInstallDir "preinstall.json"
$installerDir  = Join-Path $preInstallDir "installers"
$logDir        = "C:\Logs\PreInstall"
$logFile       = Join-Path $logDir "install.log"

# --- Inline site-config reader (same pattern as Setup-OpenText.ps1; this script
# runs from C:\Enrollment\shopfloor-setup\Shopfloor\ but Get-PCProfile.ps1 may
# not be available yet, so keep the lookup self-contained) ---
function Get-SiteConfig {
    $configPath = 'C:\Enrollment\site-config.json'
    if (-not (Test-Path $configPath)) { return $null }
    try {
        return Get-Content $configPath -Raw | ConvertFrom-Json
    } catch {
        return $null
    }
}

# --- Setup logging ---
# IMPORTANT: do NOT wipe the log on each run. The runner can get killed by an
# installer-triggered reboot mid-execution. On the next autologon, the dispatcher
# re-runs us - if we wipe the log here, we destroy the forensic record from the
# previous (interrupted) run. Instead, append a session header so runs are visible
# but history is preserved.
if (-not (Test-Path $logDir)) {
    New-Item -Path $logDir -ItemType Directory -Force | Out-Null
}

function Write-PreInstallLog {
    param(
        [Parameter(Mandatory=$true, Position=0)]
        [string]$Message,
        [Parameter(Position=1)]
        [ValidateSet('INFO','WARN','ERROR')]
        [string]$Level = 'INFO'
    )
    $stamp = Get-Date -Format "yyyy-MM-dd HH:mm:ss"
    $line  = "[$stamp] [$Level] $Message"
    Write-Host $line

    # Synchronous write-through so each line hits disk immediately, even if a system
    # reboot kills the process right after this call returns. Bypasses the OS write
    # cache via FileOptions.WriteThrough.
    try {
        $fs = New-Object System.IO.FileStream(
            $logFile,
            [System.IO.FileMode]::Append,
            [System.IO.FileAccess]::Write,
            [System.IO.FileShare]::Read,
            4096,
            [System.IO.FileOptions]::WriteThrough
        )
        $bytes = [System.Text.Encoding]::UTF8.GetBytes($line + "`r`n")
        $fs.Write($bytes, 0, $bytes.Length)
        $fs.Flush()
        $fs.Dispose()
    }
    catch {
        # Last-resort fallback if the FileStream open fails for any reason
        Add-Content -Path $logFile -Value $line -ErrorAction SilentlyContinue
    }
}

# Session header so multiple runs are visually distinguishable in the log
Write-PreInstallLog "================================================================"
Write-PreInstallLog "=== Runner session start (PID $PID) ==="
Write-PreInstallLog "================================================================"

# --- Bail early if no preinstall bundle staged ---
if (-not (Test-Path $jsonPath)) {
    Write-PreInstallLog "No preinstall.json at $jsonPath - skipping (no bundle staged for this PC)."
    exit 0
}

# --- Read PCTYPE and optional PCSUBTYPE from C:\Enrollment\ ---
$pcTypeFile = "C:\Enrollment\pc-type.txt"
if (-not (Test-Path $pcTypeFile)) {
    Write-PreInstallLog "No pc-type.txt at $pcTypeFile - skipping" "WARN"
    exit 0
}
$pcType = (Get-Content $pcTypeFile -First 1).Trim()
if (-not $pcType) {
    Write-PreInstallLog "pc-type.txt is empty - skipping" "WARN"
    exit 0
}

$pcSubtype = ''
$subtypeFile = "C:\Enrollment\pc-subtype.txt"
if (Test-Path $subtypeFile) {
    $pcSubtype = (Get-Content $subtypeFile -First 1 -ErrorAction SilentlyContinue).Trim()
}
$pcProfileKey = if ($pcSubtype) { "$pcType-$pcSubtype" } else { $pcType }
Write-PreInstallLog "PC type: $pcType$(if ($pcSubtype) { " (subtype: $pcSubtype, profile: $pcProfileKey)" })"

# --- Parse JSON ---
try {
    $config = Get-Content $jsonPath -Raw | ConvertFrom-Json
} catch {
    Write-PreInstallLog "Failed to parse $jsonPath : $_" "ERROR"
    exit 0
}

if (-not $config.Applications) {
    Write-PreInstallLog "No Applications in preinstall.json"
    exit 0
}

Write-PreInstallLog "Staged installer dir: $installerDir"
Write-PreInstallLog "Found $($config.Applications.Count) app entries in preinstall.json"

# --- Site-name override: replace 'West Jefferson' in InstallArgs if
#     site-config says otherwise. UDC_Setup.exe expects the spaced site
#     name in quotes as arg 1, with a dash-prefixed machine number as arg 2.
$siteConfig = Get-SiteConfig
if ($siteConfig -and $siteConfig.siteName -and $siteConfig.siteName -ne 'West Jefferson') {
    Write-PreInstallLog "Site config loaded - siteName: $($siteConfig.siteName)"
    foreach ($app in $config.Applications) {
        if ($app.InstallArgs -and $app.InstallArgs -match 'West Jefferson') {
            $app.InstallArgs = $app.InstallArgs -replace 'West Jefferson', $siteConfig.siteName
            Write-PreInstallLog "  Overrode site name in $($app.Name) args: $($app.InstallArgs)"
        }
    }
} else {
    Write-PreInstallLog "No site-config override for siteName (using defaults in preinstall.json)"
}

# --- Machine-number override: replace "9999" in UDC InstallArgs if tech entered a number ---
$machineNumFile = 'C:\Enrollment\machine-number.txt'
if (Test-Path -LiteralPath $machineNumFile) {
    $machineNum = (Get-Content -LiteralPath $machineNumFile -First 1 -ErrorAction SilentlyContinue).Trim()
    if ($machineNum -and $machineNum -ne '9999' -and $machineNum -match '^\d+$') {
        Write-PreInstallLog "Machine number from PXE menu: $machineNum"
        foreach ($app in $config.Applications) {
            if ($app.InstallArgs -and $app.InstallArgs -match '9999') {
                $app.InstallArgs = $app.InstallArgs -replace '9999', $machineNum
                Write-PreInstallLog "  Overrode machine number in $($app.Name) args: $($app.InstallArgs)"
            }
        }
    } else {
        Write-PreInstallLog "Machine number: $machineNum (default placeholder)"
    }
} else {
    Write-PreInstallLog "No machine-number.txt found (using 9999 default)"
}

# --- Pre-stage UDC settings backup if available. UDC_Setup.exe tries to
#     pull udc_settings_<num>.json from \\tsgwp00525\shared\SPC\UDC\Settings_Backups\,
#     but at imaging time that share is unreachable (no SFLD creds yet).
#     We carry a local mirror of Settings_Backups on the PXE server at
#     Y:\pre-install\udc-backups\. Pre-creating C:\ProgramData\UDC\udc_settings.json
#     before UDC_Setup.exe runs means the installer's File.Copy (overwrite:true)
#     would overwrite it IF the share were reachable, but since it isn't, our
#     pre-staged file survives and UDC launches with correct settings.
if ($machineNum -and $machineNum -ne '9999') {
    $udcBackupDir = 'C:\Enrollment\shopfloor-setup\Standard\udc-backups'
    $udcBackup    = Join-Path $udcBackupDir "udc_settings_$machineNum.json"
    $udcTarget    = 'C:\ProgramData\UDC\udc_settings.json'
    if (Test-Path -LiteralPath $udcBackup) {
        if (-not (Test-Path 'C:\ProgramData\UDC')) {
            New-Item -Path 'C:\ProgramData\UDC' -ItemType Directory -Force | Out-Null
        }
        Copy-Item -Path $udcBackup -Destination $udcTarget -Force
        Write-PreInstallLog "Pre-staged UDC settings from $udcBackup -> $udcTarget"
    } else {
        Write-PreInstallLog "No UDC settings backup for machine $machineNum in $udcBackupDir"
    }
}

# --- Pre-create Windows Firewall rules for UDC + MTConnect Agent so the
#     installer doesn't pop firewall-allow dialogs during silent install.
#     Rules are idempotent (New-NetFirewallRule -ErrorAction SilentlyContinue
#     on existing rule name is a no-op in practice; we remove-then-add).
$fwRules = @(
    @{ Name = 'UDC';              Program = 'C:\Program Files\UDC\UDC.exe' },
    @{ Name = 'UDC MTConnect Agent'; Program = 'C:\ProgramData\UDC\MTConnect_UDC\Agent\agent.exe' }
)
foreach ($r in $fwRules) {
    try {
        Remove-NetFirewallRule -DisplayName $r.Name -ErrorAction SilentlyContinue
        New-NetFirewallRule -DisplayName $r.Name -Direction Inbound -Program $r.Program `
            -Action Allow -Profile Any -ErrorAction Stop | Out-Null
        Write-PreInstallLog "Firewall rule created: $($r.Name) -> $($r.Program)"
    } catch {
        Write-PreInstallLog "Firewall rule '$($r.Name)' failed: $_" "WARN"
    }
}

# --- Detection helper (mirrors Simple-Install.ps1's Test-ApplicationInstalled) ---
function Test-AppInstalled {
    param($App)

    if (-not $App.DetectionMethod) {
        return $false
    }

    try {
        switch ($App.DetectionMethod) {
            "Registry" {
                if (-not (Test-Path $App.DetectionPath)) {
                    return $false
                }
                if ($App.DetectionName) {
                    $value = Get-ItemProperty -Path $App.DetectionPath -Name $App.DetectionName -ErrorAction SilentlyContinue
                    if (-not $value) {
                        return $false
                    }
                    if ($App.DetectionValue) {
                        return ($value.$($App.DetectionName) -eq $App.DetectionValue)
                    }
                    return $true
                }
                return $true
            }
            "File" {
                return Test-Path $App.DetectionPath
            }
            default {
                Write-PreInstallLog "  Unknown detection method: $($App.DetectionMethod)" "WARN"
                return $false
            }
        }
    } catch {
        Write-PreInstallLog "  Detection check threw: $_" "WARN"
        return $false
    }
}

# --- Iterate apps ---
$installed = 0
$skipped   = 0
$failed    = 0

foreach ($app in $config.Applications) {
    # Cancel any reboot a previous installer scheduled (some respect /norestart, some
    # don't - VC++ 2008's bootstrapper sometimes triggers an immediate Windows reboot
    # despite the flag). Doing this BEFORE each install protects the rest of the loop.
    cmd /c "shutdown /a 2>nul" *>$null

    Write-PreInstallLog "==> $($app.Name)"

    # Filter by PCTypes - matches on wildcard, base type, or composite type-subtype key
    $allowedTypes = @($app.PCTypes)
    $matchesType  = ($allowedTypes -contains "*") -or ($allowedTypes -contains $pcType) -or ($pcProfileKey -ne $pcType -and $allowedTypes -contains $pcProfileKey)
    if (-not $matchesType) {
        Write-PreInstallLog "  PCTypes filter excludes '$pcProfileKey' (allowed: $($allowedTypes -join ', ')) - skipping"
        $skipped++
        continue
    }

    # Detection check
    if (Test-AppInstalled -App $app) {
        Write-PreInstallLog "  Already installed - skipping"
        $skipped++
        continue
    }

    # Locate installer file
    $installerPath = Join-Path $installerDir $app.Installer
    if (-not (Test-Path $installerPath)) {
        Write-PreInstallLog "  Installer file not found: $installerPath" "ERROR"
        $failed++
        continue
    }

    Write-PreInstallLog "  Installing from $installerPath"
    if ($app.InstallArgs) {
        Write-PreInstallLog "  InstallArgs: $($app.InstallArgs)"
    }

    # Per-app verbose msiexec log path - written by /L*v on MSI installs and tailed
    # into the runner log on failure so we don't need to dig through C:\Logs manually.
    $safeName = $app.Name -replace '[^a-zA-Z0-9]','_'
    $msiLog   = Join-Path $logDir "msi-$safeName.log"
    if (Test-Path $msiLog) { Remove-Item $msiLog -Force -ErrorAction SilentlyContinue }

    try {
        # We use [System.Diagnostics.Process]::Start() directly instead of Start-Process
        # because PowerShell 5.1's Start-Process -PassThru has a bug where the returned
        # Process object's OS handle is disposed when control returns to the script,
        # causing ExitCode to always read as $null even after WaitForExit() succeeds.
        # Calling Process.Start() directly returns a Process object with a live handle
        # that exposes ExitCode correctly. We poll HasExited so UDC_Setup.exe (which
        # hangs forever after spawning a hidden WPF window) can be killed via the
        # detection-during-install path.
        $psi = New-Object System.Diagnostics.ProcessStartInfo
        $psi.UseShellExecute = $false
        $psi.CreateNoWindow  = $true
        $psi.WindowStyle     = [System.Diagnostics.ProcessWindowStyle]::Hidden

        if ($app.Type -eq "MSI") {
            $psi.FileName  = "msiexec.exe"
            $psi.Arguments = "/i `"$installerPath`""
            if ($app.InstallArgs) {
                $psi.Arguments += " " + $app.InstallArgs
            }
            $psi.Arguments += " /L*v `"$msiLog`""
            Write-PreInstallLog "  msiexec verbose log: $msiLog"
        }
        elseif ($app.Type -eq "EXE") {
            $psi.FileName = $installerPath
            if ($app.InstallArgs) {
                $psi.Arguments = $app.InstallArgs
            }
            # If the JSON entry specifies a LogFile (per-installer log written by
            # the EXE itself - e.g. Setup-OpenText.ps1 -> C:\Logs\PreInstall\Setup-
            # OpenText.log), surface it on failure the same way we surface the
            # msiexec /L*v verbose log for MSI installs. Lets EXE wrappers actually
            # report what went wrong inside.
            if ($app.LogFile) {
                Write-PreInstallLog "  Installer log: $($app.LogFile)"
            }
        }
        else {
            Write-PreInstallLog "  Unsupported Type: $($app.Type) - skipping" "ERROR"
            $failed++
            continue
        }

        $proc = [System.Diagnostics.Process]::Start($psi)

        # Poll for completion: process exit OR detection success (whichever happens first)
        $timeoutSec   = 600   # 10 min hard cap per app
        $pollInterval = 5
        $elapsed      = 0
        $killedAfterDetect = $false

        while ($elapsed -lt $timeoutSec) {
            if ($proc.HasExited) { break }

            # Detection-during-install kill is OPT-IN via "KillAfterDetection: true"
            # in the JSON entry. It's only safe for installers that hang forever after
            # creating their detection markers (UDC_Setup.exe spawns a hidden WPF window
            # and never exits). For normal installers (Oracle, msiexec, etc.) the
            # detection registry key often gets written midway through the install AND
            # msiexec is still doing post-install cleanup AND msiserver is still holding
            # the install mutex - killing msiexec at that point leaves the system in a
            # bad state and the NEXT msiexec call returns 1618 ERROR_INSTALL_ALREADY_
            # RUNNING. So opt-out by default; only kill apps that explicitly need it.
            if ($app.KillAfterDetection -and (Test-AppInstalled -App $app)) {
                Write-PreInstallLog "  Detection passed at $elapsed s - killing installer to advance (KillAfterDetection)"
                try { $proc.Kill(); $proc.WaitForExit(5000) | Out-Null } catch { }

                # UDC's installer auto-launches UDC.exe silently; it's UDC.exe
                # (not UDC_Setup) that actually writes udc_settings.json from
                # the CLI args. Killing it immediately after the detection
                # marker appears loses the MachineNumber write. Wait (up to
                # 15s) for settings.json to reflect the target number, then
                # kill. Works uniformly for tech-typed numbers and the 9999
                # placeholder - whichever was passed as arg 2 is what we
                # expect to see persist.
                if ($app.Name -eq "UDC") {
                    $udcJson   = 'C:\ProgramData\UDC\udc_settings.json'
                    $targetNum = $null
                    if ($app.InstallArgs -match '-(\d+)\s*$') { $targetNum = $matches[1] }
                    $waitSec = 0
                    while ($waitSec -lt 15) {
                        if (Test-Path $udcJson) {
                            try {
                                $mn = (Get-Content $udcJson -Raw -ErrorAction Stop |
                                       ConvertFrom-Json).GeneralSettings.MachineNumber
                                if ($mn -and $mn -eq $targetNum) {
                                    Write-PreInstallLog "  UDC persisted MachineNumber=$mn after $waitSec s"
                                    break
                                }
                            } catch { }
                        }
                        Start-Sleep -Seconds 1
                        $waitSec++
                    }
                    if ($waitSec -ge 15) {
                        Write-PreInstallLog "  UDC did not persist MachineNumber within 15 s - killing anyway" "WARN"
                    }
                    Get-Process UDC -ErrorAction SilentlyContinue | ForEach-Object {
                        try { $_.Kill(); $_.WaitForExit(2000) | Out-Null } catch { }
                    }
                }

                $killedAfterDetect = $true
                break
            }

            Start-Sleep -Seconds $pollInterval
            $elapsed += $pollInterval
        }

        if (-not $proc.HasExited -and -not $killedAfterDetect) {
            Write-PreInstallLog "  TIMEOUT after $timeoutSec seconds - killing installer" "ERROR"
            try { $proc.Kill() } catch { }
            if ($app.Name -eq "UDC") {
                Get-Process UDC -ErrorAction SilentlyContinue | ForEach-Object {
                    try { $_.Kill() } catch { }
                }
            }
            $failed++
            continue
        }

        if ($killedAfterDetect) {
            Write-PreInstallLog "  SUCCESS (verified via detection during install)"
            $installed++
        }
        else {
            # Start-Process -PassThru returns a Process object whose ExitCode is null
            # until WaitForExit() is called - even if HasExited is already true. Without
            # this, every install that goes through the exit-code branch gets reported
            # as "Exit code  - FAILED" (empty) regardless of the real result.
            try { $proc.WaitForExit() } catch { }
            $exitCode = $proc.ExitCode

            if ($exitCode -eq 0 -or $exitCode -eq 3010) {
                Write-PreInstallLog "  Exit code $exitCode after $elapsed s - SUCCESS"
                if ($exitCode -eq 3010) {
                    Write-PreInstallLog "  (Reboot required for $($app.Name))"
                }
                $installed++
            }
            else {
                Write-PreInstallLog "  Exit code $exitCode - FAILED" "ERROR"

                # If the JSON entry specifies a LogFile for an EXE install (e.g.
                # Setup-OpenText.ps1 writes its own log at C:\Logs\PreInstall\Setup-
                # OpenText.log), tail it here so the runner log surfaces the actual
                # cause without us having to dig through C:\Logs manually.
                if ($app.Type -eq "EXE" -and $app.LogFile -and (Test-Path $app.LogFile)) {
                    Write-PreInstallLog "  --- last 30 lines of $($app.LogFile) ---"
                    Get-Content $app.LogFile -Tail 30 -ErrorAction SilentlyContinue | ForEach-Object {
                        Write-PreInstallLog "    $_"
                    }
                    Write-PreInstallLog "  --- end installer log tail ---"
                }

                # Surface the *meaningful* lines from the verbose msiexec log (not just
                # the tail, which is rollback/cleanup noise). The actual root-cause lines
                # are: MSI error notes (Note: 1: <code>), failed action returns (return
                # value 3), errors logged by custom actions, and any line containing the
                # word "Error". Grep these out so the runner log shows the smoking gun.
                if ($app.Type -eq "MSI" -and (Test-Path $msiLog)) {
                    Write-PreInstallLog "  --- meaningful lines from $msiLog ---"
                    $patterns = @(
                        'Note: 1: ',
                        'return value 3',
                        'Error \d+\.',
                        'CustomAction .* returned actual error',
                        'Failed to ',
                        'Installation failed',
                        'Calling custom action',
                        '1: 2262',  # Database not found
                        '1: 2203',  # Cannot open database
                        '1: 2330'   # Insufficient permissions
                    )
                    $regex = ($patterns -join '|')
                    $matches = Select-String -Path $msiLog -Pattern $regex -ErrorAction SilentlyContinue |
                               Select-Object -First 30
                    if ($matches) {
                        foreach ($m in $matches) {
                            Write-PreInstallLog "    $($m.Line.Trim())"
                        }
                    } else {
                        Write-PreInstallLog "    (no error patterns matched - falling back to last 25 lines)"
                        Get-Content $msiLog -Tail 25 -ErrorAction SilentlyContinue | ForEach-Object {
                            Write-PreInstallLog "    $_"
                        }
                    }
                    Write-PreInstallLog "  --- end MSI log scan ---"
                }
                $failed++
            }
        }
    } catch {
        Write-PreInstallLog "  Install threw: $_" "ERROR"
        $failed++
    }
}

Write-PreInstallLog "============================================"
Write-PreInstallLog "PreInstall complete: $installed installed, $skipped skipped, $failed failed"
Write-PreInstallLog "============================================"

# Final reboot cancel - if the last installer in the loop scheduled one, the
# dispatcher's later `shutdown /a` won't fire until the next baseline script starts.
# Cancel here so control returns cleanly to Run-ShopfloorSetup.ps1.
cmd /c "shutdown /a 2>nul" *>$null

exit 0
