# Report-PrintersToShopDB.ps1
#
# Reports the print queues this PC ACTUALLY has to ShopDB, so the register can
# be compared against what the bay is SUPPOSED to have. ShopDB knows the
# assignment (GET /api/printers/for-host/<hostname>, applied by
# Set-ShopdbPrinters.ps1); it has never known what is really installed. This is
# that missing half.
#
# TARGET: the ADR-006 collector API.
#     POST <shopdb>/api/collector/printers
# The server is NOT baked in. It comes from HKLM:\SOFTWARE\GE\ShopDB BaseUrl,
# which Install-GEEnforce.ps1 provisions and the enforcement client already
# needs, or from -ApiUrl in the manifest entry's Args. ADR-015: a site name in
# product code is a defect, and this script ships to every site.
#
# READ ONLY. It calls nothing that creates, changes or removes a queue, a port
# or a driver - only Get-*. Convergence is Set-ShopdbPrinters.ps1's job and
# stays there; a reporter that also fixes things cannot be trusted to tell you
# what was broken.
#
# OBSERVED IS NOT ASSIGNED. The server stores this in its own table and never
# turns it into an assignment on its own. A drifted bay reporting its drift must
# not be able to redefine what correct means.
#
# THE LATEST REPORT REPLACES THE PREVIOUS ONE for this hostname, which makes an
# empty queues list a legitimate "this bay has no printers" and wipes the host's
# observed rows. So a FAILED enumeration must send NOTHING rather than an empty
# list - see the $enumerated flag below. Reporting nothing loses one cycle;
# reporting [] after a WMI hiccup deletes real state and reads as a bay that
# lost its printers.
#
# AUTH: the collector API does NOT honor the GE-Enforce IP allowlist (that only
# covers the geenforce fetch/report endpoints). It needs a collector.ingest key,
# sent as the X-API-Key header. The key is read from HKLM:\SOFTWARE\GE\ShopDB
# CollectorKey (the same secret store Report-AssetToShopDB.ps1 uses; provisioned
# at imaging), or overridden via the manifest entry's Args -ApiKey. Never bake
# the key into the manifest JSON on the share.
#
# Runs every GE-Enforce cycle as a Type=PS1 / DetectionMethod=Always entry under
# the SYSTEM task. Always exits 0 so a printer problem never fails an
# enforcement run; failures are logged, never thrown.

param(
    # Flask collector endpoint for the printers plugin. Empty resolves from
    # HKLM:\SOFTWARE\GE\ShopDB BaseUrl; override here if the path ever moves.
    [string]$ApiUrl = '',

    # collector.ingest key (X-API-Key). Default: read from the GE-Enforce secret
    # store in the registry. Override with -ApiKey via Args for testing.
    [string]$ApiKey = '',

    # Identity field of the payload. Defaults to this machine's name, which is
    # what the assignment side (for-host) and the computers collector both key on.
    [string]$Hostname = $env:COMPUTERNAME,

    [int]$TimeoutSec = 30,

    # Enumerate and log the payload, post nothing. For proving what a bay would
    # report before a site is pointed at a live server.
    [switch]$WhatIfOnly
)

$ErrorActionPreference = 'Continue'

