Documents.ps1

function Get-DocumentHash([string]$Text) {
  [Convert]::ToHexString([Security.Cryptography.SHA256]::HashData([Text.Encoding]::UTF8.GetBytes($Text))).ToLowerInvariant()
}

function Get-DocumentKey($Document, $Context) {
  $Context.scope + '_' + (Get-DocumentHash (ConvertTo-Json -InputObject @($Document.order_number, $Document.bol_number) -Compress))
}

function Assert-Document($Document) {
  foreach ($field in 'document_id', 'order_number', 'bol_number', 'file_name') {
    if ($Document.$field -isnot [string] -or [string]::IsNullOrWhiteSpace($Document.$field)) { throw "Document $field is required." }
  }
  if ($Document.file_name -cnotmatch '^[A-Za-z0-9][A-Za-z0-9._-]*\.pdf$') { throw 'Invalid document filename.' }
  if ($Document.bol_number.Trim() -ieq 'undefined') { throw 'Invalid BOL number.' }
}

function Test-DocumentScope($Record, $Context, [switch]$Legacy) {
  $Record.tenant -ceq $Context.tenant -and $Record.destination_tenant -ceq $Context.destination_tenant -and
    $Record.instance -ceq $Context.instance -and ($Legacy -or $Record.base_url -ceq $Context.base_url)
}

function Get-DocumentReceipt($Document, $Context) {
  $key = Get-DocumentKey $Document $Context
  $paths = @((Join-Path $Context.directory "cache/$key.json"))
  if ($Context.legacy -and $Document.order_number -cmatch '^[1-9][0-9]*$') {
    $old = "$($Document.order_number)_$(Get-DocumentHash $Document.bol_number)"
    $paths += Join-Path $Context.directory "cache/$old.json"
  }
  foreach ($path in $paths) {
    if (-not (Test-Path -LiteralPath $path)) { continue }
    $record = Get-Content -LiteralPath $path -Raw -ErrorAction Stop | ConvertFrom-Json -AsHashtable -DateKind String -ErrorAction Stop
    if ($record.schema -ne 1 -or $record.state -cne 'sent' -or
        -not (Test-DocumentScope $record $Context -Legacy:($path -ne $paths[0])) -or
        $record.order_number -cne $Document.order_number -or $record.bol_number -cne $Document.bol_number) { throw 'Invalid document receipt scope.' }
    $date = [datetime]::Parse($record.recorded_at).ToUniversalTime()
    if ($date -gt [datetime]::UtcNow.AddMinutes(5)) { throw 'Document receipt is future-dated.' }
    if ($date.AddDays($Context.keep_days) -gt [datetime]::UtcNow) { return $true }
  }
  if ($Context.legacy -and $Document.order_number -cmatch '^[1-9][0-9]*$') {
    $hash = Get-DocumentHash $Document.bol_number
    foreach ($file in Get-ChildItem "$($Context.directory)/cache/*_$($Document.order_number)_*_$hash.sent" -ErrorAction SilentlyContinue) {
      if ($file.Length -ne 0 -or $file.Name -cnotmatch '^([0-9]{8}T[0-9]{6}Z)_[1-9][0-9]*_[1-9][0-9]*_[a-f0-9]{64}\.sent$') { throw 'Invalid legacy document marker.' }
      $date = [datetime]::ParseExact($Matches[1], "yyyyMMdd'T'HHmmss'Z'", [cultureinfo]::InvariantCulture,
        [Globalization.DateTimeStyles]::AssumeUniversal -bor [Globalization.DateTimeStyles]::AdjustToUniversal)
      if ($date -gt [datetime]::UtcNow.AddMinutes(5)) { throw 'Document marker is future-dated.' }
      if ($date.AddDays($Context.keep_days) -gt [datetime]::UtcNow) { return $true }
    }
  }
  $false
}

