FilterBox/SRxChangeQueue.psm1

<#
================================================================================
  SRxChangeQueue.psm1
  -----------------------------------------------------------------------------
  Self-contained DEFERRED CHANGE QUEUE for customizations (Round 4, item 4).
  Encapsulated so neither TemplateExplorer nor SRxFilterBoxControl grows.

  Implements the architecture doc's model:
    * KEYED DICTIONARY (not a raw queue) keyed by "<xPath>|<optype>|<scope>"
      -> free de-duplication: the last edit for a node overwrites the entry.
    * Ordered by a monotonic Seq (insertion order preserved for commit + review).
    * Owns its own commit orchestration: starts one isolated worker per entry,
      polls them, reports progress, and drops entries as they succeed.

  Host contract (all optional scriptblocks):
      OnStatus($text) - status bar text
      OnCountChanged($count) - queue size changed (show/hide Save button)
      OnEntryCommitted($entry,$state) - a single entry finished (success or not)
      OnAllCompleted($ok,$failed) - the whole batch finished

  Commit needs a pack builder supplied by the host, because only the host can
  walk the XML tree ancestry: $packBuilder = { param($entry) ... BranchPack }

  Requires SRxFilterBox.psm1 (Start/Get/Stop-SRxFilterBranchOperation).
================================================================================
#>


Import-Module (Join-Path $PSScriptRoot 'SRxFilterBox.psm1') -DisableNameChecking -Force -ErrorAction SilentlyContinue

class SRxChangeQueue {

    hidden [System.Collections.Hashtable] $_entries = @{}     # key -> entry
    hidden [int] $_seq = 0

    # ---- commit machinery ----
    hidden [System.Windows.Forms.Timer] $_timer = $null
    hidden [System.Collections.Hashtable] $_active = @{}      # pid -> @{ Entry; Op }
    hidden [int] $_okCount = 0
    hidden [int] $_failCount = 0

    [string] $SourcePath = ''
    [scriptblock] $OnStatus = $null
    [scriptblock] $OnCountChanged = $null
    [scriptblock] $OnEntryCommitted = $null
    [scriptblock] $OnAllCompleted = $null

    SRxChangeQueue() {
        $this._timer = [System.Windows.Forms.Timer]::new()
        $this._timer.Interval = 500
        $this._timer.Tag = $this
        $this._timer.add_tick({
            param($s,$e)
            $ErrorActionPreference = 'Continue'
            try { $s.Tag.pollCommits() } catch { Write-Host "SRxChangeQueue tick: $($_.Exception.Message)" }
        })
    }

    # ================================================================ identity
    static [string] MakeKey([string]$xPath, [int]$optype, [int]$scope) {
        return ("{0}|{1}|{2}" -f $xPath, $optype, $scope)
    }

    # ================================================================ mutation
    # $kind: 'Include' | 'Exclude' | 'Reset' | 'Modified'
    [void] Upsert([string]$xPath, [int]$scope, [int]$optype, [string]$kind, [hashtable]$attributes, $node) {
        try {
            if ([string]::IsNullOrEmpty($xPath)) { return }
            $key = [SRxChangeQueue]::MakeKey($xPath, $optype, $scope)

            $before   = $this._entries.Count
            $prevKind = $null
            if ($this._entries.ContainsKey($key)) { $prevKind = [string]$this._entries[$key].Kind }

            $seq = $this._seq
            if ($this._entries.ContainsKey($key)) { $seq = [int]$this._entries[$key].Seq }
            else { $this._seq++; $seq = $this._seq }

            $attrs = @{}
            if ($attributes) { foreach ($k in $attributes.Keys) { $attrs[[string]$k] = [string]$attributes[$k] } }

            $this._entries[$key] = [pscustomobject]@{
                Key        = $key
                XPath      = $xPath
                Scope      = $scope
                Optype     = $optype
                Kind       = $kind
                Attributes = $attrs
                Node       = $node
                Origin     = 'user'
                Seq        = $seq
                Error      = $null
            }

            # only notify when the queue SHAPE changed (new entry, or kind changed)
            if (($this._entries.Count -ne $before) -or ($prevKind -ne $kind)) { $this.raiseCount() }
        } catch { Write-Host "SRxChangeQueue.Upsert - $($_.Exception.Message)" }
    }

    [bool] RemoveNode([string]$xPath, [int]$scope, [int]$optype) {
        $key = [SRxChangeQueue]::MakeKey($xPath, $optype, $scope)
        if ($this._entries.ContainsKey($key)) {
            [void]$this._entries.Remove($key)
            $this.raiseCount()
            return $true
        }
        return $false
    }

    [void] Clear() { $this._entries.Clear(); $this.raiseCount() }

    # ================================================================ query
    [int] Count() { return $this._entries.Count }