# Force TLS 1.2 - older images default to SystemDefault which may negotiate a
# protocol the site rejects; the collector POST is HTTPS.
[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 ('report-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
}

$REGPATHS = @('HKLM:\SOFTWARE\GE\ShopDB', 'HKLM:\SOFTWARE\WOW6432Node\GE\ShopDB')

function Get-ShopdbRegValue([string]$name) {
    foreach ($path in $REGPATHS) {
        try {
            if (Test-Path $path) {
                $value = [string](Get-ItemProperty -Path $path -Name $name -ErrorAction Stop).$name
                if ($value -and $value.Trim()) { return $value.Trim() }
            }
        } catch {}
    }
    return ''
}

Log "=== Report printers to ShopDB (collector) : $Hostname ==="

# Server from the GE-Enforce config hive when not passed via Args. Any site
# running this script is running the enforcement client, which cannot work
# without BaseUrl, so it is present wherever this is deployed.
if (-not $ApiUrl) {
    $base = Get-ShopdbRegValue 'BaseUrl'
    if ($base) { $ApiUrl = $base.TrimEnd('/') + '/api/collector/printers' }
}
if (-not $ApiUrl -and -not $WhatIfOnly) {
    Log 'ERROR no ShopDB URL (HKLM:\SOFTWARE\GE\ShopDB BaseUrl or -ApiUrl). Skipping.'
    exit 0
}

# collector key from the GE-Enforce secret store when not passed via Args.
if (-not $ApiKey) { $ApiKey = Get-ShopdbRegValue 'CollectorKey' }
if (-not $ApiKey -and -not $WhatIfOnly) {
    Log 'ERROR no collector key (HKLM:\SOFTWARE\GE\ShopDB CollectorKey or -ApiKey). Skipping.'
    exit 0
}

# Queues that are not devices: the Windows-supplied virtual printers plus the
# Office writers. They exist on every image, match nothing in the register, and
# would each land as an UNKNOWN row on every bay in the fleet.
#
# Matched on the PORT, not the queue name, because the name is whatever a user
# renamed it to while the port of a virtual device is fixed. Two of them are
# matched on driver as well, since a redirected-port queue can share PORTPROMPT.
$VIRTUALPORTS = @('PORTPROMPT:', 'SHRFAX:', 'XPSPort:', 'nul:', 'NUL:')
$VIRTUALDRIVERS = @(
    'Microsoft XPS Document Writer',
    'Microsoft XPS Document Writer v4',
    'Microsoft Print To PDF',
    'Microsoft Shared Fax Driver',
    'Send to Microsoft OneNote Driver',
    'Microsoft Software Printer Driver'
)

function Test-VirtualQueue([string]$portname, [string]$drivername) {
    foreach ($p in $VIRTUALPORTS) {
        if ($portname -and $portname.Trim().ToLower() -eq $p.ToLower()) { return $true }
    }
    # OneNote's port is a per-install GUID path, so it can only be caught here.
    if ($portname -and $portname -like 'Microsoft.Office.OneNote*') { return $true }
    foreach ($d in $VIRTUALDRIVERS) {
        if ($drivername -and $drivername.Trim().ToLower() -eq $d.ToLower()) { return $true }
    }
    return $false
}

# Port address is the primary match key server-side: an IP or FQDN is
# unambiguous where a queue name is a local habit. Built once as a lookup so a
# bay with 8 queues does not re-enumerate ports 8 times.
#
# A port with no host address (USB, WSD, a redirected port) reports a null
# address and matches on name alone, which is correct: a locally attached
# printer is still a real printer worth seeing.
$portAddresses = @{}
$portsRead = $false
try {
    foreach ($port in (Get-PrinterPort -ErrorAction Stop)) {
        $address = ''
        if ($port.PSObject.Properties['PrinterHostAddress']) {
            $address = [string]$port.PrinterHostAddress
        }
        if ($port.Name) { $portAddresses[[string]$port.Name] = $address.Trim() }
    }
    $portsRead = $true
} catch {
    Log "WARN Get-PrinterPort failed, falling back to WMI ports: $($_.Exception.Message)"
}
if (-not $portsRead) {
    # PS 5.1-era hosts without the PrintManagement module, and images where the
    # spooler cmdlets are broken but WMI still answers.
    try {
        foreach ($port in (Get-CimInstance -ClassName Win32_TCPIPPrinterPort -ErrorAction Stop)) {
            if ($port.Name) { $portAddresses[[string]$port.Name] = ([string]$port.HostAddress).Trim() }
        }
        $portsRead = $true
    } catch {
        # Not fatal: queues still report, just without an address to match on.
        Log "WARN could not read printer ports at all: $($_.Exception.Message)"
    }
}

# The queues themselves. $enumerated stays false unless a read actually
# succeeded, because "no queues" and "could not look" are the same empty list
# and the server treats them very differently (see the header).
$queues = @()
$enumerated = $false
try {
    foreach ($printer in (Get-Printer -ErrorAction Stop)) {
        $queues += [pscustomobject]@{
            queuename  = [string]$printer.Name
            drivername = [string]$printer.DriverName
            portname   = [string]$printer.PortName
        }
    }
    $enumerated = $true
} catch {
    Log "WARN Get-Printer failed, falling back to WMI queues: $($_.Exception.Message)"
}
if (-not $enumerated) {
    try {
        foreach ($printer in (Get-CimInstance -ClassName Win32_Printer -ErrorAction Stop)) {
            $queues += [pscustomobject]@{
                queuename  = [string]$printer.Name
                drivername = [string]$printer.DriverName
                portname   = [string]$printer.PortName
            }
        }
        $enumerated = $true
    } catch {
        Log "ERROR could not enumerate printers: $($_.Exception.Message)"
    }
}

if (-not $enumerated) {
    # Deliberately posts nothing. An empty report REPLACES this host's observed
    # rows, so a failed read must not be able to claim the bay has no printers.
    Log 'ERROR enumeration failed; posting NOTHING so the last good report stands.'
    exit 0
}

# Which queue the interactive user actually prints to. This process is SYSTEM,
# and the default printer is per user, so Win32_Printer.Default here describes
# the SYSTEM session and is usually wrong. Read the console user's own value
# first: HKU\<sid>\...\Windows Device holds "<queue>,winspool,<port>".
$defaultName = ''
try {
    $consoleUser = [string](Get-CimInstance -ClassName Win32_ComputerSystem -ErrorAction Stop).UserName
    if ($consoleUser) {
        $sid = (New-Object System.Security.Principal.NTAccount($consoleUser)).Translate(
            [System.Security.Principal.SecurityIdentifier]).Value
        $devicePath = "Registry::HKEY_USERS\$sid\Software\Microsoft\Windows NT\CurrentVersion\Windows"
        $device = [string](Get-ItemProperty -Path $devicePath -Name Device -ErrorAction Stop).Device
        if ($device) { $defaultName = ($device -split ',')[0].Trim() }
        if ($defaultName) { Log "default for $consoleUser : $defaultName" }
    }
} catch {
    # Nobody logged on, a roaming hive not loaded, or a name that will not
    # translate. Not worth a warning every cycle on an unattended bay.
}
if (-not $defaultName) {
    # Falls back to whatever this session sees. Marked in the log because a
    # SYSTEM-session default is weak evidence and a reviewer should know which
    # one they are looking at before seeding an assignment from it.
    try {
        $sysDefault = Get-CimInstance -ClassName Win32_Printer -ErrorAction Stop |
                      Where-Object { $_.Default } | Select-Object -First 1
        if ($sysDefault) {
            $defaultName = [string]$sysDefault.Name
            Log "default from the SYSTEM session (no console user): $defaultName"
        }
    } catch {}
}

$reported = @()
$skipped = 0
foreach ($queue in $queues) {
    if (-not $queue.queuename) { continue }
    if (Test-VirtualQueue $queue.portname $queue.drivername) { $skipped++; continue }

    $portAddress = ''
    if ($queue.portname -and $portAddresses.ContainsKey($queue.portname)) {
        $portAddress = [string]$portAddresses[$queue.portname]
    }

    $row = @{
        queuename = $queue.queuename
        isdefault = ($defaultName -and $queue.queuename -eq $defaultName)
    }
    # Sent only when present: a null is "not known", and an empty string would
    # read as a driver or a port genuinely named nothing.
    if ($queue.drivername) { $row['drivername'] = $queue.drivername }
    if ($queue.portname)   { $row['portname']   = $queue.portname }
    if ($portAddress)      { $row['portaddress'] = $portAddress }

    $reported += $row
    Log ("queue: {0} | driver={1} | port={2} | address={3} | default={4}" -f `
        $queue.queuename, $queue.drivername, $queue.portname, $portAddress, $row['isdefault'])
}

Log "reporting $($reported.Count) queue(s), $skipped virtual queue(s) skipped"
if ($reported.Count -eq 0) {
    # Legitimate and meaningful: it clears this host's observed rows so the
    # comparison shows every assigned printer as missing, which is exactly what
    # a bay with no queues is.
    Log 'no real queues on this host; reporting an empty set (clears observed state)'
}

# Collector schema fields (lowercase concatenated). hostname is the identity
# field. observedat is sent for the record and named to match the declared
# collector schema; the server stamps its own and ignores this one.
$body = @{
    hostname   = $Hostname
    queues     = @($reported)
    observedat = (Get-Date).ToUniversalTime().ToString('yyyy-MM-ddTHH:mm:ss')
}

# Depth 4 covers hostname -> queues -> row -> value; the default of 2 flattens
# the rows to type names.
$json = $body | ConvertTo-Json -Compress -Depth 4

if ($WhatIfOnly) {
    Log "WOULD POST $ApiUrl $json"
    exit 0
}

Log ("POST {0} host={1} queues={2}" -f $ApiUrl, $Hostname, $reported.Count)
try {
    $response = Invoke-RestMethod -Uri $ApiUrl -Method Post -Body $json `
        -ContentType 'application/json' `
        -Headers @{ 'X-API-Key' = $ApiKey } `
        -TimeoutSec $TimeoutSec -ErrorAction Stop
    Log ("RESPONSE {0}" -f ($response | ConvertTo-Json -Compress -Depth 4))
} catch {
    Log "ERROR POST failed: $($_.Exception.Message)"
}

exit 0
