# Install-FromManifest.ps1 - Generic JSON-manifest runner for shopfloor apps.
#
# Stage 2a extension: adds PS1, BAT, File, Registry, INF action types and
# Always / MarkerFile / ValueMatches / pnputil detection methods. Also adds
# optional PCTypes filtering (entry skipped if current pc-type.txt doesn't
# match any value in the entry's PCTypes array).
#
# Manifest-schema fields parsed-but-not-yet-acted-on (Stage 2b will wire them):
#   ApplyMode, UpdateWindow, InUseCheck. The lib treats all entries as
#   immediate-apply today; Stage 2b adds shift-window gating and
#   close-and-reopen behavior.
#
# Consumers:
#   - GE-Enforce.ps1 (new unified dispatcher, runs via scheduled task with
#     both logon and periodic triggers)
#   - Legacy *-Enforce.ps1 that still point here (will be retired)
#
# Exit codes:
#   0 = every required entry either already satisfied or installed successfully
#   1 = at least one entry failed
#   2 = manifest or installer-root could not be read

param(
    [Parameter(Mandatory=$true)]  [string]$ManifestPath,
    [Parameter(Mandatory=$true)]  [string]$InstallerRoot,
    [Parameter(Mandatory=$true)]  [string]$LogFile,
    [Parameter(Mandatory=$false)] [string]$PCType,
    [Parameter(Mandatory=$false)] [string]$PCSubType
)

$ErrorActionPreference = 'Continue'

# Lib's supported manifest schema version. Bump the MAJOR part whenever a
# manifest field or behavior changes in a way older libs can't handle.
# Bump the MINOR part for additive, backward-compatible additions (new
# optional field, new Type, new DetectionMethod). Manifests tagged with
# a newer MAJOR than the lib get processed best-effort with unknown Types
# logged; manifests tagged with a newer MINOR are fine.
#
# Changelog:
#   2.1 - added TargetHostnames filter (exact + -like wildcards)
#   2.0 - initial Stage 2a: PS1/BAT/File/Registry/INF action types,
#         Always/MarkerFile/ValueMatches/pnputil detection, PCTypes filter
$LIB_MANIFEST_MAJOR = 2
$LIB_MANIFEST_MINOR = 1

$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
    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"
if ($PCType)    { Write-InstallLog "PCType:        $PCType" }
if ($PCSubType) { Write-InstallLog "PCSubType:     $PCSubType" }
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 -or $config.Applications.Count -eq 0) {
    Write-InstallLog 'No Applications in manifest - nothing to do'
    exit 0
}

# ---------------------------------------------------------------------------
# Manifest schema version check. Accepts "X" or "X.Y" form. If the manifest
# declares a MAJOR higher than the lib supports, log a clear warning so the
# operator sees "upgrade the lib on this PC" instead of weird silent
# skip-everything behavior. The lib still tries to run (unknown Types will
# log as ERROR and count as failed, but supported entries work).
# ---------------------------------------------------------------------------
$manifestMajor = 0
$manifestMinor = 0
if ($config.Version) {
    $parts = "$($config.Version)".Split('.')
    if ($parts.Length -ge 1 -and [int]::TryParse($parts[0], [ref]$null)) { $manifestMajor = [int]$parts[0] }
    if ($parts.Length -ge 2 -and [int]::TryParse($parts[1], [ref]$null)) { $manifestMinor = [int]$parts[1] }
}
if ($manifestMajor -gt $LIB_MANIFEST_MAJOR) {
    Write-InstallLog "  Manifest schema version $($config.Version) is newer than this lib (supports $LIB_MANIFEST_MAJOR.$LIB_MANIFEST_MINOR). Unknown Types/fields will be logged as errors. Upgrade the lib on this PC." 'WARN'
} elseif ($manifestMajor -eq $LIB_MANIFEST_MAJOR -and $manifestMinor -gt $LIB_MANIFEST_MINOR) {
    Write-InstallLog "  Manifest schema $($config.Version) is minor-newer than lib $LIB_MANIFEST_MAJOR.$LIB_MANIFEST_MINOR. Should be backward compatible; any unknown Types will be logged." 'INFO'
}

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