function Write-DocumentState($Context, [string]$Path, $Record, [switch]$CreateOnly) {
  $json = $Record | ConvertTo-Json -Depth 8
  $full = Join-Path $Context.directory $Path
  if ($Context.write) {
    # The host must durably confirm its write before this run can dispatch a PDF.
    $confirmed = & $Context.write $Path $json ([bool]$CreateOnly)
    if ($confirmed -isnot [bool]) { throw 'WriteState must return one boolean confirmation.' }
    if (-not $confirmed) { return $false }
  }
  $null = New-Item -ItemType Directory -Path (Split-Path $full) -Force
  if ($CreateOnly) {
    try { $stream = [IO.File]::Open($full, [IO.FileMode]::CreateNew, [IO.FileAccess]::Write, [IO.FileShare]::None) }
    catch [IO.IOException] {
      if (Test-Path -LiteralPath $full) { return $false }
      throw
    }
    try {
      $bytes = [Text.Encoding]::UTF8.GetBytes($json)
      $stream.Write($bytes); $stream.Flush($true)
    } finally { $stream.Dispose() }
  } else {
    [IO.File]::WriteAllText("$full.new", $json)
    [IO.File]::Move("$full.new", $full, $true)
  }
  $true
}

function Remove-DocumentState($Context, [string]$Path) {
  $full = Join-Path $Context.directory $Path
  if ($Context.remove) { & $Context.remove $Path (Get-Content -LiteralPath $full -Raw -ErrorAction Stop) | Out-Null }
  Remove-Item -LiteralPath $full -ErrorAction Stop
}

function Save-DocumentReceipt($Document, $Context, $Response = $null) {
  if (Get-DocumentReceipt $Document $Context) { return }
  $record = [ordered]@{
    schema = 1; state = 'sent'; recorded_at = [datetime]::UtcNow.ToString('o')
    base_url = $Context.base_url; tenant = $Context.tenant; destination_tenant = $Context.destination_tenant; instance = $Context.instance
    order_number = $Document.order_number; bol_number = $Document.bol_number
    document_id = $Document.document_id; file_name = $Document.file_name
    evidence = $(if ($null -ne $Response) { 'upload_and_photo_readback' } else { 'existing_photo_readback' }); response = $null
  }
  if ($null -ne $Response) {
    # Response bodies may contain URLs or secrets; retain only these diagnostic fields.
    Set-StrictMode -Off
    $record.response = @{
      http = [int]$Response.http; parse_error = [bool]$Response.parse_error
      status = $(if ($Response.data.status -is [string] -and $Response.data.status -cin @('synced','pending','requested','origin_mapped','master_mapped','destination_mapped','canceled','error','rejected')) { $Response.data.status } else { $null })
      log_id = $(if ($Response.data.log._id -is [string] -and $Response.data.log._id -cmatch '^[a-f0-9]{24}$') { $Response.data.log._id } else { $null })
    }
  }
  if (-not (Write-DocumentState $Context "cache/$(Get-DocumentKey $Document $Context).json" $record)) { throw 'Document receipt was not persisted.' }
}

function Clear-DocumentReceipts($Context) {
  $removed = 0
  foreach ($file in Get-ChildItem "$($Context.directory)/cache/*" -File -ErrorAction SilentlyContinue) {
    if ($removed -ge 20) { break }
    if ($file.Name -cmatch ('^' + $Context.scope + '_[a-f0-9]{64}\.json$') -or
        ($Context.legacy -and $file.Name -cmatch '^[1-9][0-9]*_[a-f0-9]{64}\.json$')) {
      $record = Get-Content -LiteralPath $file.FullName -Raw | ConvertFrom-Json -AsHashtable -DateKind String -ErrorAction Stop
      $legacy = -not $record.ContainsKey('base_url')
      if (-not (Test-DocumentScope $record $Context -Legacy:$legacy)) { continue }
      if ($record.schema -ne 1 -or $record.state -cne 'sent') { throw 'Invalid document receipt.' }
      $date = [datetime]::Parse($record.recorded_at).ToUniversalTime()
    } elseif ($Context.legacy -and $file.Name -cmatch '^([0-9]{8}T[0-9]{6}Z)_[1-9][0-9]*_[1-9][0-9]*_[a-f0-9]{64}\.sent$') {
      if ($file.Length -ne 0) { throw 'Invalid legacy document marker.' }
      $date = [datetime]::ParseExact($Matches[1], "yyyyMMdd'T'HHmmss'Z'", [cultureinfo]::InvariantCulture,
        [Globalization.DateTimeStyles]::AssumeUniversal -bor [Globalization.DateTimeStyles]::AdjustToUniversal)
    } else { continue }
    if ($date -gt [datetime]::UtcNow.AddMinutes(5)) { throw 'Document receipt is future-dated.' }
    if ($date.AddDays($Context.keep_days) -le [datetime]::UtcNow) {
      Remove-DocumentState $Context "cache/$($file.Name)"
      $removed++
    }
  }
}

