# Install-FromManifest.ps1 - Generic JSON-manifest installer for Keyence apps.
#
# Forked from CMM/lib/Install-FromManifest.ps1 with two additions to support
# the Keyence USB driver:
#   - DetectionMethod "pnputil": matches pnputil /enum-drivers output against
#     a regex (manifest field: DetectionPattern).
#   - Type "INF": installs a driver package via pnputil /add-driver /install.
#
# Every Keyence manifest entry applies to Keyence PCs (no PCTypes filter;
# Keyence-Enforce.ps1 only runs on Keyence PCs by virtue of where the
# scheduled task is registered).
#
# Called from:
#   - 09-Setup-Keyence.ps1 at imaging time with
#       InstallerRoot=<C:\Enrollment\shopfloor-setup\Keyence>
#   - Keyence-Enforce.ps1 on logon with InstallerRoot=<mounted tsgwp00525 share>
#
# Returns via exit code: 0 if every required app is either already installed
# or installed successfully; non-zero if any install failed.

param(
    [Parameter(Mandatory=$true)]
    [string]$ManifestPath,

    [Parameter(Mandatory=$true)]
    [string]$InstallerRoot,

    [Parameter(Mandatory=$true)]
    [string]$LogFile
)

$ErrorActionPreference = 'Continue'

$logDir = Split-Path -Parent $LogFile
if (-not (Test-Path $logDir)) {
    New-Item -Path $logDir -ItemType Directory -Force | Out-Null
}

function Write-InstallLog {
    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, mirroring
    # the preinstall runner's approach - protects forensic trail if an installer
    # triggers a reboot mid-loop.
    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 {
        Add-Content -Path $LogFile -Value $line -ErrorAction SilentlyContinue
    }
}

Write-InstallLog "================================================================"
Write-InstallLog "=== Install-FromManifest session start (PID $PID) ==="
Write-InstallLog "Manifest:      $ManifestPath"
Write-InstallLog "InstallerRoot: $InstallerRoot"
Write-InstallLog "================================================================"

if (-not (Test-Path -LiteralPath $ManifestPath)) {
    Write-InstallLog "Manifest not found: $ManifestPath" "ERROR"
    exit 2
}

if (-not (Test-Path -LiteralPath $InstallerRoot)) {
    Write-InstallLog "InstallerRoot not found: $InstallerRoot" "ERROR"
    exit 2
}

try {
    $config = Get-Content -LiteralPath $ManifestPath -Raw | ConvertFrom-Json
} catch {
    Write-InstallLog "Failed to parse manifest: $_" "ERROR"
    exit 2
}

if (-not $config.Applications) {
    Write-InstallLog "No Applications in manifest - nothing to do"
    exit 0
}

Write-InstallLog "Manifest lists $($config.Applications.Count) app(s)"

# Detection helper - mirrors the preinstall runner's logic. Registry path +
# optional value name + optional exact value. The exact-value compare is how
# version-pinned drift detection works: bumping DetectionValue in the manifest
# makes the current install "fail" detection and reinstall.
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
            }
            "FileVersion" {
                # Compare a file's VersionInfo.FileVersion against the
                # manifest's expected value. Used for version-pinned MSI/EXE
                # installs where existence alone doesn't tell you whether
                # the right release is on disk. Exact string match - the
                # manifest must carry the exact version the vendor stamps
                # into the binary.
                if (-not (Test-Path $App.DetectionPath)) { return $false }
                if (-not $App.DetectionValue) {
                    Write-InstallLog "  FileVersion detection requires DetectionValue - treating as not installed" "WARN"
                    return $false
                }
                $actual = (Get-Item $App.DetectionPath -ErrorAction Stop).VersionInfo.FileVersion
                if (-not $actual) { return $false }
                return ($actual -eq $App.DetectionValue)
            }
            "Hash" {
                # Compare SHA256 of the on-disk file against the manifest's
                # expected value. Used for content-versioned files that do not
                # expose a DisplayVersion (secrets like eMxInfo.txt). Bumping
                # DetectionValue in the manifest and replacing the file on the
                # share is the entire update workflow.
                if (-not (Test-Path $App.DetectionPath)) { return $false }
                if (-not $App.DetectionValue) {
                    Write-InstallLog "  Hash detection requires DetectionValue - treating as not installed" "WARN"
                    return $false
                }
                $actual = (Get-FileHash -Path $App.DetectionPath -Algorithm SHA256 -ErrorAction Stop).Hash
                return ($actual -ieq $App.DetectionValue)
            }
            "pnputil" {
                # Driver package detection via `pnputil /enum-drivers`. The
                # DetectionPattern is a regex matched against the full output;
                # a hit on the original INF filename (e.g. 'keyence_vr_series\.inf')
                # means the package is staged in the DriverStore.
                if (-not $App.DetectionPattern) {
                    Write-InstallLog "  pnputil detection requires DetectionPattern - treating as not installed" "WARN"
                    return $false
                }
                try {
                    $enum = & pnputil.exe /enum-drivers 2>&1 | Out-String
                    return ($enum -match $App.DetectionPattern)
                } catch {
                    Write-InstallLog "  pnputil /enum-drivers failed: $_" "WARN"
                    return $false
                }
            }
            default {
                Write-InstallLog "  Unknown detection method: $($App.DetectionMethod)" "WARN"
                return $false
            }
        }
    } catch {
        Write-InstallLog "  Detection check threw: $_" "WARN"
        return $false
    }
}

