PropertyBox/Write-SRxTermBatch.ps1

<#
.SYNOPSIS
    Iteration-2/3 isolated worker: commit a WHOLE-NODE custom-property set to the
    remote O365 Termstore in one atomic batch, with WORKER-SIDE optimistic
    concurrency (full-state drift guard + optional timestamp), under the Entra App
    Registration identity (PnP CSOM, app-only certificate).

    FIX (2026-09-06): the whole-node replace no longer calls
      Set-PnPTerm -Identity ([GUID]$TermId) -DeleteAllCustomProperties ...
    which threw System.NullReferenceException inside SetTerm.ExecuteCmdlet() when
    re-resolving a term from a bare GUID in an app-only taxonomy session. We instead
    mutate the ALREADY-LOADED $term CSOM object directly (DeleteAllCustomProperties +
    SetCustomProperty) and commit with Invoke-PnPQuery - the proven app-only pattern
    already used by CustomizationWriter / Update-SRxTermCustomProperty in SRxCore.
#>

[CmdletBinding()]
param(
    [Parameter(Mandatory)] [string]$RootPath,
    [Parameter(Mandatory)] [string]$TermId,
    [Parameter(Mandatory)] [string]$DesiredSetJson,
    [Parameter(Mandatory=$false)] [string]$OriginalPropsJson = '{}',
    [Parameter(Mandatory=$false)] [AllowEmptyString()] [string]$BaselineModified = '',
    [Parameter(Mandatory)] [string]$ResultPath,
    [Parameter(Mandatory=$false)] [switch]$Force
)

$ErrorActionPreference = 'Stop'

function Write-SRxWorkerResult {
    param([bool]$Success, [bool]$Conflict, $Data, [string]$ErrorText)
    $payload = [pscustomobject]@{
        Success   = $Success
        Conflict  = $Conflict
        Data      = $Data
        Error     = $ErrorText
        TermId    = $TermId
        Pid       = $PID
        Ended     = (Get-Date).ToString('o')
    }
    $json = $payload | ConvertTo-Json -Depth 25 -Compress
    try {
        $dir = Split-Path -Parent $ResultPath
        if ($dir -and -not (Test-Path $dir)) { New-Item -ItemType Directory -Path $dir -Force | Out-Null }
        Set-Content -LiteralPath $ResultPath -Value $json -Encoding UTF8
    } catch { }
    Write-Host ("__JSON__" + $json)
}

function ConvertTo-SRxLogicalProps {
    param([hashtable]$Raw)
    $logical = @{}
    $chunks  = @{}
    foreach ($k in $Raw.Keys) {
        if ($k -match '^(?<base>.+)_(?<idx>\d+)$') {
            $base = $Matches['base']; $idx = [int]$Matches['idx']
            if (-not $chunks.ContainsKey($base)) { $chunks[$base] = @{} }
            $chunks[$base][$idx] = [string]$Raw[$k]
        } else {
            $logical[$k] = [string]$Raw[$k]
        }
    }
    foreach ($base in $chunks.Keys) {
        $sb = New-Object System.Text.StringBuilder
        foreach ($i in ($chunks[$base].Keys | Sort-Object)) { [void]$sb.Append($chunks[$base][$i]) }
        $logical[$base] = $sb.ToString()
    }
    return $logical
}

function ConvertFrom-SRxJsonToHashtable {
    param([string]$Json)
    $ht = @{}
    if ([string]::IsNullOrWhiteSpace($Json)) { return $ht }
    $obj = $Json | ConvertFrom-Json
    foreach ($p in $obj.PSObject.Properties) { $ht[$p.Name] = [string]$p.Value }
    return $ht
}

