# Install-ShopdbPrinterDrivers.ps1
#
# Installs a SITE'S WHOLE DRIVER SET from a manifest, so a bay ends up with every
# printer driver it might need in one converging run. Wraps
# Install-ShopdbPrinterDriver.ps1, which does one driver.
#
# DESIGNED FOR DSC / Intune / GE-Enforce. It declares state rather than
# performing an install: a driver already present is skipped, so this is safe to
# run on a schedule and cheap when there is nothing to do. That is what lets a
# DSC Script resource call it from TestScript as well as SetScript.
#
# THE MANIFEST, not arguments, is the contract. drivers.json lists each driver by
# the name its INF declares - what Add-PrinterDriver matches on, verbatim - and
# where its package lives. Paths are relative to this script, or absolute (a UNC
# path on a site's share is normal).
#
# EXIT CODE: 0 when every driver in the manifest is present at the end, 1 when
# one or more could not be installed. DSC needs a real answer here, unlike the
# single-driver script which never fails an enforcement run. The per-driver log
# says which and why.
#
# SHARE PATHS: on a GE-Enforce site the packages usually live on the SFLD share,
# which is mounted ONLY during the enforcement cycle. Run this as a manifest
# entry inside that cycle, not as its own scheduled task.
#
# WAVES, because a driver package is not a manifest check. GE-Enforce gives each
# PC a start offset of SHA256(hostname) % 5 MINUTES and then repeats every 5
# minutes, which was sized for reading a few KB of JSON. A driver set is 100 MB
# for the two universals and 226 MB for a full site, so the day an entry lands
# the whole fleet pulls it inside one 5-minute window: roughly 30 GB across 300
# bays, on the same share every bay needs for everything else. The failure is not
# slow drivers, it is a floor that stops converging.
#
# -WaveStart with -Waves spreads that out. Each PC derives its own wave from its
# hostname and does nothing until its turn, so there is no central coordination,
# no per-bay configuration and nothing to reconcile afterwards. The hash is the
# same idiom Register-GEEnforce.ps1 uses for its offset, SHA-256 rather than MD5
# because FIPS-enforced bays disable MD5 outright.
#
# A bay switched off during its wave installs on its next cycle instead. Late is
# a non-event here; the wave sets the earliest moment, not a deadline.
#
# Unparseable wave arguments FAIL CLOSED - nothing installs, and it says so every
# cycle. Failing open would restore precisely the stampede this exists to avoid,
# and 30 GB cannot be un-sent, whereas a typo that installs nothing is loud in
# the log and fixed in a minute.

param(
    # Defaults to drivers.json beside this script.
    [string]$Manifest = '',

    # Install only these driver names; everything else in the manifest is
    # ignored. For a bay that needs one driver out of a site-wide set.
    [string[]]$Only = @(),

    # Report what is missing and change nothing. This is what a DSC TestScript
    # calls: exit 0 means compliant. A bay whose wave has not arrived is
    # compliant BY DESIGN - not installed is its desired state today.
    [switch]$TestOnly,

    # Date the rollout opens, e.g. '2026-08-25' or '2026-08-25 22:00'. Empty
    # means no gating at all, which is right for imaging time: a bay being built
    # is one bay, and it should come off the line complete.
    [string]$WaveStart = '',

    # How many waves to spread the fleet across. 0 or 1 means everyone at once.
    # 300 bays over 10 daily waves is ~30 bays and ~3 GB a day.
    [int]$Waves = 0,

    # Wave spacing. Hours finishes a 10-wave rollout inside a day; Days is the
    # cautious setting when nobody is watching the share.
    [ValidateSet('Days', 'Hours')]
    [string]$WaveUnit = 'Days',

    # Pilot bays skip the gate. Use this to prove one bay before the fleet.
    [switch]$IgnoreWave
)

$ErrorActionPreference = 'Continue'

$here = Split-Path -Parent $MyInvocation.MyCommand.Path
if (-not $Manifest) { $Manifest = Join-Path $here 'drivers.json' }

