FilterBox/SRxCustomizationJson.psm1

<#
================================================================================
  SRxCustomizationJson.psm1
  -----------------------------------------------------------------------------
  Shared, PURE helpers for the JSON customization storage format. No PnP/CSOM,
  no WinForms - safe to import in the worker, the reader context, and Explorer.

  A JSON customization = ONE term under a layer's "Customizations" host, carrying
  the customization subtree (in the provisioning-DB shape the reader already
  consumes) as a gzip+base64 payload, chunked at 9000 chars.

  Mandatory term custom properties:
     ts_ProvisioningActivity = 'Optimize-SiteTemplate'
     ts_CustomizationFormat = 'JSON'
     ts_CustomizationKey = '<leaf xPath>|<optype>|<scope>'
     ts_CustomizationJson = <payload> (writer may store as-is; CSOM chunks to _0.._n)

  Subtree shape (matches Convert-SRxFromXML / the reader's *_JSON helpers):
     { "Name":"..", "IsDeprecated":false,
       "CustomProperties":[ {"Key":"..","Value":".."}, .. ],
       "Terms":[ { ..same.. } ] }
================================================================================
#>


$script:SRxJsonChunkSize = 9000

# ---------- gzip + base64 (compress/expand) ----------------------------------
function Compress-SRxText {
    [CmdletBinding()] param([Parameter(Mandatory)][AllowEmptyString()][string]$Text)
    if ([string]::IsNullOrEmpty($Text)) { return '' }
    $bytes = [System.Text.Encoding]::UTF8.GetBytes($Text)
    $ms = New-Object System.IO.MemoryStream
    $gz = New-Object System.IO.Compression.GZipStream($ms, [System.IO.Compression.CompressionMode]::Compress)
    $gz.Write($bytes, 0, $bytes.Length)
    $gz.Close()
    $out = [Convert]::ToBase64String($ms.ToArray())
    $ms.Dispose()
    return $out
}

function Expand-SRxText {
    [CmdletBinding()] param([Parameter(Mandatory)][AllowEmptyString()][string]$Base64)
    if ([string]::IsNullOrEmpty($Base64)) { return '' }
    $data = [Convert]::FromBase64String($Base64)
    $ms = New-Object System.IO.MemoryStream(,$data)
    $gz = New-Object System.IO.Compression.GZipStream($ms, [System.IO.Compression.CompressionMode]::Decompress)
    $sr = New-Object System.IO.StreamReader($gz, [System.Text.Encoding]::UTF8)
    $out = $sr.ReadToEnd()
    $sr.Close(); $gz.Dispose(); $ms.Dispose()
    return $out
}

# ---------- chunk / join a long property value -------------------------------
# Split -> @{ 'ts_CustomizationJson_0'=..; ... } (only if > chunk size; else single base key)
function Split-SRxJsonProperty {
    [CmdletBinding()]
    param([Parameter(Mandatory)][string]$BaseKey, [Parameter(Mandatory)][AllowEmptyString()][string]$Value, [int]$ChunkSize = $script:SRxJsonChunkSize)
    $out = @{}
    if ($Value.Length -le $ChunkSize) { $out[$BaseKey] = $Value; return $out }
    $parts = @($Value -split ("(.{$ChunkSize})") | Where-Object { $_ })
    for ([int]$i = 0; $i -lt $parts.Count; $i++) { $out["$($BaseKey)_$($i)"] = $parts[$i] }
    return $out
}

# Reassemble ts_CustomizationJson (single or _0.._n) from a properties-array/hashtable.
function Join-SRxJsonProperty {
    [CmdletBinding()]
    param([Parameter(Mandatory)]$Properties, [string]$BaseKey = 'ts_CustomizationJson')
    # normalize to @{key=value}
    $map = @{}
    if ($Properties -is [hashtable]) { foreach ($k in $Properties.Keys) { $map[[string]$k] = [string]$Properties[$k] } }
    else {
        foreach ($p in @($Properties)) {
            if ($null -eq $p) { continue }
            $k = if ($p.PSObject.Properties['Key']) { [string]$p.Key } else { $null }
            if ($null -ne $k) { $map[$k] = [string]$p.Value }
        }
    }
    if ($map.ContainsKey($BaseKey)) { return [string]$map[$BaseKey] }
    $chunkKeys = @($map.Keys | Where-Object { $_ -match ("^" + [regex]::Escape($BaseKey) + "_\d+$") } |
                   Sort-Object { [int]($_ -replace ("^" + [regex]::Escape($BaseKey) + "_"), '') })
    if ($chunkKeys.Count -eq 0) { return $null }
    $sb = New-Object System.Text.StringBuilder
    foreach ($ck in $chunkKeys) { [void]$sb.Append($map[$ck]) }
    return $sb.ToString()
}

# ---------- identity key ------------------------------------------------------
function Get-SRxCustomizationKey {
    [CmdletBinding()]
    param([Parameter(Mandatory)][AllowEmptyString()][string]$LeafXPath, [int]$Optype = -1, [int]$Scope = -1)
    return ("{0}|{1}|{2}" -f $LeafXPath, $Optype, $Scope)
}

function Get-SRxShortHash {
    [CmdletBinding()] param([Parameter(Mandatory)][string]$Text)
    $md5 = [System.Security.Cryptography.MD5]::Create()
    $bytes = $md5.ComputeHash([System.Text.Encoding]::UTF8.GetBytes($Text))
    $md5.Dispose()
    return (($bytes | ForEach-Object { $_.ToString('x2') }) -join '').Substring(0, 8)
}

# ---------- attribute helpers -------------------------------------------------
function ConvertTo-SRxAttrHash {
    param($Attributes)
    $h = @{}
    if ($Attributes -is [hashtable]) { foreach ($k in $Attributes.Keys) { $h[[string]$k] = [string]$Attributes[$k] } }
    elseif ($null -ne $Attributes) { foreach ($p in $Attributes.PSObject.Properties) { $h[$p.Name] = [string]$p.Value } }
    return $h
}

# ---------- BranchPack (leaf-first) -> nested subtree (topmost root) ----------
# Each node: { Name; IsDeprecated; CustomProperties:[{Key,Value}]; Terms:[..] }.
function ConvertTo-SRxCustomizationSubtree {
    [CmdletBinding()] param([Parameter(Mandatory)]$Branch)
    $arr = @($Branch)
    if ($arr.Count -eq 0) { return $null }
    $node = $null
    for ([int]$i = 0; $i -lt $arr.Count; $i++) {   # i=0 leaf -> build upward
        $d = $arr[$i]
        $attrs = ConvertTo-SRxAttrHash $d.Attributes
        $isDep = $false
        if ($attrs.ContainsKey('ts_Deprecated')) { $isDep = ([string]$attrs['ts_Deprecated'] -ieq 'true') }
        $cp = @()
        foreach ($k in ($attrs.Keys | Sort-Object)) { $cp += [pscustomobject]@{ Key = [string]$k; Value = [string]$attrs[$k] } }
        $newNode = [pscustomobject]@{
            Name             = [string]$d.Name
            IsDeprecated     = [bool]$isDep
            CustomProperties = $cp
            Terms            = @()
        }
        if ($null -ne $node) { $newNode.Terms = @($node) }
        $node = $newNode
    }
    return $node   # topmost ancestor
}

# ---------- normalize a deserialized subtree (from ConvertFrom-Json) ----------
# Ensures arrays + IsDeprecated on every node, so the reader's *_JSON helpers
# consume it identically to a live term subtree.
function Repair-SRxSubtree {
    [CmdletBinding()] param([Parameter(Mandatory)]$Node)
    if ($null -eq $Node) { return $null }
    $cp = @()
    if ($Node.PSObject.Properties['CustomProperties'] -and $null -ne $Node.CustomProperties) {
        foreach ($p in @($Node.CustomProperties)) {
            if ($null -eq $p) { continue }
            $cp += [pscustomobject]@{ Key = [string]$p.Key; Value = [string]$p.Value }
        }
    }
    $terms = @()
    if ($Node.PSObject.Properties['Terms'] -and $null -ne $Node.Terms) {
        foreach ($t in @($Node.Terms)) { $rt = Repair-SRxSubtree -Node $t; if ($rt) { $terms += $rt } }
    }
    $isDep = $false
    if ($Node.PSObject.Properties['IsDeprecated']) { $isDep = [bool]$Node.IsDeprecated }
    return [pscustomobject]@{
        Name             = [string]$Node.Name
        IsDeprecated     = [bool]$isDep
        CustomProperties = $cp
        Terms            = $terms
    }
}

# ---------- reader-facing: term (with CustomProperties) -> normalized root ----
# $Term is a provisioning-DB term object whose CustomProperties hold the JSON
# payload (single or chunked). Returns the reassembled, normalized subtree root.
function Expand-SRxJsonCustomization {
    [CmdletBinding()] param([Parameter(Mandatory)]$Term)
    try {
        $props = if ($Term.PSObject.Properties['CustomProperties']) { $Term.CustomProperties } else { @() }
        $payload = Join-SRxJsonProperty -Properties $props -BaseKey 'ts_CustomizationJson'
        if ([string]::IsNullOrEmpty($payload)) { return $null }
        $json = Expand-SRxText -Base64 $payload
        if ([string]::IsNullOrEmpty($json)) { return $null }
        $obj = $json | ConvertFrom-Json
        return (Repair-SRxSubtree -Node $obj)
    } catch { Write-Host "Expand-SRxJsonCustomization - $($_.Exception.Message)"; return $null }
}

# ---------- writer-facing: BranchPack -> single container term descriptor -----
# Produces the single-node "nodesStack" term for CustomizationWriter.Start:
# Name = stable 'C_<hash(key)>' (idempotent upsert by name)
# Attributes = { ts_ProvisioningActivity; ts_CustomizationFormat=JSON;
# ts_CustomizationKey; ts_CustomizationJson=<compressed payload> }
# CustomizationWriter chunks the long ts_CustomizationJson to _0.._n automatically.
function New-SRxJsonContainerTerm {
    [CmdletBinding()] param([Parameter(Mandatory)]$Pack, [Parameter(Mandatory)][string]$Operation)
    $branch  = $Pack.Branch
    $leafX   = ''
    if (@($branch).Count -gt 0) { $leafX = [string](@($branch)[0].XPath) }
    $optype = -1
    if ($Pack.PSObject.Properties['Optype'] -and $null -ne $Pack.Optype) { $optype = [int]$Pack.Optype }
    $scope  = -1
    if ($Pack.PSObject.Properties['Scope']  -and $null -ne $Pack.Scope)  { $scope  = [int]$Pack.Scope  }    $key     = Get-SRxCustomizationKey -LeafXPath $leafX -Optype $optype -Scope $scope
    $name    = 'C_' + (Get-SRxShortHash $key)

    $attrs = @{
        'ts_ProvisioningActivity' = 'Optimize-SiteTemplate'
        'ts_CustomizationFormat'  = 'JSON'
        'ts_CustomizationKey'     = $key
    }
    if ($Operation -ne 'remove') {
        $subtree = ConvertTo-SRxCustomizationSubtree -Branch $branch
        $json    = $subtree | ConvertTo-Json -Depth 60 -Compress
        $attrs['ts_CustomizationJson'] = (Compress-SRxText -Text $json)
    }

    return [pscustomobject]@{
        Name       = $name
        Attributes = $attrs
        Scope      = $scope
        xPath      = $leafX
        Operation  = $Operation
        optype     = $optype
        Key        = $key
    }
}

Export-ModuleMember -Function `
    Compress-SRxText, Expand-SRxText, `
    Split-SRxJsonProperty, Join-SRxJsonProperty, `
    Get-SRxCustomizationKey, Get-SRxShortHash, `
    ConvertTo-SRxAttrHash, ConvertTo-SRxCustomizationSubtree, Repair-SRxSubtree, `
    Expand-SRxJsonCustomization, New-SRxJsonContainerTerm