PropertyBox/SRxPropertyBox.psm1

<#
================================================================================
  SRxPropertyBox.psm1 (Iteration 2)
  -----------------------------------------------------------------------------
  Orchestration + local reconciliation for the WRITABLE, BATCH "Term custom
  properties" box. Runs in the PS 5.1 Explorer UI process. Actual remote writes
  happen in isolated per-node child processes (Write-SRxTermBatch.ps1) so the UI
  never blocks and every batch is a killable PID tracked on its treeview node.

  Public surface:
    # chunking helpers (grid <-> store)
    ConvertTo-SRxLogicalProps coalesce key_0,key_1,... -> single logical value
    Split-SRxChunkedProperties split long values -> key_0,key_1,... (9000 chars)


    # batch lifecycle (per node)
    Start-SRxTermBatchOperation launch worker -> {ProcessId, ResultPath, ...}
    Get-SRxTermBatchOperationState poll worker -> {State, Success, Conflict, Data, Error}
    Stop-SRxTermBatchOperation kill one batch by PID

    # local reconciliation (whole-node replace)
    Sync-SRxLocalTermBatch on success: replace node's props in XML+JSON, reload

    # path helper (same as iteration 1)
    Get-SRxProfileFolderPath
================================================================================
#>


$script:SRxPnpNs      = 'http://schemas.dev.office.com/PnP/2022/09/ProvisioningSchema'
$script:SRxChunkSize  = 9000

# ------------------------------------------------------------------------------
function Get-SRxProfileFolderPath {
    [CmdletBinding()] param()
    $sgroupName = Remove-SRxStringSpecialCharacter -String $global:SRxEnv.Tenancy.TermGroupName
    return (Join-Path (Join-Path $global:SRxEnv.CachePath 'Profiles') $sgroupName)
}

# ------------------------------------------------------------------------------
# Coalesce chunk keys (key_0,key_1,...) -> single logical value.
# ------------------------------------------------------------------------------
function ConvertTo-SRxLogicalProps {
    [CmdletBinding()] param([Parameter(Mandatory)][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]) }
        # a base that also had a non-chunk value (rare) is overwritten by the concatenation
        $logical[$base] = $sb.ToString()
    }
    return $logical
}

# ------------------------------------------------------------------------------
# Split long values (> chunk size) into key_0,key_1,... ; short values pass through.
# ------------------------------------------------------------------------------
function Split-SRxChunkedProperties {
    [CmdletBinding()]
    param(
        [Parameter(Mandatory)][hashtable]$Logical,
        [int]$ChunkSize = $script:SRxChunkSize
    )
    $out = @{}
    foreach ($k in $Logical.Keys) {
        $v = [string]$Logical[$k]
        if ($v.Length -le $ChunkSize) {
            $out[$k] = $v
        } else {
            $parts = @($v -split ("(.{$ChunkSize})") | Where-Object { $_ })
            for ([int]$i = 0; $i -lt $parts.Count; $i++) { $out["$($k)_$($i)"] = $parts[$i] }
        }
    }
    return $out
}


