# Set-ShopdbPrinters.ps1
#
# Makes this PC's printers match what ShopDB says the bay should have. Asks
# GET /api/printers/for-host/<hostname> and creates any queue that is missing.
#
# WHY THE ASSIGNMENT IS NOT ON THIS PC: it is on the MACHINE, and reaches
# whichever PC controls it. A reimaged or swapped box inherits the bay's printers
# with nothing saved off the old one - the asset register is the backup.
#
# CONVERGES, does not install. A queue that already exists is left alone, so this
# is cheap to run every enforcement cycle and safe to run twice.
#
# NEVER REMOVES A QUEUE. If a printer disappears from the response - because the
# API had a bad minute, or someone unassigned it - the bay keeps printing. Taking
# printers away from a working bay because of a transient error is the one
# failure this must not have.
#
# DRIVERS ARE NOT FETCHED HERE. Install-ShopdbPrinterDrivers.ps1 stages the site's
# set in the common scope, once per bay. A queue is created against a driver that
# is already present; if it is not, that is logged and the printer is skipped,
# because downloading 48 MB while somebody waits to print is the wrong moment.
#
# THE DEFAULT PRINTER IS PER USER. This runs as SYSTEM and cannot set it for the
# logged-on person, so it records the desired default in HKLM and leaves applying
# it to a logon task. Without that, SYSTEM would set a default nobody sees.
#
# IT ALSO REGISTERS THAT LOGON TASK, and stages a LOCAL copy of
# Apply-ShopdbDefaultPrinter.ps1 for it to run. Recording a default that nothing
# ever applies was the gap: the queues appeared, the default never moved. The
# local copy is not tidiness - the share this script runs from is mounted only
# for the enforcement cycle, and the task fires at logon when it is gone.
#
# Exits 0 always: a printer problem must not fail an enforcement run.

param(
    # ShopDB base URL. Empty resolves from HKLM:\SOFTWARE\GE\ShopDB BaseUrl,
    # written by Install-GEEnforce.ps1 and already present wherever this runs.
    [string]$BaseUrl = '',

    # Defaults to this machine's name, which is what the collector upserts by.
    [string]$Hostname = $env:COMPUTERNAME,

    [int]$TimeoutSec = 30,

    # Where the per-user logon script is staged. Anywhere is fine as long as it
    # is on this PC and every user can read it.
    [string]$LocalScriptDir = (Join-Path ([Environment]::GetFolderPath('CommonApplicationData')) 'ShopDB'),

    [string]$LogonTaskName = 'ShopDB default printer',

    # The task runs as a GROUP, not a person: a shared bay has no one owner and
    # the default must be applied for whoever logs on. If this name does not
    # resolve - it is localised on non-English Windows - the well-known SID is
    # tried instead.
    [string]$UsersGroup = 'BUILTIN\Users',

    # 0 means at logon only. A shared bay where people pick their own default
    # can be pulled back on a repeat; a single-user PC should not be, so the
    # neutral default is the one that does not argue with the user.
    [int]$RepeatMinutes = 0,

    # For a site that deploys the logon task by GPO instead.
    [switch]$NoLogonTask,

    # Report what would change and touch nothing.
    [switch]$WhatIfOnly
)