function Get-DocumentReadback($Read, $Document, $Context, [switch]$ExactFile) {
  # External optional fields are checked explicitly; absent fields must fail closed.
  Set-StrictMode -Off
  $body = $Read.data
  $origin = $body.origin_order.origin_order_number
  $destination = $body.destination_order.origin_order_number
  $routing = $body.routing
  if ($Read.http -lt 200 -or $Read.http -ge 300 -or $Read.parse_error -or
      $origin -isnot [string] -or $origin -cne $Document.order_number -or
      $routing.origin_tenant_name -isnot [string] -or $routing.origin_tenant_name -cne $Context.tenant -or
      $routing.destination_tenant_name -isnot [string] -or $routing.destination_tenant_name -cne $Context.destination_tenant -or
      $routing.destination_instance_name -isnot [string] -or $routing.destination_instance_name -cne $Context.instance -or
      $body._id -isnot [string] -or [string]::IsNullOrWhiteSpace($body._id) -or
      (-not [string]::IsNullOrWhiteSpace("$destination") -and "$destination" -cne $Document.order_number)) { return 'order_unverified' }
  $pattern = '(?:^|_)' + [regex]::Escape($Document.file_name.Substring(0, $Document.file_name.Length - 4)) +
    '(?:_[a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12})?\.pdf$'
  $photos = @($body.origin_order.bol_photos; $body.master_order.bol_photos; $body.destination_order.bol_photos) |
    Where-Object { $_.bol_number -is [string] -and $_.bol_number -ceq $Document.bol_number -and
      $_.file_name -is [string] -and -not [string]::IsNullOrWhiteSpace($_.file_name) }
  if ($ExactFile) {
    if (@($photos | Where-Object { $_.file_name -cmatch $pattern }).Count) { return 'visible' }
    return 'awaiting_readback'
  }
  if ($body.destination_order.destination_order_number -isnot [string] -or
      [string]::IsNullOrWhiteSpace($body.destination_order.destination_order_number) -or
      -not @($body.destination_order.bols | Where-Object { $_.bol_number -is [string] -and $_.bol_number -ceq $Document.bol_number }).Count) {
    return 'waiting_for_destination_bol'
  }
  if (@($photos).Count) { return 'already_visible' }
  'ready'
}

function Read-DocumentOrder($Document, $Context, [string]$Token) {
  $read = Invoke-CrossroadsRequest -BaseUrl $Context.base_url -Path '/v1/order/get' -Token $Token `
    -Tenant $Context.tenant -DestinationTenant $Context.destination_tenant -ReadOnly -ThrowOnTransportError `
    -Body @{ order_number = $Document.order_number } -TimeoutSec 15
  if ($read.http -in 0, 401, 403, 429 -or $read.http -ge 500) { throw "Order $($Document.order_number) read returned HTTP $($read.http)." }
  $read
}