# ------------------------------------------------------------------------------
# Launch the isolated per-node batch worker. Non-blocking.
# DesiredLogical = complete desired set (logical, pre-chunk). OriginalLogical =
# user's pre-edit originals for the property-level concurrency guard.
# ------------------------------------------------------------------------------
function Start-SRxTermBatchOperation {
    [CmdletBinding()]
    param(
        [Parameter(Mandatory)] [string]$TermId,
        [Parameter(Mandatory)] [hashtable]$DesiredLogical,
        [Parameter(Mandatory=$false)] [hashtable]$OriginalLogical = @{},
        [Parameter(Mandatory=$false)] [AllowEmptyString()] [string]$BaselineModified = '',
        [Parameter(Mandatory)] [string]$SourcePath,
        [Parameter(Mandatory=$false)] [switch]$Force,
        [Parameter(Mandatory=$false)] [switch]$HideWindow
    )
    try {
        #$worker = Join-Path $SourcePath 'Write-SRxTermBatch.ps1'
        $worker = $("$PSScriptRoot\Write-SRxTermBatch.ps1")
        if (-not (Test-Path $worker)) { throw "Worker not found: $worker" }

        $desiredChunked = Split-SRxChunkedProperties -Logical $DesiredLogical
        $desiredJson  = ($desiredChunked  | ConvertTo-Json -Depth 5 -Compress)
        $originalJson = ($OriginalLogical | ConvertTo-Json -Depth 5 -Compress)
        if ([string]::IsNullOrWhiteSpace($desiredJson))  { $desiredJson  = '{}' }
        if ([string]::IsNullOrWhiteSpace($originalJson)) { $originalJson = '{}' }

        $tag        = ("{0:yyyyMMddHHmmssfff}-{1}" -f (Get-Date), ([guid]::NewGuid().ToString('N').Substring(0,8)))
        $resultPath = Join-Path $env:TEMP ("srx-batch-$tag.json")
        $desiredPath  = Join-Path $env:TEMP ("srx-batch-$tag.desired.json")
        $originalPath = Join-Path $env:TEMP ("srx-batch-$tag.original.json")
        # pass big JSON via files to avoid command-line length limits
        Set-Content -LiteralPath $desiredPath  -Value $desiredJson  -Encoding UTF8
        Set-Content -LiteralPath $originalPath -Value $originalJson -Encoding UTF8

        # Worker reads DesiredSetJson/OriginalPropsJson from files via Get-Content indirection
        # done in a tiny -Command shim so we keep Write-SRxTermBatch.ps1 signature clean.
        $shim = @"
`$d = Get-Content -LiteralPath '$desiredPath' -Raw
`$o = Get-Content -LiteralPath '$originalPath' -Raw
& '$worker' -RootPath '$SourcePath' -TermId '$TermId' -DesiredSetJson `$d -OriginalPropsJson `$o -BaselineModified '$BaselineModified' -ResultPath '$resultPath'$(if($Force){' -Force'})
"@

        $encoded = [Convert]::ToBase64String([Text.Encoding]::Unicode.GetBytes($shim))

        $startArgs = @{
            FilePath     = 'powershell.exe'
            ArgumentList = @('-NoProfile','-NonInteractive','-ExecutionPolicy','Bypass','-EncodedCommand', $encoded)
            PassThru     = $true
        }
        if ($HideWindow) { $startArgs['WindowStyle'] = 'Hidden' }

        $proc = Start-Process @startArgs

        return [pscustomobject]@{
            ProcessId    = $proc.Id
            Process      = $proc
            ResultPath   = $resultPath
            DesiredPath  = $desiredPath
            OriginalPath = $originalPath
            TermId       = $TermId
            DesiredLogical = $DesiredLogical
            StartedUtc   = (Get-Date).ToUniversalTime()
            Op           = 'batch'
        }
    }
    catch {
        return [pscustomobject]@{ ProcessId=0; Process=$null; ResultPath=$null; TermId=$TermId; StartError=$_.Exception.Message }
    }
}

# ------------------------------------------------------------------------------
# Poll one batch. State: Running | Completed | Conflict | Failed
# ------------------------------------------------------------------------------
function Get-SRxTermBatchOperationState {
    [CmdletBinding()] param([Parameter(Mandatory)]$Operation)
    $state = [pscustomobject]@{ State='Running'; Success=$false; Conflict=$false; Data=$null; Error=$null; Pid=$Operation.ProcessId }

    if ($Operation.StartError) { $state.State='Failed'; $state.Error=$Operation.StartError; return $state }

    if ($Operation.ResultPath -and (Test-Path $Operation.ResultPath)) {
        try {
            $raw = Get-Content -LiteralPath $Operation.ResultPath -Raw -ErrorAction Stop
            if (-not [string]::IsNullOrWhiteSpace($raw)) {
                $obj = $raw | ConvertFrom-Json -ErrorAction Stop
                $state.Success  = [bool]$obj.Success
                $state.Conflict = [bool]$obj.Conflict
                $state.Data     = $obj.Data
                $state.Error    = $obj.Error
                if     ($obj.Success)  { $state.State = 'Completed' }
                elseif ($obj.Conflict) { $state.State = 'Conflict'  }
                else                   { $state.State = 'Failed'    }
                return $state
            }
        } catch { }  # mid-write; keep Running
    }

    $alive = $false
    try { $p = Get-Process -Id $Operation.ProcessId -ErrorAction SilentlyContinue; $alive = ($null -ne $p -and -not $p.HasExited) } catch { $alive = $false }
    if ($alive) { $state.State='Running'; return $state }

    $state.State='Failed'; $state.Error='Worker exited without a result (killed or crashed).'; return $state
}

# ------------------------------------------------------------------------------
function Stop-SRxTermBatchOperation {
    [CmdletBinding()] param([Parameter(Mandatory)]$Operation)
    try {
        if ($Operation.ProcessId -gt 0) {
            $p = Get-Process -Id $Operation.ProcessId -ErrorAction SilentlyContinue
            if ($p -and -not $p.HasExited) { Stop-Process -Id $Operation.ProcessId -Force -ErrorAction Stop; return $true }
        }
    } catch { }
    return $false
}