# ---------------------------------------------------------------------------
# Detection
# ---------------------------------------------------------------------------
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' {
                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' {
                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)
            }
            'MarkerFile' {
                # Used for one-shot PS1 scripts. Presence of the marker file
                # means the script already ran successfully. The lib writes
                # the marker automatically after a 0 exit on PS1/BAT/CMD.
                if (-not $App.DetectionPath) { return $false }
                return Test-Path $App.DetectionPath
            }
            'ValueMatches' {
                # For Type=Registry entries. Check that the registry value
                # equals the desired RegValue (read from the app entry).
                if (-not (Test-Path $App.DetectionPath)) { return $false }
                if (-not $App.DetectionName) {
                    Write-InstallLog '  ValueMatches detection requires DetectionName' 'WARN'
                    return $false
                }
                $p = Get-ItemProperty -Path $App.DetectionPath -Name $App.DetectionName -ErrorAction SilentlyContinue
                if (-not $p) { return $false }
                return ("$($p.$($App.DetectionName))" -eq "$($App.RegValue)")
            }
            'pnputil' {
                # For INF drivers. Run pnputil /enum-drivers once and grep.
                if (-not $App.DetectionPattern) {
                    Write-InstallLog '  pnputil detection requires DetectionPattern' 'WARN'
                    return $false
                }
                $drivers = & pnputil /enum-drivers 2>&1 | Out-String
                return ($drivers -match $App.DetectionPattern)
            }
            'Always' {
                # Never considered installed - entry runs every cycle.
                # Useful for idempotent scripts like the VNC firewall rule.
                return $false
            }
            default {
                Write-InstallLog "  Unknown detection method: $($App.DetectionMethod)" 'WARN'
                return $false
            }
        }
    } catch {
        Write-InstallLog "  Detection check threw: $_" 'WARN'
        return $false
    }
}