try {
    $externalScript = Join-Path $RootPath 'loadmodule.ps1'
    . $externalScript
    if ($null -ne $LoadModule) { $LoadModule.Invoke("SRxCore") | Out-Null }
    Initialize-SRxEnv -LoadModule2 $LoadModule -RootPath $RootPath | Out-Null
    Connect-SRxSPOService
    if ($null -eq $global:SRxEnv) {
        if (Get-Command Start-SRxShell -ErrorAction SilentlyContinue) {
            Start-SRxShell -RootPath $RootPath -isJob | Out-Null
        } elseif (Get-Command Initialize-SRxEnv -ErrorAction SilentlyContinue) {
            Initialize-SRxEnv -RootPath $RootPath | Out-Null
        } else {
            throw "SRx environment could not be initialised from '$RootPath'."
        }
    }
    if ($null -eq $global:SRxEnv -or $null -eq $global:SRxEnv.Tenancy) {
        throw "global:SRxEnv or global:SRxEnv.Tenancy is null after bootstrap."
    }

    # so we can run Sync-SRxLocalTermBatch here in the child process:
    try { Import-Module (Join-Path $PSScriptRoot 'SRxPropertyBox.psm1') -DisableNameChecking -Force -ErrorAction SilentlyContinue } catch { }
    #try { Import-Module ($("$PSScriptRoot\SRxPropertyBox.psm1")) -DisableNameChecking -Force }
    #catch { Write-Host "SRxPropertyBox import failed: $($_.Exception.Message)" }

    $connection = Get-SRxConnection -siteUrl $global:SRxEnv.Tenancy.AdminUrl -Termstore -Supress
    if (-not $connection) { throw "Failed to establish app-only PnP connection to the Termstore." }

    $term = Get-PnPTerm -Identity ([GUID]$TermId) -Connection $connection -ErrorAction Stop
    Get-PnPProperty -ClientObject $term -Property Id, Name, CustomProperties, LastModifiedDate -Connection $connection | Out-Null

    $remoteRaw = @{}
    if ($null -ne $term.CustomProperties) {
        foreach ($k in $term.CustomProperties.Keys) { $remoteRaw[$k] = [string]$term.CustomProperties[$k] }
    }
    $remoteLogical  = ConvertTo-SRxLogicalProps -Raw $remoteRaw
    $remoteModified = $null
    try { $remoteModified = $term.LastModifiedDate.ToString('o') } catch { }

    $desired  = ConvertFrom-SRxJsonToHashtable -Json $DesiredSetJson
    $original = ConvertFrom-SRxJsonToHashtable -Json $OriginalPropsJson

    if (-not $Force) {
        $conflictReasons = @()
        if (-not [string]::IsNullOrWhiteSpace($BaselineModified) -and $remoteModified) {
            try {
                $baseDt = [datetime]::Parse($BaselineModified).ToUniversalTime()
                $remDt  = [datetime]::Parse($remoteModified).ToUniversalTime()
                if ($remDt -gt $baseDt) {
                    $conflictReasons += "Term was modified remotely ($remoteModified) after baseline ($BaselineModified)."
                }
            } catch { }
        }
        $allKeys = New-Object System.Collections.Generic.HashSet[string]
        foreach ($k in $original.Keys)      { [void]$allKeys.Add([string]$k) }
        foreach ($k in $remoteLogical.Keys) { [void]$allKeys.Add([string]$k) }
        foreach ($k in $allKeys) {
            $inOrig = $original.ContainsKey($k)
            $inRem  = $remoteLogical.ContainsKey($k)
            if     (-not $inRem -and $inOrig) {
                $conflictReasons += "Key '$k' was deleted remotely (you based edits on '$($original[$k])')."
            }
            elseif ($inRem -and -not $inOrig) {
                $conflictReasons += "Key '$k' was added remotely (remote='$($remoteLogical[$k])')."
            }
            elseif ($inRem -and $inOrig -and ($remoteLogical[$k] -ne $original[$k])) {
                $conflictReasons += "Key '$k' changed remotely (remote='$($remoteLogical[$k])', you based edits on '$($original[$k])')."
            }
        }
        if ($conflictReasons.Count -gt 0) {
            $data = [pscustomobject]@{
                TermId         = $TermId
                RemoteProps    = $remoteLogical
                RemoteModified = $remoteModified
                Reasons        = $conflictReasons
            }
            Write-SRxWorkerResult -Success $false -Conflict $true -Data $data -ErrorText ($conflictReasons -join ' ')
            exit 2
        }
    }

    # whole-node replace on the already-loaded $term (avoids Set-PnPTerm NRE)
    $desiredHt = @{}
    foreach ($k in $desired.Keys) { $desiredHt[$k] = [string]$desired[$k] }
    $term.DeleteAllCustomProperties()
    foreach ($k in $desiredHt.Keys) { $term.SetCustomProperty($k, [string]$desiredHt[$k]) }
    Invoke-PnPQuery -Connection $connection -ErrorAction Stop

    # re-read authoritative post-state
    $after = Get-PnPTerm -Identity ([GUID]$TermId) -Connection $connection -ErrorAction Stop
    Get-PnPProperty -ClientObject $after -Property CustomProperties, LastModifiedDate -Connection $connection | Out-Null
    $afterRaw = @{}
    if ($null -ne $after.CustomProperties) {
        foreach ($k in $after.CustomProperties.Keys) { $afterRaw[$k] = [string]$after.CustomProperties[$k] }
    }
    $afterModified = $null
    try { $afterModified = $after.LastModifiedDate.ToString('o') } catch { }

    # ---- LOCAL SYNC IN THE CHILD PROCESS (the heavy work, off the UI thread) ----
    $afterLogical  = ConvertTo-SRxLogicalProps -Raw $afterRaw
    $afterOuterXml = $null
    $syncWarning   = $null
    try {
        if (Get-Command Sync-SRxLocalTermBatch -ErrorAction SilentlyContinue) {
            $sync = Sync-SRxLocalTermBatch -TermId $TermId -AfterRaw $afterRaw
            if ($sync.Success) {
                if ($sync.OuterXml)         { $afterOuterXml = $sync.OuterXml }
                if ($sync.CustomProperties) { $afterLogical  = $sync.CustomProperties }
            } else { $syncWarning = "Local sync failed: $($sync.Error)" }
        } else { $syncWarning = "Sync-SRxLocalTermBatch not available in worker; local cache not updated." }
    } catch { $syncWarning = "Local sync threw: $($_.Exception.Message)" }

    $data = [pscustomobject]@{
        TermId        = $TermId
        PropsAfterRaw = $afterRaw
        PropsAfter    = $afterLogical
        OuterXml      = $afterOuterXml
        ModifiedAfter = $afterModified
        SyncWarning   = $syncWarning
    }
    Write-SRxWorkerResult -Success $true -Conflict $false -Data $data -ErrorText $null
    exit 0
}
catch {
    $msg = $_.Exception.Message
    if ($_.Exception.InnerException) { $msg += " | inner: " + $_.Exception.InnerException.Message }
    Write-SRxWorkerResult -Success $false -Conflict $false -Data $null -ErrorText $msg
    exit 1
}