$ErrorActionPreference = 'Continue'
[Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12

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

# Resolved once, at script scope: $PSScriptRoot is empty when the file is piped
# into powershell rather than run by path, and the logon script sits beside this
# one.
$SCRIPTDIR = $PSScriptRoot
if (-not $SCRIPTDIR -and $MyInvocation.MyCommand.Path) {
    $SCRIPTDIR = Split-Path -Parent $MyInvocation.MyCommand.Path
}

function Ensure-LogonTask {
    # Half of this feature is per-user state that SYSTEM cannot write. All SYSTEM
    # can do is arrange for something to run AS the user later, which is this
    # task. Nothing else registered it, so the default was recorded every cycle
    # and applied never.
    if ($NoLogonTask) {
        Log 'logon task: skipped (-NoLogonTask)'
        return
    }

    $source = ''
    if ($SCRIPTDIR) { $source = Join-Path $SCRIPTDIR 'Apply-ShopdbDefaultPrinter.ps1' }
    if (-not $source -or -not (Test-Path $source)) {
        Log "SKIP logon task: Apply-ShopdbDefaultPrinter.ps1 is not beside this script"
        return
    }

    # THE LOCAL COPY IS LOAD-BEARING. This script runs from a share that is
    # mounted only for the enforcement cycle; the task fires at logon, when the
    # share is gone. A task pointing at the share never runs and says nothing.
    $localscript = Join-Path $LocalScriptDir 'Apply-ShopdbDefaultPrinter.ps1'
    $refreshed = $false
    try {
        if (-not (Test-Path $LocalScriptDir)) {
            # Inherited ACL is what is wanted here: every user can read it, only
            # admins can write it, so the task cannot be pointed somewhere else.
            New-Item -ItemType Directory -Path $LocalScriptDir -Force -ErrorAction Stop | Out-Null
        }
        $stale = $true
        if (Test-Path $localscript) {
            $stale = (Get-FileHash -Path $localscript -Algorithm SHA256).Hash -ne
                     (Get-FileHash -Path $source -Algorithm SHA256).Hash
        }
        if ($stale) {
            if ($WhatIfOnly) {
                Log "WOULD stage the logon script at $localscript"
            } else {
                Copy-Item -Path $source -Destination $localscript -Force -ErrorAction Stop
                $refreshed = $true
                Log "staged the logon script at $localscript"
            }
        }
    } catch {
        # No local copy means no task worth registering - a task pointing at a
        # file that is not there is worse than no task, because it looks fine.
        Log "ERROR staging ${localscript}: $($_.Exception.Message)"
        return
    }

    $arguments = "-NoProfile -ExecutionPolicy Bypass -WindowStyle Hidden -File `"$localscript`""

    $task = Get-ScheduledTask -TaskName $LogonTaskName -ErrorAction SilentlyContinue
    if ($task -and -not $refreshed) {
        # Re-registering every cycle throws away the task's run history, which
        # is the only evidence it ever fired. So it is replaced only when it
        # points somewhere other than the local copy, or has no group principal
        # - a task left behind running as one person applies one person's
        # default. Matched on the PATH rather than the whole argument string
        # because Task Scheduler is free to normalise quoting, and an exact
        # compare would churn over a difference that changes nothing.
        $registered = @($task.Actions)[0]
        $pointslocal = $registered -and $registered.Arguments -and
            $registered.Arguments.IndexOf($localscript, [StringComparison]::OrdinalIgnoreCase) -ge 0
        if ($pointslocal -and $task.Principal.GroupId) {
            Log "logon task present: $LogonTaskName"
            return
        }
    }

    if ($WhatIfOnly) {
        Log "WOULD register the logon task: $LogonTaskName -> $localscript"
        return
    }

    try {
        $triggers = @(New-ScheduledTaskTrigger -AtLogOn)
        if ($RepeatMinutes -gt 0) {
            $triggers += New-ScheduledTaskTrigger -Once -At (Get-Date) `
                -RepetitionInterval (New-TimeSpan -Minutes $RepeatMinutes)
        }
        $action = New-ScheduledTaskAction -Execute 'powershell.exe' -Argument $arguments
        $settings = New-ScheduledTaskSettingsSet -AllowStartIfOnBatteries `
            -DontStopIfGoingOnBatteries -StartWhenAvailable
    } catch {
        # An SKU without the ScheduledTasks module is the likely reason. Nothing
        # to register with, and still not a reason to fail the run.
        Log "ERROR building the logon task: $($_.Exception.Message)"
        return
    }

    # Limited, not Highest: setting your own default printer needs no elevation,
    # and a task the whole Users group can trigger should not have any.
    $candidates = @($UsersGroup)
    if ($UsersGroup -ne 'S-1-5-32-545') { $candidates += 'S-1-5-32-545' }

    $lasterror = 'no principal accepted'
    foreach ($groupid in $candidates) {
        try {
            $principal = New-ScheduledTaskPrincipal -GroupId $groupid -RunLevel Limited -ErrorAction Stop
            Register-ScheduledTask -TaskName $LogonTaskName -Action $action -Trigger $triggers `
                -Principal $principal -Settings $settings -Force -ErrorAction Stop | Out-Null
            Log "registered the logon task: $LogonTaskName as $groupid"
            return
        } catch {
            $lasterror = $_.Exception.Message
        }
    }

    # A missing logon task means the default is not applied. It does not mean the
    # queues are wrong, so it is logged and the run carries on.
    Log "ERROR registering ${LogonTaskName}: $lasterror"
}

$REGPATH = 'HKLM:\SOFTWARE\GE\ShopDB'

if (-not $BaseUrl) {
    foreach ($path in @($REGPATH, 'HKLM:\SOFTWARE\WOW6432Node\GE\ShopDB')) {
        try {
            if (Test-Path $path) {
                $value = [string](Get-ItemProperty -Path $path -Name BaseUrl -ErrorAction Stop).BaseUrl
                if ($value -and $value.Trim()) { $BaseUrl = $value.Trim(); break }
            }
        } catch {}
    }
}
if (-not $BaseUrl) {
    Log 'ERROR no ShopDB URL (HKLM:\SOFTWARE\GE\ShopDB BaseUrl or -BaseUrl). Skipping.'
    exit 0
}

Log "=== Set printers for $Hostname ==="

# Before the API call on purpose: the task depends on files on this PC, not on
# the server. A bad minute from the API must not leave a bay with no way to apply
# the default it was already told about.
Ensure-LogonTask

$url = $BaseUrl.TrimEnd('/') + '/api/printers/for-host/' + [uri]::EscapeDataString($Hostname)
try {
    $response = Invoke-RestMethod -Uri $url -Method Get -TimeoutSec $TimeoutSec
} catch {
    # An unreachable server means "no information", not "no printers". Changing
    # nothing is the only safe response.
    Log "ERROR could not read $url : $($_.Exception.Message)"
    exit 0
}