# ---------------------------------------------------------------------------
# Action dispatch
# ---------------------------------------------------------------------------
function Invoke-InstallerAction {
    param($App)

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

    switch ($App.Type) {
        'MSI' {
            $installerPath = Join-Path $InstallerRoot $App.Installer
            if (-not (Test-Path -LiteralPath $installerPath)) {
                Write-InstallLog "  MSI not found: $installerPath" 'ERROR'
                return [pscustomobject]@{ ExitCode = -1; LogRef = $null }
            }
            $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: $installerPath"
            Write-InstallLog "  verbose log: $msiLog"
            $proc = [System.Diagnostics.Process]::Start($psi)
            $proc.WaitForExit()
            return [pscustomobject]@{ ExitCode = $proc.ExitCode; LogRef = $msiLog }
        }
        'EXE' {
            $installerPath = Join-Path $InstallerRoot $App.Installer
            if (-not (Test-Path -LiteralPath $installerPath)) {
                Write-InstallLog "  EXE not found: $installerPath" 'ERROR'
                return [pscustomobject]@{ ExitCode = -1; LogRef = $null }
            }
            $psi.FileName = $installerPath
            if ($App.InstallArgs) { $psi.Arguments = $App.InstallArgs }
            Write-InstallLog "  exe: $installerPath"
            if ($App.InstallArgs) { Write-InstallLog "  args: $($App.InstallArgs)" }
            $proc = [System.Diagnostics.Process]::Start($psi)
            $proc.WaitForExit()
            return [pscustomobject]@{ ExitCode = $proc.ExitCode; LogRef = $App.LogFile }
        }
        { $_ -eq 'CMD' -or $_ -eq 'BAT' } {
            $installerPath = Join-Path $InstallerRoot $App.Installer
            if (-not (Test-Path -LiteralPath $installerPath)) {
                Write-InstallLog "  CMD/BAT not found: $installerPath" 'ERROR'
                return [pscustomobject]@{ ExitCode = -1; LogRef = $null }
            }
            $psi.FileName  = 'cmd.exe'
            $psi.Arguments = "/c `"$installerPath`""
            if ($App.InstallArgs) { $psi.Arguments += " " + $App.InstallArgs }
            Write-InstallLog "  cmd /c $installerPath"
            $proc = [System.Diagnostics.Process]::Start($psi)
            $proc.WaitForExit()
            return [pscustomobject]@{ ExitCode = $proc.ExitCode; LogRef = $App.LogFile }
        }
        'PS1' {
            $scriptPath = Join-Path $InstallerRoot ($App.Script)
            if (-not (Test-Path -LiteralPath $scriptPath)) {
                Write-InstallLog "  PS1 not found: $scriptPath" 'ERROR'
                return [pscustomobject]@{ ExitCode = -1; LogRef = $null }
            }
            $psi.FileName  = 'powershell.exe'
            $psi.Arguments = "-NoProfile -ExecutionPolicy Bypass -File `"$scriptPath`""
            if ($App.Args) { $psi.Arguments += " " + $App.Args }
            Write-InstallLog "  ps1: $scriptPath"
            if ($App.Args) { Write-InstallLog "  args: $($App.Args)" }
            $proc = [System.Diagnostics.Process]::Start($psi)
            $proc.WaitForExit()
            return [pscustomobject]@{ ExitCode = $proc.ExitCode; LogRef = $null }
        }
        'INF' {
            $infPath = Join-Path $InstallerRoot $App.Installer
            if (-not (Test-Path -LiteralPath $infPath)) {
                Write-InstallLog "  INF not found: $infPath" 'ERROR'
                return [pscustomobject]@{ ExitCode = -1; LogRef = $null }
            }
            Write-InstallLog "  pnputil /add-driver `"$infPath`" /install"
            $out = & pnputil /add-driver "$infPath" /install 2>&1 | Out-String
            Write-InstallLog "  pnputil output: $($out.Trim())"
            # pnputil exits 0 on success, 259 if already installed, etc.
            return [pscustomobject]@{ ExitCode = $LASTEXITCODE; LogRef = $null }
        }
        'File' {
            # Copy a file from the share (configs/*) to an absolute on-PC path.
            $source = Join-Path $InstallerRoot $App.Source
            if (-not (Test-Path -LiteralPath $source)) {
                Write-InstallLog "  File source not found: $source" 'ERROR'
                return [pscustomobject]@{ ExitCode = -1; LogRef = $null }
            }
            if (-not $App.Destination) {
                Write-InstallLog '  File entry missing Destination' 'ERROR'
                return [pscustomobject]@{ ExitCode = -1; LogRef = $null }
            }
            $destDir = Split-Path -Parent $App.Destination
            if ($destDir -and -not (Test-Path $destDir)) {
                New-Item -Path $destDir -ItemType Directory -Force | Out-Null
            }
            try {
                Copy-Item -LiteralPath $source -Destination $App.Destination -Force -ErrorAction Stop
                Write-InstallLog "  copied $source -> $($App.Destination)"
                return [pscustomobject]@{ ExitCode = 0; LogRef = $null }
            } catch {
                Write-InstallLog "  Copy failed: $_" 'ERROR'
                return [pscustomobject]@{ ExitCode = 1; LogRef = $null }
            }
        }
        'Registry' {
            # Write a single registry value.
            if (-not $App.RegPath -or -not $App.RegName) {
                Write-InstallLog '  Registry entry missing RegPath/RegName' 'ERROR'
                return [pscustomobject]@{ ExitCode = -1; LogRef = $null }
            }
            if (-not (Test-Path $App.RegPath)) {
                New-Item -Path $App.RegPath -Force | Out-Null
            }
            $type = if ($App.RegType) { $App.RegType } else { 'String' }
            try {
                Set-ItemProperty -Path $App.RegPath -Name $App.RegName -Value $App.RegValue -Type $type -Force -ErrorAction Stop
                Write-InstallLog "  set $($App.RegPath)\$($App.RegName) = $($App.RegValue) ($type)"
                return [pscustomobject]@{ ExitCode = 0; LogRef = $null }
            } catch {
                Write-InstallLog "  Registry write failed: $_" 'ERROR'
                return [pscustomobject]@{ ExitCode = 1; LogRef = $null }
            }
        }
        default {
            Write-InstallLog "  Unsupported Type: $($App.Type)" 'ERROR'
            return [pscustomobject]@{ ExitCode = -2; LogRef = $null }
        }
    }
}

# ---------------------------------------------------------------------------
# Entry-applies filter. An entry applies to this PC only if:
#   - PCTypes is omitted OR matches current pcType/pcType-subType/"*", AND
#   - TargetHostnames is omitted OR matches current COMPUTERNAME
#     (supports exact match and -like wildcards: "WJS-*", "*-SHOP-*").
# Both filters are ANDed so they compose: scope to a type AND a hostname
# subset, or either alone. Case-insensitive throughout.
# ---------------------------------------------------------------------------
function Test-PCTypeMatches {
    param($App, [string]$Type, [string]$SubType)
    if (-not $App.PCTypes -or $App.PCTypes.Count -eq 0) { return $true }
    if (-not $Type) { return $true }
    foreach ($t in $App.PCTypes) {
        if ($t -eq '*') { return $true }
        if ($t -eq $Type) { return $true }
        if ($SubType -and $t -eq "$Type-$SubType") { return $true }
    }
    return $false
}

function Test-HostnameMatches {
    param($App)
    if (-not $App.TargetHostnames -or $App.TargetHostnames.Count -eq 0) { return $true }
    $myName = $env:COMPUTERNAME
    foreach ($h in $App.TargetHostnames) {
        if ($h -ieq $myName)   { return $true }
        if ($myName -ilike $h) { return $true }  # glob patterns: WJS-*, *-SHOP-*
    }
    return $false
}

# ---------------------------------------------------------------------------
# Main loop
# ---------------------------------------------------------------------------
$installed = 0
$skipped   = 0
$failed    = 0
$pcFiltered = 0

foreach ($app in $config.Applications) {
    # Cancel any reboot that a prior MSI queued, so the enforcer never
    # triggers an unexpected restart on a shopfloor PC.
    cmd /c 'shutdown /a 2>nul' *>$null

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

    if (-not (Test-PCTypeMatches -App $app -Type $PCType -SubType $PCSubType)) {
        Write-InstallLog "  PCTypes filter: entry targets $($app.PCTypes -join ',') but PC is $PCType$(if ($PCSubType) { "-$PCSubType" }) - skipping"
        $pcFiltered++
        continue
    }

    if (-not (Test-HostnameMatches -App $app)) {
        Write-InstallLog "  TargetHostnames filter: entry targets $($app.TargetHostnames -join ',') but PC is $env:COMPUTERNAME - skipping"
        $pcFiltered++
        continue
    }

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

    # --- InUseCheck (partial Stage 2b): ForceClose / CloseAndReopen ---
    # Before install, if the entry declares processes that would block the
    # install, close them. Stage 2a supports ForceClose: polite WM_CLOSE
    # with a timeout then hard Kill. "CloseAndReopen" is currently treated
    # the same (Stage 2b will add the user-session relaunch trick). No
    # reopen happens today; operator relaunches the app or the enforcer
    # leaves it for Windows Explorer / Start-Menu shortcut behavior.
    if ($app.InUseCheck -and $app.InUseCheck.Behavior -in @('ForceClose','CloseAndReopen')) {
        foreach ($p in ($app.InUseCheck.Processes | Where-Object { $_ })) {
            $procs = Get-Process -Name $p.Name -ErrorAction SilentlyContinue
            foreach ($proc in $procs) {
                $timeout = if ($p.GracefulCloseTimeoutSec) { [int]$p.GracefulCloseTimeoutSec } else { 10 }
                try {
                    Write-InstallLog "  InUseCheck: $($p.Name) (PID $($proc.Id)) asked to close (timeout ${timeout}s)"
                    $null = $proc.CloseMainWindow()
                    if (-not $proc.WaitForExit([int]($timeout * 1000))) {
                        Write-InstallLog "  InUseCheck: $($p.Name) did not exit gracefully - killing" 'WARN'
                        $proc.Kill()
                        $proc.WaitForExit(5000) | Out-Null
                    }
                } catch {
                    Write-InstallLog "  InUseCheck: close/kill of $($p.Name) threw: $_" 'WARN'
                }
            }
        }
    }

    $result = Invoke-InstallerAction -App $app
    $rc     = $result.ExitCode

    if ($rc -eq 0 -or $rc -eq 1641 -or $rc -eq 3010 -or $rc -eq 259) {
        Write-InstallLog "  Exit $rc - SUCCESS"
        if ($rc -eq 3010) { Write-InstallLog "  (Reboot pending for $($app.Name))" }
        if ($rc -eq 1641) { Write-InstallLog "  (Installer initiated a reboot for $($app.Name))" }
        if ($rc -eq 259)  { Write-InstallLog '  (pnputil: no newer driver found - considered installed)' }
        $installed++

        # Auto-write marker file for MarkerFile-detected entries that just
        # completed successfully. Keeps one-shot PS1 scripts from running
        # twice (idempotent scripts can skip this by using Always detection).
        if ($app.DetectionMethod -eq 'MarkerFile' -and $app.DetectionPath) {
            $markerDir = Split-Path -Parent $app.DetectionPath
            if ($markerDir -and -not (Test-Path $markerDir)) {
                New-Item -Path $markerDir -ItemType Directory -Force | Out-Null
            }
            try {
                Set-Content -Path $app.DetectionPath -Value (Get-Date -Format 'o') -ErrorAction Stop
                Write-InstallLog "  marker written: $($app.DetectionPath)"
            } catch {
                Write-InstallLog "  marker write failed: $_" 'WARN'
            }
        }
    }
    else {
        Write-InstallLog "  Exit $rc - FAILED" 'ERROR'

        if ($result.LogRef -and (Test-Path $result.LogRef)) {
            if ($app.Type -eq 'MSI') {
                Write-InstallLog "  --- meaningful lines from $($result.LogRef) ---"
                $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 $result.LogRef -Pattern $regex -ErrorAction SilentlyContinue | Select-Object -First 30
                if ($matches) {
                    foreach ($m in $matches) { Write-InstallLog "    $($m.Line.Trim())" }
                } else {
                    Get-Content $result.LogRef -Tail 25 -ErrorAction SilentlyContinue | ForEach-Object { Write-InstallLog "    $_" }
                }
                Write-InstallLog '  --- end MSI log scan ---'
            } else {
                Write-InstallLog "  --- last 30 lines of $($result.LogRef) ---"
                Get-Content $result.LogRef -Tail 30 -ErrorAction SilentlyContinue | ForEach-Object { Write-InstallLog "    $_" }
                Write-InstallLog '  --- end installer log tail ---'
            }
        }

        $failed++
    }
}

Write-InstallLog '============================================'
Write-InstallLog "Install-FromManifest complete: $installed installed, $skipped skipped, $failed failed, $pcFiltered pc-filtered"
Write-InstallLog '============================================'

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

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