# ------------------------------------------------------------------------------
# Cleanup a finished operation's temp files.
# ------------------------------------------------------------------------------
function Remove-SRxBatchTempFiles {
    [CmdletBinding()] param([Parameter(Mandatory)]$Operation)
    foreach ($p in @($Operation.ResultPath, $Operation.DesiredPath, $Operation.OriginalPath)) {
        try { if ($p -and (Test-Path $p)) { Remove-Item $p -Force -ErrorAction SilentlyContinue } } catch { }
    }
}

# ------------------------------------------------------------------------------
# Whole-node local reconciliation after a successful batch. Replaces the term's
# ENTIRE CustomProperties element in termSet.xml with $AfterRaw (chunk keys as
# returned by the store), regenerates JSON copies, reloads the in-memory DB.
# Returns the logical property hashtable for grid/node refresh.
# ------------------------------------------------------------------------------
function Sync-SRxLocalTermBatch {
    [CmdletBinding()]
    param(
        [Parameter(Mandatory)] [string]$TermId,
        [Parameter(Mandatory)] $AfterRaw   # hashtable OR PSCustomObject of raw props (incl. chunks)
    )
    $result = [pscustomobject]@{ Success=$false; Error=$null; CustomProperties=@{}; OuterXml=$null }

    try {
        # normalize AfterRaw to a hashtable
        $raw = @{}
        if ($AfterRaw -is [hashtable]) {
            foreach ($k in $AfterRaw.Keys) { $raw[$k] = [string]$AfterRaw[$k] }
        } else {
            foreach ($p in $AfterRaw.PSObject.Properties) { $raw[$p.Name] = [string]$p.Value }
        }

        $profileFolder = Get-SRxProfileFolderPath
        $schemaPath    = Join-Path $profileFolder 'termSet.xml'
        if (-not (Test-Path $schemaPath)) { throw "termSet.xml not found at $schemaPath" }

        [xml]$xml = Get-Content -LiteralPath $schemaPath -Raw -Encoding UTF8
        $ns = New-Object System.Xml.XmlNamespaceManager($xml.NameTable)
        $ns.AddNamespace('pnp', $script:SRxPnpNs)

        $termNode = $xml.SelectSingleNode("//pnp:Term[@ID='$TermId']", $ns)
        if (-not $termNode) { throw "Term node ID='$TermId' not found in termSet.xml" }

        # Replace the whole CustomProperties element.
        $cpNode = $termNode.SelectSingleNode('pnp:CustomProperties', $ns)
        if ($cpNode) { [void]$termNode.RemoveChild($cpNode) }
        if ($raw.Count -gt 0) {
            $cpNode = $xml.CreateElement('pnp', 'CustomProperties', $script:SRxPnpNs)
            [void]$termNode.AppendChild($cpNode)
            foreach ($k in ($raw.Keys | Sort-Object)) {
                $prop = $xml.CreateElement('pnp', 'Property', $script:SRxPnpNs)
                $prop.SetAttribute('Key',   $k)
                $prop.SetAttribute('Value', $raw[$k])
                [void]$cpNode.AppendChild($prop)
            }
        }

        $xml.Save($schemaPath)

        # Regenerate JSON copies (identical pipeline to Save-SRxProfile).
        $jsonPath = Join-Path $profileFolder 'ProvisioningDatabase.json'
        $minPath  = Join-Path $profileFolder 'ProvisioningDatabase.min.json'
        [xml[]] (Get-Content -Raw -Encoding UTF8 -Path $schemaPath) `
            | Convert-SRxFromXML | ConvertTo-Json -Depth 50 | Format-SRxJson -Indentation 1 `
            | Set-Content -LiteralPath $jsonPath -Encoding UTF8
        [xml[]] (Get-Content -Raw -Encoding UTF8 -Path $schemaPath) `
            | Convert-SRxFromXML | ConvertTo-Json -Depth 50 | Format-SRxJson -Minify `
            | Set-Content -LiteralPath $minPath -Encoding UTF8

        if (Get-Command Connect-SRxProvisioningDatabase_JSON -ErrorAction SilentlyContinue) {
            Connect-SRxProvisioningDatabase_JSON | Out-Null
        }

        $result.Success          = $true
        $result.CustomProperties = ConvertTo-SRxLogicalProps -Raw $raw
        $result.OuterXml         = $termNode.OuterXml
        return $result
    }
    catch { $result.Error = $_.Exception.Message; return $result }
}

Export-ModuleMember -Function `
    Get-SRxProfileFolderPath, `
    ConvertTo-SRxLogicalProps, `
    Split-SRxChunkedProperties, `
    Start-SRxTermBatchOperation, `
    Get-SRxTermBatchOperationState, `
    Stop-SRxTermBatchOperation, `
    Remove-SRxBatchTempFiles, `
    Sync-SRxLocalTermBatch