$payload = $response.data
if ($null -eq $payload) { $payload = $response }
$wanted = @($payload.printers)
$defaultid = $payload.defaultprinterid

if ($wanted.Count -eq 0) {
    Log 'nothing assigned to this host'
    exit 0
}
Log "assigned: $($wanted.Count) printer(s)"

function Ensure-Port([string]$address) {
    $portname = 'IP_' + $address
    if (-not (Get-PrinterPort -Name $portname -ErrorAction SilentlyContinue)) {
        Add-PrinterPort -Name $portname -PrinterHostAddress $address -ErrorAction Stop
        Log "port: $portname"
    }
    return $portname
}

function Repair-Queue($queue, [string]$address, [string]$drivername) {
    $name = $queue.Name

    # The address ShopDB holds is the truth about where the printer IS. A queue
    # left pointing at the old address prints into the void, and looks fine.
    if ($address) {
        $wantedport = 'IP_' + $address
        if ($queue.PortName -ne $wantedport) {
            if ($WhatIfOnly) {
                Log "WOULD repoint $name : $($queue.PortName) -> $wantedport"
            } else {
                try {
                    $portname = Ensure-Port $address
                    Set-Printer -Name $name -PortName $portname -ErrorAction Stop
                    Log "repointed $name : $($queue.PortName) -> $portname"
                } catch {
                    Log "ERROR repointing ${name}: $($_.Exception.Message)"
                }
            }
        }
    }

    # A queue built on a driver the site has moved off keeps using it forever.
    # Only corrected when the wanted driver is actually staged - swapping a queue
    # onto a driver that is not installed would break a working printer.
    if ($drivername -and $queue.DriverName -ne $drivername) {
        if (-not (Get-PrinterDriver -Name $drivername -ErrorAction SilentlyContinue)) {
            Log "SKIP driver fix for $name : '$drivername' is not staged"
        } elseif ($WhatIfOnly) {
            Log "WOULD re-driver $name : $($queue.DriverName) -> $drivername"
        } else {
            try {
                Set-Printer -Name $name -DriverName $drivername -ErrorAction Stop
                Log "re-drivered $name : $($queue.DriverName) -> $drivername"
            } catch {
                Log "ERROR re-drivering ${name}: $($_.Exception.Message)"
            }
        }
    }

    if ($queue.PortName -eq ('IP_' + $address) -and
        ($drivername -eq '' -or $queue.DriverName -eq $drivername)) {
        Log "present: $name"
    }
}

$existing = @{}
foreach ($queue in (Get-Printer -ErrorAction SilentlyContinue)) {
    $existing[$queue.Name] = $queue
}

$defaultname = ''
foreach ($printer in $wanted) {
    $name = $printer.queuename
    if (-not $name) { continue }
    if ($printer.printerid -eq $defaultid) { $defaultname = $name }

    $address = $printer.hostname
    if (-not $address) { $address = $printer.ipaddress }
    $drivername = $printer.drivername

    if ($existing.ContainsKey($name)) {
        # A queue with the right NAME can still be wrong: pointing at a printer
        # that has moved, or built on a driver that has since been replaced.
        # Absence used to be the only thing fixed, so a bay with a stale queue
        # looked converged and printed to the wrong device.
        #
        # Corrected IN PLACE with Set-Printer, never removed and recreated: the
        # queue keeps its name, its sharing, its permissions, and whoever has it
        # as their default keeps it.
        Repair-Queue $existing[$name] $address $drivername
        continue
    }

    if (-not $address) {
        Log "SKIP $name : no hostname or IP to point a port at"
        continue
    }

    if (-not $drivername) {
        Log "SKIP $name : ShopDB has no driver name for it"
        continue
    }
    if (-not (Get-PrinterDriver -Name $drivername -ErrorAction SilentlyContinue)) {
        # Deliberately not fetched here - see the header.
        Log "SKIP $name : driver '$drivername' is not staged on this PC"
        continue
    }

    if ($WhatIfOnly) {
        Log "WOULD create: $name -> $address ($drivername)"
        continue
    }

    try {
        $portname = Ensure-Port $address
        Add-Printer -Name $name -DriverName $drivername -PortName $portname -ErrorAction Stop
        Log "created: $name -> $address ($drivername)"
    } catch {
        Log "ERROR creating ${name}: $($_.Exception.Message)"
    }
}

# The default is recorded, not applied: this process is SYSTEM and the setting
# is per user. The logon task registered above runs
# Apply-ShopdbDefaultPrinter.ps1, which reads this value in the user's context.
if ($defaultname) {
    if ($WhatIfOnly) {
        Log "WOULD record default: $defaultname"
    } else {
        try {
            if (-not (Test-Path $REGPATH)) { New-Item -Path $REGPATH -Force | Out-Null }
            Set-ItemProperty -Path $REGPATH -Name DefaultPrinter -Value $defaultname
            Log "default recorded for the logon task: $defaultname"
        } catch {
            Log "ERROR recording the default: $($_.Exception.Message)"
        }
    }
} else {
    Log 'no default assigned'
}

exit 0
