Public/Invoke-NSPScanBridge.ps1

function Invoke-NSPScanBridge {
    <#
    .SYNOPSIS
        Runs one unattended scan intake and delivery pass.
    .DESCRIPTION
        Claims completed PDFs to a same-volume spool. Persists per-job receipts,
        delivers via an app-only Graph upload session, and archives confirmed jobs.
        Ambiguous uploads are held for review rather than blindly retried.
    #>

    [CmdletBinding(SupportsShouldProcess)]
    param(
        [Parameter(Mandatory)][string]$ConfigurationPath,
        [switch]$IntakeOnly
    )
    $ErrorActionPreference = 'Stop'
    $config = Import-PowerShellDataFile -LiteralPath $ConfigurationPath
    if (-not $config.Routes -or -not $config.LockPath) { throw 'Routes and LockPath are required.' }
    # Validate the complete route set before creating locks, claiming files or connecting.
    $routes = @(Get-NSPScanBridgeValidatedRoutes -Routes $config.Routes)
    $bindings = @{}
    foreach ($route in $routes) { $bindings[$route.Name] = Get-NSPScanBridgeJobBinding -Route $route -Auth $config.Auth }
    $lockPath = [IO.Path]::GetFullPath([string]$config.LockPath)
    if (-not (Test-Path -LiteralPath (Split-Path -Parent $lockPath) -PathType Container)) { throw 'Lock directory does not exist.' }
    $lock = $null
    try {
        try {
            $lock = [IO.File]::Open($lockPath, [IO.FileMode]::OpenOrCreate, [IO.FileAccess]::ReadWrite, [IO.FileShare]::None)
        } catch [IO.IOException] { throw 'Another ScanBridge process holds the lock.' }
        if (-not $IntakeOnly -and -not $WhatIfPreference) {
            Connect-NSPScanBridgeContext -Auth $config.Auth
        }
        foreach ($route in $routes) {
            $binding = $bindings[$route.Name]
            $source = $route.Source
            $spool = $route.Spool
            $archive = $route.Archive
            $age = $route.MinimumAgeSeconds
            foreach ($candidate in @(Get-ChildItem -LiteralPath $source -Filter '*.pdf' -File -ErrorAction Stop)) {
                if (-not (Test-NSPScanBridgeFileReady -Path $candidate.FullName -MinimumAgeSeconds $age)) { continue }
                $jobId = [guid]::NewGuid().ToString('N')
                $claimedPath = Join-Path $spool ($jobId + '.pdf')
                if ($PSCmdlet.ShouldProcess('Ready PDF', 'Claim into durable spool')) {
                    try {
                        [IO.File]::Move($candidate.FullName, $claimedPath)
                        Save-NSPScanBridgeReceipt -PdfPath $claimedPath -State Queued -Binding $binding
                        [pscustomobject]@{ Route = $route.Name; JobId = $jobId; State = 'Queued' }
                    } catch [IO.IOException] {
                        Write-Warning 'A scan could not be claimed; it will be considered in the next pass.'
                    }
                }
            }
            if ($IntakeOnly -or $WhatIfPreference) { continue }
            foreach ($orphanReceipt in @(Get-ChildItem -LiteralPath $spool -Filter '*.json' -File -ErrorAction Stop)) {
                $orphanId = [IO.Path]::GetFileNameWithoutExtension($orphanReceipt.Name)
                if ($orphanId -cnotmatch '^[0-9a-f]{32}$') {
                    Write-Warning 'Unknown spool receipt requires review.'
                    [pscustomobject]@{ Route = $route.Name; JobId = $null; State = 'ManualReviewRequired' }
                    continue
                }
                $spooledPdf = Join-Path $spool ($orphanId + '.pdf')
                $archivedPdf = Join-Path $archive ($orphanId + '.pdf')
                if (-not (Test-Path -LiteralPath $spooledPdf -PathType Leaf)) {
                    if (-not (Test-Path -LiteralPath $archivedPdf -PathType Leaf)) {
                        Write-Warning ('Orphan receipt requires review for job {0}.' -f $orphanId)
                        [pscustomobject]@{ Route = $route.Name; JobId = $orphanId; State = 'ManualReviewRequired' }
                        continue
                    }
                    try {
                        Complete-NSPScanBridgeArchive -PdfPath $spooledPdf -ArchiveDirectory $archive -ExpectedBinding $binding
                        [pscustomobject]@{ Route = $route.Name; JobId = $orphanId; State = 'ArchiveRecovered' }
                    } catch {
                        Write-Warning ("Archive recovery requires review for job {0}." -f $orphanId)
                        [pscustomobject]@{ Route = $route.Name; JobId = $orphanId; State = 'ManualReviewRequired' }
                    }
                }
            }
            foreach ($job in @(Get-ChildItem -LiteralPath $spool -Filter '*.pdf' -File -ErrorAction Stop)) {
                if ($job.Name -cnotmatch '^[0-9a-f]{32}\.pdf$') {
                    Write-Warning 'Unknown spool document requires review.'
                    [pscustomobject]@{ Route = $route.Name; JobId = $null; State = 'ManualReviewRequired' }
                    continue
                }
                $jobId = [IO.Path]::GetFileNameWithoutExtension($job.Name)
                $receiptPath = [IO.Path]::ChangeExtension($job.FullName, '.json')
                if (-not (Test-Path -LiteralPath $receiptPath)) {
                    # A missing receipt cannot distinguish an interrupted claim from a prior upload.
                    [pscustomobject]@{ Route = $route.Name; JobId = $jobId; State = 'ManualReviewRequired' }
                    continue
                }
                try { $receipt = Read-NSPScanBridgeReceipt -PdfPath $job.FullName -ReceiptPath $receiptPath -ExpectedBinding $binding }
                catch {
                    Write-Warning ('Receipt requires review for job {0}.' -f $jobId)
                    [pscustomobject]@{ Route = $route.Name; JobId = $jobId; State = 'ManualReviewRequired' }
                    continue
                }
                if ($receipt.State -in @('Uploading','Review')) {
                    [pscustomobject]@{ Route = $route.Name; JobId = $jobId; State = 'ManualReviewRequired' }
                    continue
                }
                if ($receipt.State -eq 'Uploaded') {
                    try {
                        Complete-NSPScanBridgeArchive -PdfPath $job.FullName -ArchiveDirectory $archive -ExpectedBinding $binding
                        [pscustomobject]@{ Route = $route.Name; JobId = $jobId; State = 'Archived' }
                    } catch {
                        Write-Warning ('Archive requires review for job {0}.' -f $jobId)
                        [pscustomobject]@{ Route = $route.Name; JobId = $jobId; State = 'ManualReviewRequired' }
                    }
                    continue
                }
                if ($receipt.State -ne 'Queued') { throw 'Unrecognized receipt state.' }
                try {
                    if ($job.Length -gt 250MB) { throw 'Configured development size limit exceeded.' }
                    Save-NSPScanBridgeReceipt -PdfPath $job.FullName -State Uploading -Binding $binding
                    $result = Send-NSPScanBridgeUploadSession -Path $job.FullName -DriveId ([string]$route.DriveId) -FolderItemId ([string]$route.FolderItemId) -Name $job.Name
                    if (-not $result.id -or [long]$result.size -ne [long]$job.Length) {
                        throw 'Upload response failed size or item ID verification.'
                    }
                    if ($route.ContainsKey('VerifyRemoteAfterUpload') -and [bool]$route.VerifyRemoteAfterUpload) {
                        $readback = Test-NSPScanBridgeRemoteMatch -PdfPath $job.FullName -DriveId ([string]$route.DriveId) -FolderItemId ([string]$route.FolderItemId)
                        if ($readback.Status -ne 'Verified' -or $readback.RemoteId -ne [string]$result.id) {
                            throw 'Remote upload readback not verified; job held for review.'
                        }
                    }
                    Save-NSPScanBridgeReceipt -PdfPath $job.FullName -State Uploaded -RemoteId ([string]$result.id) -Binding $binding
                    Complete-NSPScanBridgeArchive -PdfPath $job.FullName -ArchiveDirectory $archive -ExpectedBinding $binding
                    [pscustomobject]@{ Route = $route.Name; JobId = $jobId; State = 'Delivered'; DriveItemId = [string]$result.id }
                } catch {
                    if (Test-Path -LiteralPath $job.FullName) {
                        $current = Get-Content -LiteralPath $receiptPath -Raw -ErrorAction SilentlyContinue | ConvertFrom-Json -ErrorAction SilentlyContinue
                        if ($current.State -eq 'Uploading') {
                            Save-NSPScanBridgeReceipt -PdfPath $job.FullName -State Review -Binding $binding
                        }
                    }
                    Write-Warning ("Delivery held for job {0}; inspect in protected environment." -f $jobId)
                    [pscustomobject]@{ Route = $route.Name; JobId = $jobId; State = 'PendingReviewOrRetry' }
                }
            }
        }
    } finally {
        if ($null -ne $lock) { $lock.Dispose() }
    }
}