$logDir = 'C:\Logs\Shopfloor'
if (-not (Test-Path $logDir)) {
    New-Item -ItemType Directory -Path $logDir -Force -ErrorAction SilentlyContinue | Out-Null
}
$logFile = Join-Path $logDir ('printer-drivers-{0}.log' -f (Get-Date -Format 'yyyyMMdd'))
function Log([string]$msg) {
    $ts = Get-Date -Format 'yyyy-MM-dd HH:mm:ss'
    "$ts [set] $msg" | Tee-Object -FilePath $logFile -Append | Out-Null
}

# The gate runs BEFORE the manifest is read, because the manifest lives on the
# share too. A bay that is not due this wave must not touch the share at all -
# reading it is cheap, but 300 bays deciding to read it in the same 5 minutes is
# how this whole problem starts.
if ($WaveStart -and -not $IgnoreWave) {
    $start = [datetime]::MinValue
    if (-not [datetime]::TryParse($WaveStart, [ref]$start)) {
        Log "ERROR -WaveStart '$WaveStart' is not a date. Refusing to install."
        Log '  (failing closed on purpose: guessing here would release the whole fleet at once)'
        exit 1
    }

    $waveCount = $Waves
    if ($waveCount -lt 1) { $waveCount = 1 }

    # Same hostname hash as the enforcer's own start offset, so a bay's wave is
    # stable for the life of its name: it cannot drift between cycles, and a
    # rerun never moves a PC into a different wave.
    $hostHash = [System.BitConverter]::ToUInt32(
        [System.Security.Cryptography.SHA256]::Create().ComputeHash(
            [System.Text.Encoding]::UTF8.GetBytes([System.Environment]::MachineName)), 0)
    $wave = [int]($hostHash % [uint32]$waveCount)

    if ($WaveUnit -eq 'Hours') { $due = $start.AddHours($wave) }
    else                       { $due = $start.AddDays($wave) }

    if ((Get-Date) -lt $due) {
        Log ("wave {0} of {1} for {2}; not due until {3}. Nothing to do." -f `
            $wave, $waveCount, [System.Environment]::MachineName,
            $due.ToString('yyyy-MM-dd HH:mm'))
        exit 0
    }
    Log ("wave {0} of {1} opened {2}; proceeding" -f `
        $wave, $waveCount, $due.ToString('yyyy-MM-dd HH:mm'))
}

if (-not (Test-Path $Manifest)) {
    Log "ERROR manifest not found: $Manifest"
    exit 1
}

try {
    $config = Get-Content -Raw -Path $Manifest | ConvertFrom-Json
} catch {
    Log "ERROR manifest is not valid JSON: $($_.Exception.Message)"
    exit 1
}

$wanted = @($config.drivers)
if ($Only.Count -gt 0) {
    $wanted = @($wanted | Where-Object { $Only -contains $_.drivername })
}
if ($wanted.Count -eq 0) {
    Log "nothing to do: the manifest selects no drivers"
    exit 0
}

$single = Join-Path $here 'Install-ShopdbPrinterDriver.ps1'
if (-not (Test-Path $single)) {
    Log "ERROR Install-ShopdbPrinterDriver.ps1 is not beside this script"
    exit 1
}

$missing = @()
foreach ($driver in $wanted) {
    $name = $driver.drivername
    if (-not $name) { continue }

    if (Get-PrinterDriver -Name $name -ErrorAction SilentlyContinue) {
        Log "present: $name"
        continue
    }

    if ($TestOnly) {
        Log "MISSING: $name"
        $missing += $name
        continue
    }

    # Relative paths are resolved against the package, so the whole thing can be
    # copied anywhere - a share, C:\ProgramData, an Intune staging folder - and
    # still find its own payloads.
    $path = $driver.path
    if ($path -and -not [System.IO.Path]::IsPathRooted($path)) {
        $path = Join-Path $here $path
    }
    if (-not $path -or -not (Test-Path $path)) {
        Log "ERROR package not found for '$name': $path"
        $missing += $name
        continue
    }

    Log "installing: $name"
    & $single -DriverName $name -Source $path | Out-Null

    if (Get-PrinterDriver -Name $name -ErrorAction SilentlyContinue) {
        Log "installed: $name"
    } else {
        Log "FAILED: $name (see the per-driver lines above)"
        $missing += $name
    }
}

if ($missing.Count -gt 0) {
    Log ("not present: {0}" -f ($missing -join ', '))
    exit 1
}
Log "all $($wanted.Count) driver(s) present"
exit 0