function Send-CrossroadsDocuments {
  [CmdletBinding()]
  param(
    [Parameter(Mandatory)][AllowEmptyCollection()][object[]]$Documents,
    [Parameter(Mandatory)][ValidateNotNullOrWhiteSpace()][string]$BaseUrl,
    [Parameter(Mandatory)][ValidateNotNullOrWhiteSpace()][string]$Token,
    [Parameter(Mandatory)][ValidateNotNullOrWhiteSpace()][string]$Tenant,
    [Parameter(Mandatory)][ValidateNotNullOrWhiteSpace()][string]$DestinationTenant,
    [Parameter(Mandatory)][ValidateNotNullOrWhiteSpace()][string]$DestinationInstance,
    [Parameter(Mandatory)][ValidateNotNullOrWhiteSpace()][string]$StateDirectory,
    [Parameter(Mandatory)][scriptblock]$ReadDocument,
    [scriptblock]$WriteState,
    [scriptblock]$RemoveState,
    [switch]$ReadLegacyState,
    [object[]]$PriorAttempts = @(),
    [ValidateRange(1, 3650)][int]$KeepDays = 14,
    [ValidateRange(1, 86400)][int]$BudgetSeconds = 480,
    [ValidateRange(1, 2000)][int]$MaxDocuments = 2000,
    [ValidateRange(0, 2000)][int]$MaxUploads = 0,
    [switch]$Apply
  )
  $ErrorActionPreference = 'Stop'
  $uri = [uri]$BaseUrl
  if (-not $uri.IsAbsoluteUri -or $uri.Scheme -notin 'http','https' -or $uri.UserInfo -or $uri.Query -or $uri.Fragment) {
    throw 'BaseUrl must be an HTTP endpoint without credentials, query or fragment.'
  }
  if ([bool]$WriteState -ne [bool]$RemoveState) { throw 'Supply both state persistence callbacks or neither.' }
  if ($Documents.Count -gt 2000) { throw 'Source selection truncated. No uploads.' }
  foreach ($document in $Documents) { Assert-Document $document }
  foreach ($attempt in $PriorAttempts) { Assert-Document $attempt }
  $context = @{ base_url = $BaseUrl.TrimEnd('/'); tenant = $Tenant; destination_tenant = $DestinationTenant
    instance = $DestinationInstance; directory = [IO.Path]::GetFullPath($StateDirectory); keep_days = $KeepDays
    legacy = [bool]$ReadLegacyState; write = $WriteState; remove = $RemoveState }
  $context.scope = Get-DocumentHash (ConvertTo-Json -InputObject @($context.base_url,$Tenant,$DestinationTenant,$DestinationInstance) -Compress)
  $documents = @($Documents | Group-Object { Get-DocumentKey $_ $context } | ForEach-Object { $_.Group[0] })
  $clock = [Diagnostics.Stopwatch]::StartNew()
  $uploads = 0; $seen = 0
  if ($Apply) { Clear-DocumentReceipts $context }
  $claims = @{}
  foreach ($file in Get-ChildItem "$StateDirectory/claims/*.json" -ErrorAction SilentlyContinue) {
    $claim = Get-Content -LiteralPath $file.FullName -Raw | ConvertFrom-Json -AsHashtable -ErrorAction Stop
    $legacy = $ReadLegacyState -and -not $claim.ContainsKey('base_url')
    if (-not (Test-DocumentScope $claim $context -Legacy:$legacy)) { continue }
    Assert-Document $claim
    $key = Get-DocumentKey $claim $context
    $expectedKey = $key
    if ($legacy) {
      $expectedKey = if ($claim.file_name -cmatch '^EBE-[0-9]+-[a-f0-9]{64}\.pdf$') {
        Get-DocumentHash (ConvertTo-Json -InputObject @($Tenant,$DestinationTenant,$DestinationInstance,
            $claim.order_number,$claim.bol_number,$claim.document_id,$claim.file_name) -Compress)
      } else { "$($claim.order_number)_$(Get-DocumentHash $claim.bol_number)" }
    }
    if ($file.BaseName -cne $expectedKey) { throw 'Invalid document claim key.' }
    $claims[$key] = @{ record = $claim; path = "claims/$($file.Name)" }
  }
  foreach ($key in @($claims.Keys)) {
    if ($clock.Elapsed.TotalSeconds -ge $BudgetSeconds) { break }
    $claim = $claims[$key].record
    $read = Read-DocumentOrder $claim $context $Token
    $state = Get-DocumentReadback $read $claim $context -ExactFile
    if ($state -eq 'visible' -and $Apply) {
      Save-DocumentReceipt $claim $context
      Remove-DocumentState $context $claims[$key].path
      $claims.Remove($key)
    }
    [pscustomobject]@{ order_number = $claim.order_number; bol_number = $claim.bol_number; document_id = $claim.document_id
      disposition = $(if ($state -eq 'visible') { 'claim_resolved' } else { 'awaiting_readback' }); upload_attempts = 0 }
  }
  foreach ($group in ($documents | Group-Object order_number)) {
    if ($clock.Elapsed.TotalSeconds -ge $BudgetSeconds -or $seen -ge $MaxDocuments -or ($Apply -and $MaxUploads -gt 0 -and $uploads -ge $MaxUploads)) { break }
    $read = $null
    if (@($group.Group | Where-Object { -not (Get-DocumentReceipt $_ $context) }).Count) { $read = Read-DocumentOrder $group.Group[0] $context $Token }
    foreach ($document in $group.Group) {
      if ($clock.Elapsed.TotalSeconds -ge $BudgetSeconds -or $seen -ge $MaxDocuments -or ($Apply -and $MaxUploads -gt 0 -and $uploads -ge $MaxUploads)) { break }
      $key = Get-DocumentKey $document $context
      $state = if (Get-DocumentReceipt $document $context) { 'sent_cached' } else { Get-DocumentReadback $read $document $context }
      if ($state -eq 'ready' -and ($claims.ContainsKey($key) -or @($PriorAttempts | Where-Object {
            $_.order_number -ceq $document.order_number -and $_.bol_number -ceq $document.bol_number }).Count)) { $state = 'awaiting_readback' }
      $result = [pscustomobject]@{ order_number = $document.order_number; bol_number = $document.bol_number
        document_id = $document.document_id; disposition = $state; upload_attempts = 0 }
      try {
        if ($Apply -and $state -eq 'already_visible') { Save-DocumentReceipt $document $context }
        if ($Apply -and $state -eq 'ready') {
        [byte[]]$bytes = & $ReadDocument $document
        try {
          if ($bytes.Length -lt 5 -or [Text.Encoding]::ASCII.GetString($bytes,0,5) -cne '%PDF-') { throw 'Source returned non-PDF content.' }
          $claim = @{ base_url = $context.base_url; tenant = $Tenant; destination_tenant = $DestinationTenant; instance = $DestinationInstance
            order_number = $document.order_number; bol_number = $document.bol_number; document_id = $document.document_id
            file_name = $document.file_name; claimed_at = [datetime]::UtcNow.ToString('o') }
          if (Write-DocumentState $context "claims/$key.json" $claim -CreateOnly) {
            $result.upload_attempts = 1; $result.disposition = 'awaiting_readback'; $uploads++
            $response = Send-CrossroadsBolImage -BaseUrl $BaseUrl -Token $Token -Tenant $Tenant -DestinationTenant $DestinationTenant `
              -OrderNumber $document.order_number -BolNumber $document.bol_number -FileName $document.file_name -Bytes $bytes -AllowWrite
            $status = & { Set-StrictMode -Off; if ($response.data.status -is [string] -and
                $response.data.status -cin @('synced','pending','requested','error','rejected')) { $response.data.status } else { 'unknown' } }
            $result | Add-Member -NotePropertyMembers @{ http = $response.http; status = $status; file_name = $document.file_name }
            Start-Sleep -Seconds 3
            $after = Read-DocumentOrder $document $context $Token
            $result.disposition = Get-DocumentReadback $after $document $context -ExactFile
            if ($result.disposition -eq 'visible') {
              Save-DocumentReceipt $document $context $response
              Remove-DocumentState $context "claims/$key.json"
            }
          } else { $result.disposition = 'awaiting_readback' }
        } finally { $bytes = $null }
        }
      } finally { $result; $seen++ }
      if ($result.upload_attempts -gt 0 -and $result.disposition -ne 'visible') { throw 'Upload unresolved. No further writes this run.' }
    }
  }
}