    [object[]] Entries() {
        return @($this._entries.Values | Sort-Object { [int]$_.Seq })
    }

    [object] GetNode([string]$xPath, [int]$scope, [int]$optype) {
        $key = [SRxChangeQueue]::MakeKey($xPath, $optype, $scope)
        if ($this._entries.ContainsKey($key)) { return $this._entries[$key] }
        return $null
    }

    # Overlay for recolouring: xPath -> pending kind, for ONE layer.
    [hashtable] OverlayForScope([int]$scope) {
        $map = @{}
        foreach ($e in $this._entries.Values) {
            if ([int]$e.Scope -ne $scope) { continue }
            $map[[string]$e.XPath] = [pscustomobject]@{
                Exclude  = ($e.Kind -eq 'Exclude')
                Include  = ($e.Kind -eq 'Include')
                Modified = ($e.Kind -eq 'Modified')
                Reset    = ($e.Kind -eq 'Reset')
            }
        }
        return $map
    }

    # ================================================================ commit
    # $packBuilder: { param($entry) -> BranchPack hashtable (or $null to skip) }
    [void] CommitAll($packBuilder) {
        try {
            if ($this._entries.Count -eq 0) { return }
            if (-not $packBuilder) { $this.status('Cannot commit: no pack builder supplied.'); return }
            if ($this._active.Count -gt 0) { $this.status('Commit already in progress...'); return }

            $this._okCount = 0
            $this._failCount = 0
            $started = 0

            foreach ($entry in $this.Entries()) {
                $pack = $null
                try { $pack = & $packBuilder $entry } catch { $pack = $null }
                if ($null -eq $pack) {
                    $entry.Error = 'pack build failed'
                    $this._failCount++
                    continue
                }
                $op = Start-SRxFilterBranchOperation -Pack ([hashtable]$pack) -SourcePath $this.SourcePath -HideWindow
                if ($op.StartError) {
                    $entry.Error = $op.StartError
                    $this._failCount++
                    continue
                }
                $this._active[[string]$op.ProcessId] = @{ Entry = $entry; Op = $op }
                $started++
            }

            if ($started -gt 0) {
                $this.status("Committing $started customization(s)...")
                if (-not $this._timer.Enabled) { $this._timer.Start() }
            } else {
                $this.status("Nothing committed ($($this._failCount) failed to start).")
                $this.raiseAllCompleted()
            }
        } catch { Write-Host "SRxChangeQueue.CommitAll - $($_.Exception.Message)" }
    }

    hidden [void] pollCommits() {
        if ($this._active.Count -eq 0) { $this._timer.Stop(); $this.raiseAllCompleted(); return }
        $done = @()
        foreach ($kv in @($this._active.GetEnumerator())) {
            $rec = $kv.Value
            $st = $null
            try { $st = Get-SRxFilterBranchOperationState -Operation $rec.Op } catch { }
            if ($null -eq $st) { continue }
            if ($st.State -eq 'Running') { continue }

            if ($st.State -eq 'Completed' -and $st.Success) {
                $this._okCount++
                [void]$this._entries.Remove($rec.Entry.Key)          # committed -> leaves the queue
            } else {
                $this._failCount++
                $rec.Entry.Error = [string]$st.Error                  # stays queued, marked
            }
            try { Remove-SRxFilterBranchTempFiles -Operation $rec.Op } catch { }
            if ($this.OnEntryCommitted) { try { & $this.OnEntryCommitted $rec.Entry $st } catch { } }
            $done += $kv.Key
        }
        foreach ($k in $done) { [void]$this._active.Remove($k) }
        $this.raiseCount()

        if ($this._active.Count -eq 0) {
            $this._timer.Stop()
            $this.raiseAllCompleted()
        }
    }

    [void] Shutdown() {
        try {
            if ($this._timer) { $this._timer.Stop() }
            foreach ($kv in @($this._active.GetEnumerator())) {
                try { Stop-SRxFilterBranchOperation -Operation $kv.Value.Op | Out-Null } catch { }
            }
            $this._active.Clear()
        } catch { }
    }

    # ================================================================ events
    hidden [void] status([string]$t) { if ($this.OnStatus) { try { & $this.OnStatus $t } catch { } } }
    hidden [void] raiseCount() { if ($this.OnCountChanged) { try { & $this.OnCountChanged $this._entries.Count } catch { } } }
    hidden [void] raiseAllCompleted() {
        $this.status("Commit finished: $($this._okCount) saved, $($this._failCount) failed.")
        if ($this.OnAllCompleted) { try { & $this.OnAllCompleted $this._okCount $this._failCount } catch { } }
    }
}

function New-SRxChangeQueue {
    [OutputType([object])]
    param()
    return [SRxChangeQueue]::new()
}

Export-ModuleMember -Function New-SRxChangeQueue