$installed = 0
$skipped   = 0
$failed    = 0

foreach ($app in $config.Applications) {
    # Cancel any pending reboot scheduled by a previous installer, same as the
    # preinstall runner. Some Burn bundles schedule a reboot even with /norestart
    # (chained bootstrapper ignores the flag for some internal prereqs).
    cmd /c "shutdown /a 2>nul" *>$null

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

    if (Test-AppInstalled -App $app) {
        Write-InstallLog "  Already installed at expected version - skipping"
        $skipped++
        continue
    }

    $installerPath = Join-Path $InstallerRoot $app.Installer
    if (-not (Test-Path -LiteralPath $installerPath)) {
        Write-InstallLog "  Installer file not found: $installerPath" "ERROR"
        $failed++
        continue
    }

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

    try {
        $psi = New-Object System.Diagnostics.ProcessStartInfo
        $psi.UseShellExecute = $false
        $psi.CreateNoWindow  = $true
        $psi.WindowStyle     = [System.Diagnostics.ProcessWindowStyle]::Hidden

        if ($app.Type -eq "MSI") {
            $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 }

            $psi.FileName  = "msiexec.exe"
            $psi.Arguments = "/i `"$installerPath`""
            if ($app.InstallArgs) { $psi.Arguments += " " + $app.InstallArgs }
            $psi.Arguments += " /L*v `"$msiLog`""
            Write-InstallLog "  msiexec verbose log: $msiLog"
        }
        elseif ($app.Type -eq "EXE") {
            $psi.FileName = $installerPath
            if ($app.InstallArgs) { $psi.Arguments = $app.InstallArgs }
            if ($app.LogFile) {
                Write-InstallLog "  Installer log: $($app.LogFile)"
            }
        }
        elseif ($app.Type -eq "INF") {
            # Driver package: stage to Windows DriverStore via pnputil. The
            # /install flag binds the driver to any matching hardware currently
            # present; drivers without a bound device still persist in the
            # store and attach when hardware is plugged in later.
            $psi.FileName  = "pnputil.exe"
            $psi.Arguments = "/add-driver `"$installerPath`" /install"
        }
        else {
            Write-InstallLog "  Unsupported Type: $($app.Type) - skipping" "ERROR"
            $failed++
            continue
        }

        # Use Process.Start directly rather than Start-Process because PS 5.1's
        # Start-Process -PassThru disposes the process handle when control returns,
        # making ExitCode read as $null. Direct Process.Start gives us a live handle.
        $proc = [System.Diagnostics.Process]::Start($psi)

        # No per-app timeout here - PC-DMIS bundles can run 20+ minutes on slow
        # disks and we don't want to kill them mid-chain. The calling script
        # controls overall session timing.
        $proc.WaitForExit()
        $exitCode = $proc.ExitCode

        # Burn and MSI exit codes:
        #   0    success
        #   1641 success, reboot initiated
        #   3010 success, reboot pending
        if ($exitCode -eq 0 -or $exitCode -eq 1641 -or $exitCode -eq 3010) {
            Write-InstallLog "  Exit code $exitCode - SUCCESS"
            if ($exitCode -eq 3010) { Write-InstallLog "  (Reboot pending for $($app.Name))" }
            if ($exitCode -eq 1641) { Write-InstallLog "  (Installer initiated a reboot for $($app.Name))" }
            $installed++
        }
        else {
            Write-InstallLog "  Exit code $exitCode - FAILED" "ERROR"

            if ($app.Type -eq "EXE" -and $app.LogFile -and (Test-Path $app.LogFile)) {
                Write-InstallLog "  --- last 30 lines of $($app.LogFile) ---"
                Get-Content $app.LogFile -Tail 30 -ErrorAction SilentlyContinue | ForEach-Object {
                    Write-InstallLog "    $_"
                }
                Write-InstallLog "  --- end installer log tail ---"
            }

            if ($app.Type -eq "MSI" -and $msiLog -and (Test-Path $msiLog)) {
                Write-InstallLog "  --- meaningful lines from $msiLog ---"
                $patterns = @(
                    'Note: 1: ',
                    'return value 3',
                    'Error \d+\.',
                    'CustomAction .* returned actual error',
                    'Failed to ',
                    'Installation failed',
                    '1: 2262',
                    '1: 2203',
                    '1: 2330'
                )
                $regex = ($patterns -join '|')
                $matches = Select-String -Path $msiLog -Pattern $regex -ErrorAction SilentlyContinue |
                           Select-Object -First 30
                if ($matches) {
                    foreach ($m in $matches) { Write-InstallLog "    $($m.Line.Trim())" }
                } else {
                    Get-Content $msiLog -Tail 25 -ErrorAction SilentlyContinue | ForEach-Object {
                        Write-InstallLog "    $_"
                    }
                }
                Write-InstallLog "  --- end MSI log scan ---"
            }

            $failed++
        }
    } catch {
        Write-InstallLog "  Install threw: $_" "ERROR"
        $failed++
    }
}

Write-InstallLog "============================================"
Write-InstallLog "Install-FromManifest complete: $installed installed, $skipped skipped, $failed failed"
Write-InstallLog "============================================"

cmd /c "shutdown /a 2>nul" *>$null

if ($failed -gt 0) { exit 1 }
exit 0
