FilterBox/SRxFilterBoxControl.psm1

<#
================================================================================
  SRxFilterBoxControl.psm1 (Iteration 1 SCAFFOLD)
  -----------------------------------------------------------------------------
  Self-contained WinForms UserControl for the "Filter Box": edits attributes of a
  read-only XML template node and authors customization Filters (change value /
  Exclude node / Include node / Reset), then commits a Term BRANCH to the Termstore
  via an isolated worker (Write-SRxFilterBranch.ps1) - mirroring the Property Box.

  MECHANICS (learned from Property Box):
   * Host uses this by COMPOSITION + DYNAMIC DISPATCH (untyped field). No 'using module'.
   * WinForms/Drawing must be loaded BEFORE this module is imported.
   * Create via factory New-SRxFilterBoxControl.
   * Import the logic module (SRxFilterBox.psm1) INSIDE this module (scope isolation).

  SIMPLIFIED STATE (architecture doc §6) - per XML tree node .Tag:
     @{ XPath; XmlAttributes(@{key=originalValue}); LayerFilters(@{0..3=layerFilter});
        Draft($null | @{Attributes=@{}; Exclude; Include}) }
  Grid is Key|Value, name-based cells (no numeric indexes). Key readonly; Value red when
  != XmlAttributes[key]. Exclude/Include are node-level toggles; Reset clears active layer.

  SCAFFOLD: constructor builds the layout + contract; heavy methods are stubbed with a
  clear TODO marker to fill with the merge/dirty/build-branch logic in the next pass.
================================================================================
#>


# Bring the Filter Box logic into THIS module's scope (start/poll/kill/sync/reload +
# the pure Get-SRxEffectiveFilter merge function).
Import-Module (Join-Path $PSScriptRoot 'SRxFilterBox.psm1') -DisableNameChecking -Force -ErrorAction SilentlyContinue

class SRxFilterBoxControl : System.Windows.Forms.UserControl {

    # ---- public config ----
    [string] $SourcePath = ''
    [hashtable] $Icons = @{}

    # ---- public callbacks (host provides) ----
    [scriptblock] $OnStatus = $null                    # & $OnStatus $text
    [scriptblock] $OnClose = $null                     # & $OnClose
    [scriptblock] $OnLayerContext = $null              # & $OnLayerContext -> {Scope;Optype;Environment;Design;TermID;ScopeTitle}
    [scriptblock] $OnExecuteEditCustomizations = $null # & $OnExecuteEditCustomizations (host runs Edit-Customizations ruleset)
    [scriptblock] $OnFiltersChanged = $null            # & $OnFiltersChanged $filterNodes (host recolors main tree)
    [bool] $DeferredMode = $false
    [bool] $ReadOnlyMode = $false                      # Composition simulation - view only
    [scriptblock] $OnPendingChanged = $null            # & $OnPendingChanged $pendingChange (deferred only)
    [scriptblock] $QueueLookup = $null                 # & $QueueLookup $xpath -> {Kind;Attributes} | $null
    hidden [System.Collections.Hashtable] $_cellDiffState = @{}   # key -> [bool] differs
    [scriptblock] $OnBuildBranchPack = $null           # & $OnBuildBranchPack $ctx $draft $operation -> BranchPack hashtable

    # ---- internal UI ----
    hidden [System.Windows.Forms.ToolStrip] $_title = $null
    hidden [System.Windows.Forms.ToolStripLabel] $_titleLabel = $null
    hidden [System.Windows.Forms.ToolStripButton] $_btnSave = $null
    #hidden [System.Windows.Forms.ToolStripLabel] $_lblKind = $null
    hidden [System.Windows.Forms.ToolStripSplitButton] $_btnFilter = $null
    hidden [System.Windows.Forms.ToolStripMenuItem] $_miExclude = $null
    hidden [System.Windows.Forms.ToolStripMenuItem] $_miInclude = $null
    hidden [System.Windows.Forms.ToolStripMenuItem] $_miReset = $null
    hidden [System.Windows.Forms.ToolStripButton] $_btnClose = $null
    hidden [System.Windows.Forms.DataGridView] $_dataGridView = $null
    hidden [System.Windows.Forms.Panel] $_overlay = $null
    hidden [System.Windows.Forms.Label] $_overlayMsg = $null
    hidden [System.Windows.Forms.Button] $_overlaySave = $null
    hidden [System.Windows.Forms.Button] $_overlayDiscard = $null
    hidden [System.Windows.Forms.Button] $_overlayCancel = $null

    # ---- global manager bar (built here, docked by host) ----
    hidden [System.Windows.Forms.Panel] $_globalMgr = $null
    hidden [System.Windows.Forms.Label] $_globalMgrLabel = $null
    hidden [System.Windows.Forms.PictureBox] $_globalMgrKill = $null
    hidden [System.Windows.Forms.PictureBox] $_globalMgrBusy = $null

    # ---- engine ----
    hidden [System.Windows.Forms.Timer] $_batchTimer = $null
    hidden [System.Windows.Forms.Timer] $_spinTimer = $null
    hidden [System.Windows.Forms.Timer] $_dbReloadTimer = $null
    hidden [System.Collections.Hashtable] $_activeBatches = @{}
    hidden [boolean] $_suppressGuard = $false

    # ---- current context + pending overlay action ----
    hidden [object] $_currentCtx = $null          # {XPath; XmlAttributes; DisplayName; Token(Tag byref)}
    hidden [object] $_layer = $null               # last OnLayerContext result
    hidden [object] $_pendingTargetCtx = $null
    hidden [string] $_pendingAction = ''

    hidden [System.Drawing.Font] $_strikeFont = $null

    SRxFilterBoxControl() {
        $this.Dock = [System.Windows.Forms.DockStyle]::Fill
        $this.buildLayout()
        $this.buildGlobalManager()
        $this.buildTimers()
    }

    # ========================================================== layout
    hidden [void] buildLayout() {
        $this._title = [System.Windows.Forms.ToolStrip]::new()
        $this._title.Dock = [System.Windows.Forms.DockStyle]::Top
        $this._title.GripStyle = [System.Windows.Forms.ToolStripGripStyle]::Hidden
        $this._title.BackColor = [System.Drawing.SystemColors]::ControlLight
        $this._title.ImageScalingSize = [System.Drawing.Size]::new(20, 20)
        # Cosmetic fix: match the fixed 35px header-bar height used by the
        # left-hand tree panel's own top bar (Explorer.ps1's _toolStripTree,
        # hosted in a TableLayoutPanel row with RowStyles.Item(0).Height=35).
        # Left unset, a ToolStrip auto-sizes to a shorter height than that,
        # so the two header bars looked visually mismatched side-by-side.
        $this._title.AutoSize = $false
        $this._title.Height = 35

        $this._btnSave = [System.Windows.Forms.ToolStripButton]::new()
        $this._btnSave.DisplayStyle = [System.Windows.Forms.ToolStripItemDisplayStyle]::Image
        $this._btnSave.Alignment = [System.Windows.Forms.ToolStripItemAlignment]::Left
        $this._btnSave.ImageTransparentColor = [System.Drawing.Color]::Magenta;
        #$this._btnSave.AutoSize = $false
        # Cosmetic fix: widened from 24 to 29 to match the button sizing
        # convention used on the left-hand tree panel's own top bar
        # (Explorer.ps1's _buttonProperties/_buttonTerminal, both 29x24).
        $this._btnSave.Size = [System.Drawing.Size]::new(29, 24)
        $this._btnSave.AutoToolTip = $false
        $this._btnSave.Enabled = $false
        $this._btnSave.Visible = $false
        $this._btnSave.Tag = $this
        $this._btnSave.add_Click({ param($s, $e) $s.Tag.onSaveButtonClick() })

        # Filter split button: [icon v] Exclude / Include / --- / Reset
        $this._btnFilter = [System.Windows.Forms.ToolStripSplitButton]::new()

        $this._btnFilter.DisplayStyle = [System.Windows.Forms.ToolStripItemDisplayStyle]::Text
        $this._btnFilter.AutoSize = $true
        $this._btnFilter.Text = ''
        $this._btnFilter.ForeColor = [System.Drawing.SystemColors]::ControlText
        $this._btnFilter.Font = [System.Drawing.Font]::new('Segoe UI', 9, [System.Drawing.FontStyle]::Regular)
        $this._btnFilter.TextAlign = [System.Drawing.ContentAlignment]::MiddleLeft
        $this._btnFilter.AutoToolTip  = $false
        $this._btnFilter.ToolTipText  = ''

        $this._btnFilter.Alignment = [System.Windows.Forms.ToolStripItemAlignment]::Left
        # Cosmetic fix: widened from 36 to 41 (same +5 adjustment as the
        # other header buttons here) to match the left-hand tree panel's
        # button sizing convention (Explorer.ps1's _buttonProperties/
        # _buttonTerminal, both 29x24 - this is a split button so it keeps
        # its own wider base width, just nudged by the same amount).
        $this._btnFilter.Tag = $this

        $this._miInclude = [System.Windows.Forms.ToolStripMenuItem]::new()
        $this._miInclude.Text = 'Include XML Node'
        $this._miInclude.CheckOnClick = $false
        $this._miInclude.Tag = $this
        $this._miInclude.AutoToolTip  = $false
        $this._miInclude.ToolTipText  = ''
        $this._miInclude.add_Click({ param($s, $e) $s.Tag.onSelectFilterKind('Include') })

        $this._miExclude = [System.Windows.Forms.ToolStripMenuItem]::new()
        $this._miExclude.Text = 'Exclude XML Node'
        $this._miExclude.CheckOnClick = $false
        $this._miExclude.Tag = $this
        $this._miExclude.AutoToolTip  = $false
        $this._miExclude.ToolTipText  = ''
        $this._miExclude.add_Click({ param($s, $e) $s.Tag.onSelectFilterKind('Exclude') })

        $this._miReset = [System.Windows.Forms.ToolStripMenuItem]::new()
        $this._miReset.Text = 'Reset Filter'
        $this._miReset.Tag = $this
        $this._miReset.AutoToolTip    = $false
        $this._miReset.ToolTipText    = ''
        $this._miReset.add_Click({ param($s, $e) $s.Tag.onSelectFilterKind('Reset') })

        [void]$this._btnFilter.DropDownItems.Add($this._miInclude)
        [void]$this._btnFilter.DropDownItems.Add($this._miExclude)
        [void]$this._btnFilter.DropDownItems.Add('-')
        [void]$this._btnFilter.DropDownItems.Add($this._miReset)

        $this._btnFilter.add_DropDownOpening({ param($s, $e) $s.Tag.onFilterDropDownOpening() })

        #$this._lblKind = [System.Windows.Forms.ToolStripLabel]::new('')
        #$this._lblKind.Name = '_lblKind'
        #$this._lblKind.Font = [System.Drawing.Font]::new('Segoe UI', 8.25, [System.Drawing.FontStyle]::Bold)
        #$this._lblKind.ForeColor = [System.Drawing.Color]::FromArgb(0, 102, 0)
        #$this._lblKind.Visible = $false

        $this._titleLabel = [System.Windows.Forms.ToolStripLabel]::new('Filter')


        $this._btnClose = [System.Windows.Forms.ToolStripButton]::new()
        $this._btnClose.Alignment = [System.Windows.Forms.ToolStripItemAlignment]::Right
        $this._btnClose.DisplayStyle = [System.Windows.Forms.ToolStripItemDisplayStyle]::Image
        $this._btnClose.ImageTransparentColor = [System.Drawing.Color]::Magenta;
# $this._btnClose.AutoSize = $false
        # Cosmetic fix: widened from 24 to 29 to match the button sizing
        # convention used on the left-hand tree panel's own top bar
        # (Explorer.ps1's _buttonProperties/_buttonTerminal, both 29x24).
        $this._btnClose.Size = [System.Drawing.Size]::new(29, 24)
        $this._btnClose.AutoToolTip = $false
        $this._btnClose.Tag = $this
        $this._btnClose.add_Click({ param($s, $e) $s.Tag.RequestClose() })

        [void]$this._title.Items.Add($this._btnSave)
        [void]$this._title.Items.Add($this._titleLabel)
        [void]$this._title.Items.Add($this._btnFilter)
        [void]$this._title.Items.Add($this._btnClose)

        # grid: Key | Value (name-based access)
        $this._dataGridView = [System.Windows.Forms.DataGridView]::new()
        $this._dataGridView.Dock = [System.Windows.Forms.DockStyle]::Fill
        $this._dataGridView.BorderStyle = 'None'
        $this._dataGridView.ColumnHeadersVisible = $false
        $this._dataGridView.RowHeadersVisible = $false
        $this._dataGridView.AllowUserToAddRows = $false
        $this._dataGridView.EditMode = [System.Windows.Forms.DataGridViewEditMode]::EditOnKeystrokeOrF2

        $colKey = [System.Windows.Forms.DataGridViewTextBoxColumn]::new()
        $colKey.Name = 'Key'; $colKey.HeaderText = 'Attribute'; $colKey.Width = 100; $colKey.ReadOnly = $true
        [void]$this._dataGridView.Columns.Add($colKey)

        $colVal = [System.Windows.Forms.DataGridViewTextBoxColumn]::new()
        $colVal.Name = 'Value'; $colVal.HeaderText = 'Value'
        $colVal.AutoSizeMode = [System.Windows.Forms.DataGridViewAutoSizeColumnMode]::Fill
        [void]$this._dataGridView.Columns.Add($colVal)

        $this._dataGridView.Tag = $this
        $this._dataGridView.add_CellValueChanged({ param($s, $e) $s.Tag.onGridValueChanged($e.RowIndex, $e.ColumnIndex) })
        $this._dataGridView.add_CellFormatting({ param($s, $e) $s.Tag.onGridFormatting($e) })
        $this._dataGridView.add_KeyDown({ param($s, $e) $s.Tag.onGridKeyDown($e) })
        $this._dataGridView.add_CurrentCellDirtyStateChanged({
                param($s, $e)
                if ($s.IsCurrentCellDirty) { $s.CommitEdit([System.Windows.Forms.DataGridViewDataErrorContexts]::Commit) }
            })
        $this._dataGridView.add_EditingControlShowing({
            param($s,$e)
            $ctl = $e.Control
            if ($null -ne $ctl) {
                $ctl.Tag = $s.Tag
                $ctl.add_KeyDown({
                    param($c,$k)
                    if ($k.KeyCode -eq [System.Windows.Forms.Keys]::Escape -or ($k.Control -and $k.KeyCode -eq [System.Windows.Forms.Keys]::Z)) {
                        $c.Tag.onGridKeyDown($k)
                    }
                })
                $ctl.add_TextChanged({
                    param($c,$ev)
                    $box = $c.Tag
                    if ($null -ne $box) { $box.onEditorTextChanged([string]$c.Text) }
                })
            }
        })



        # overlay (child of grid, Dock=Fill; Save/Discard/Cancel) - reused pattern
        $this._overlay = [System.Windows.Forms.Panel]::new()
        $this._overlay.BackColor = [System.Drawing.Color]::FromArgb(250, 250, 250)
        $this._overlay.BorderStyle = [System.Windows.Forms.BorderStyle]::FixedSingle
        $this._overlay.Visible = $false
        $this._overlay.Dock = [System.Windows.Forms.DockStyle]::Fill

        $this._overlayMsg = [System.Windows.Forms.Label]::new()
        $this._overlayMsg.AutoSize = $false
        $this._overlayMsg.Font = [System.Drawing.Font]::new('Segoe UI', 9)
        $this._overlayMsg.Location = [System.Drawing.Point]::new(16, 16)
        $this._overlayMsg.Size = [System.Drawing.Size]::new(360, 48)
        $this._overlayMsg.Text = 'You have unsaved filter changes.'
        [void]$this._overlay.Controls.Add($this._overlayMsg)

        $this._overlaySave = [System.Windows.Forms.Button]::new()
        $this._overlaySave.Text = 'Save changes'; $this._overlaySave.Size = [System.Drawing.Size]::new(160, 30)
        $this._overlaySave.Location = [System.Drawing.Point]::new(16, 76)
        $this._overlaySave.TextAlign = [System.Drawing.ContentAlignment]::MiddleLeft
        $this._overlaySave.Tag = $this
        $this._overlaySave.add_Click({ param($s, $e) $s.Tag.onOverlaySave() })
        [void]$this._overlay.Controls.Add($this._overlaySave)

        $this._overlayDiscard = [System.Windows.Forms.Button]::new()
        $this._overlayDiscard.Text = 'Discard changes'; $this._overlayDiscard.Size = [System.Drawing.Size]::new(160, 30)
        $this._overlayDiscard.Location = [System.Drawing.Point]::new(16, 112)
        $this._overlayDiscard.TextAlign = [System.Drawing.ContentAlignment]::MiddleLeft
        $this._overlayDiscard.Tag = $this
        $this._overlayDiscard.add_Click({ param($s, $e) $s.Tag.onOverlayDiscard() })
        [void]$this._overlay.Controls.Add($this._overlayDiscard)

        $this._overlayCancel = [System.Windows.Forms.Button]::new()
        $this._overlayCancel.Text = 'Cancel'; $this._overlayCancel.Size = [System.Drawing.Size]::new(160, 30)
        $this._overlayCancel.Location = [System.Drawing.Point]::new(16, 148)
        $this._overlayCancel.TextAlign = [System.Drawing.ContentAlignment]::MiddleLeft
        $this._overlayCancel.Tag = $this
        $this._overlayCancel.add_Click({ param($s, $e) $s.Tag.onOverlayCancel() })
        [void]$this._overlay.Controls.Add($this._overlayCancel)

        [void]$this._dataGridView.Controls.Add($this._overlay)

        [void]$this.Controls.Add($this._dataGridView)
        [void]$this.Controls.Add($this._title)
    }

    hidden [void] buildGlobalManager() {
        $this._globalMgr = [System.Windows.Forms.Panel]::new()
        $this._globalMgr.Height = 30
        $this._globalMgr.Dock = [System.Windows.Forms.DockStyle]::Top
        $this._globalMgr.BackColor = [System.Drawing.Color]::White
        $this._globalMgr.Visible = $false

        $this._globalMgrKill = [System.Windows.Forms.PictureBox]::new()
        $this._globalMgrKill.SizeMode = [System.Windows.Forms.PictureBoxSizeMode]::Zoom
        $this._globalMgrKill.Size = [System.Drawing.Size]::new(20, 20)
        $this._globalMgrKill.Location = [System.Drawing.Point]::new(8, 5)
        $this._globalMgrKill.Cursor = [System.Windows.Forms.Cursors]::Hand
        $this._globalMgrKill.Tag = $this
        $this._globalMgrKill.add_Click({ param($s, $e) $s.Tag.killAllBatches() })
        [void]$this._globalMgr.Controls.Add($this._globalMgrKill)

        $this._globalMgrBusy = [System.Windows.Forms.PictureBox]::new()
        $this._globalMgrBusy.SizeMode = [System.Windows.Forms.PictureBoxSizeMode]::Zoom
        $this._globalMgrBusy.Size = [System.Drawing.Size]::new(20, 20)
        $this._globalMgrBusy.Location = [System.Drawing.Point]::new(36, 5)
        $this._globalMgrBusy.Visible = $false
        [void]$this._globalMgr.Controls.Add($this._globalMgrBusy)

        $this._globalMgrLabel = [System.Windows.Forms.Label]::new()
        $this._globalMgrLabel.AutoSize = $true
        $this._globalMgrLabel.Font = [System.Drawing.Font]::new('Segoe UI', 9)
        $this._globalMgrLabel.Location = [System.Drawing.Point]::new(64, 7)
        $this._globalMgrLabel.Text = 'Filter update in progress'
        [void]$this._globalMgr.Controls.Add($this._globalMgrLabel)
    }

    hidden [void] buildTimers() {
        $this._batchTimer = [System.Windows.Forms.Timer]::new()
        $this._batchTimer.Interval = 500
        $this._batchTimer.Tag = $this
        $this._batchTimer.add_tick({
                param($s, $e)
                $ErrorActionPreference = 'Continue'
                try { $s.Tag.pollBatches() }
                catch { Write-Host "TICK batchTimer EXCEPTION: $($_.Exception.GetType().FullName): $($_.Exception.Message)" }
            })

        $this._spinTimer = [System.Windows.Forms.Timer]::new()
        $this._spinTimer.Interval = 120
        $this._spinTimer.Tag = $this
        $this._spinTimer.add_tick({
                param($s, $e)
                $ErrorActionPreference = 'Continue'
                try { $s.Tag.spinGlobalManager() }
                catch { Write-Host "TICK spinTimer EXCEPTION: $($_.Exception.GetType().FullName): $($_.Exception.Message)" }
            })

        $this._dbReloadTimer = [System.Windows.Forms.Timer]::new()
        $this._dbReloadTimer.Interval = 750
        $this._dbReloadTimer.Tag = $this
        $this._dbReloadTimer.add_tick({
                param($s, $e)
                $ErrorActionPreference = 'Continue'
                try { $s.Tag.onDbReloadTick() }
                catch { Write-Host "TICK dbReloadTimer EXCEPTION: $($_.Exception.GetType().FullName): $($_.Exception.Message)" }
            })
    }
    # ========================================================== public API
    [System.Windows.Forms.Panel] GlobalManagerPanel() { return $this._globalMgr }

    [void] ApplyIcons() {
        try {
            if ($this.Icons.ContainsKey('Save') -and $this.Icons['Save']) { $this._btnSave.Image = $this.Icons['Save'] }
            if ($this.Icons.ContainsKey('Close') -and $this.Icons['Close']) { $this._btnClose.Image = $this.Icons['Close']; $this._globalMgrKill.Image = $this.Icons['Close'] }
            if ($this.Icons.ContainsKey('Busy') -and $this.Icons['Busy']) { $this._globalMgrBusy.Image = $this.Icons['Busy'] }
            if ($this.Icons.ContainsKey('Filter00') -and $this.Icons['Filter00']) { $this._btnFilter.Image = $this.Icons['Filter00'] }
            $this.updateSaveButton()
        }
        catch { Write-Host "ApplyIcons - $($_.Exception.Message)" }
    }

    hidden [void] onEditorTextChanged([string]$text) {
        try {
            $cell = $this._dataGridView.CurrentCell
            if ($null -eq $cell) { return }
            $row = $this._dataGridView.Rows[$cell.RowIndex]
            if (-not $row.Tag) { return }
            $differs = ($text -ne [string]$row.Tag.Template)
            $fore = $(if ($differs) { [System.Drawing.Color]::Red } else { [System.Drawing.Color]::Black })
            $cell.Style.ForeColor = $fore
            $ed = $this._dataGridView.EditingControl
            if ($null -ne $ed) { $ed.ForeColor = $fore }      # colour the live editor too
        } catch { }
    }

    # an ancestor is excluded -> nothing here can ever apply
    hidden [boolean] isInheritedExcluded() {
        try {
            if ($null -eq $this._currentCtx) { return $false }
            if ($this._currentCtx.PSObject.Properties.Name -contains 'InheritedExcluded') {
                return [bool]$this._currentCtx.InheritedExcluded
            }
        } catch { }
        return $false
    }

    hidden [boolean] isGridEditable() {
        if ($this.ReadOnlyMode) { return $false }
        if ($this.isInheritedExcluded()) { return $false }            # [R4-3]
        $pending   = $this.getPendingKind()
        $committed = $this.getCommittedKind()
        if ($pending -in @('Exclude','Include','Reset')) { return $false }
        if ($pending -eq 'None' -and $committed -eq 'Include') { return $false }
        if ($pending -eq 'None' -and $committed -eq 'Exclude') { return $false }
        return $true
    }

    hidden [boolean] isGridStruck() {
        if ($this.isInheritedExcluded()) { return $true }             # [R4-3]
        $pending   = $this.getPendingKind()
        $committed = $this.getCommittedKind()
        if ($pending -eq 'Exclude') { return $true }
        if ($pending -eq 'None' -and $committed -eq 'Exclude') { return $true }
        return $false
    }

    hidden [boolean] isShowingTemplateValues() {
        if ($this.isInheritedExcluded()) { return $true }             # [R4-3]
        $pending = $this.getPendingKind()
        return ($pending -in @('Exclude','Include','Reset'))
    }


    # Bind a selected XML template node. ctx.Token is the tree node whose .Tag holds
    # XmlAttributes/LayerFilters/Draft (passed BY REFERENCE).
    [void] BindXmlNode($ctx) {
        $this._currentCtx = $ctx
        if ($this.OnLayerContext) { try { $this._layer = & $this.OnLayerContext } catch { $this._layer = $null } }
        $this.bindNodeToGrid($ctx)
    }

    # Host pushes the flat _filterNodes list (from Optimize-SiteTemplate cache) so the
    # component can derive LayerFilters for the bound node.
    [void] SetLayerFilters($filterNodes) {
        try {
            $ctx = $this._currentCtx
            if ($null -eq $ctx -or $null -eq $ctx.Token) { return }
            $xpath = [string]$ctx.XPath

            $byScope = @{}
            foreach ($fn in @($filterNodes)) {
                $fxp = [string]$this.cprop($fn, 'xPath', $this.cprop($fn, 'XPath', ''))
                if ($fxp -ne $xpath) { continue }
                $scope = [int]$this.cprop($fn, 'Scope', -1)
                if ($scope -lt 0) { continue }
                $lf = $this.filterNodeToLayer($fn)
                if ($byScope.ContainsKey($scope)) {
                    # merge multiple rows for same node+scope
                    $ex = $byScope[$scope]
                    $mergedAttrs = @{}
                    foreach ($k in (& { $ex.Attributes.Keys })) { $mergedAttrs[$k] = [string]$ex.Attributes[$k] }
                    foreach ($k in (& { $lf.Attributes.Keys })) { $mergedAttrs[$k] = [string]$lf.Attributes[$k] }
                    $byScope[$scope] = [pscustomobject]@{
                        Attributes = $mergedAttrs
                        Exclude    = ([bool]$ex.Exclude -or [bool]$lf.Exclude)
                        Include    = ([bool]$ex.Include -or [bool]$lf.Include)
                    }
                }
                else {
                    $byScope[$scope] = $lf
                }
            }

            $ctx.Token.Tag | Add-Member -NotePropertyName LayerFilters -NotePropertyValue $byScope -Force
            $this.bindNodeToGrid($ctx)
            $this.syncFilterMenuChecks()
            $this.updateSaveButton()
        }
        catch { Write-Host "SetLayerFilters - $($_.Exception.Message)" }
    }

    [bool] HasUnsavedChanges() { return $this.nodeHasChanges() }

    [bool] RequestNavigate($targetCtx) {
        try {
            if (-not $this.nodeHasChanges()) { return $true }
            $this._pendingTargetCtx = $targetCtx
            $this._pendingAction = 'switch'
            $this.showOverlay()
            return $false
        }
        catch { Write-Host "RequestNavigate - $($_.Exception.Message)"; return $true }
    }

    [void] RequestClose() {
        try {
            if ($this.nodeHasChanges()) { $this._pendingAction = 'close'; $this.showOverlay() }
            else { $this.doClose() }
        }
        catch { Write-Host "RequestClose - $($_.Exception.Message)" }
    }

    [void] Shutdown() {
        try {
            if ($this._batchTimer) { $this._batchTimer.Stop() }
            if ($this._spinTimer) { $this._spinTimer.Stop() }
            if ($this._dbReloadTimer) { $this._dbReloadTimer.Stop() }
            foreach ($entry in @($this._activeBatches.GetEnumerator())) {
                try { Stop-SRxFilterBranchOperation -Operation $entry.Value.Op | Out-Null } catch {}
            }
            $this._activeBatches.Clear()
        }
        catch { Write-Host "Shutdown - $($_.Exception.Message)" }
    }

    #Region SRxFilterBoxControl.Draft.patch
    # ---- tiny property/attribute accessors (host objects may be hashtable or PSObject) ----
    hidden [object] cprop($o, [string]$n, $d) {
        if ($null -eq $o) { return $d }
        if ($o -is [hashtable]) { if ($o.ContainsKey($n)) { return $o[$n] } else { return $d } }
        $p = $o.PSObject.Properties[$n]; if ($p) { return $p.Value } else { return $d }
    }
    hidden [hashtable] cattrs($o) {
        $a = $this.cprop($o, 'Attributes', @{})
        $h = @{}
        if ($a -is [hashtable]) { foreach ($k in $a.Keys) { $h[$k] = [string]$a[$k] } }
        elseif ($null -ne $a) { foreach ($p in $a.PSObject.Properties) { $h[$p.Name] = [string]$p.Value } }
        return $h
    }

    # ---- active layer + committed/draft accessors on the current node ----
    hidden [int] getActiveLayer() {
        if ($null -ne $this._layer) { return [int]$this.cprop($this._layer, 'Scope', -1) }
        return -1
    }
    hidden [hashtable] getLayerFilters() {
        if ($null -ne $this._currentCtx -and $null -ne $this._currentCtx.Token -and $this._currentCtx.Token.Tag) {
            $tag = $this._currentCtx.Token.Tag
            if (($tag.PSObject.Properties.Name -contains 'LayerFilters') -and $tag.LayerFilters) { return [hashtable]$tag.LayerFilters }
        }
        return @{}
    }

    # (overlay the queued change so re-selecting the node restores the view)
    hidden [object] getActiveCommittedFilter() {
        $lf = $this.getLayerFilters(); $al = $this.getActiveLayer()
        $base = $null
        if ($al -ge 0 -and $lf.ContainsKey($al)) { $base = $lf[$al] }

        $q = $this.getQueuedChange()
        if ($null -eq $q) { return $base }

        # a queued change presents as if it were committed (it is "virtually saved")
        $kind  = [string]$this.cprop($q,'Kind','None')
        $attrs = $this.cattrs($base)
        if ($kind -eq 'Modified') {
            $qa = $this.cprop($q,'Attributes',@{})
            if ($qa -is [hashtable]) { foreach ($k in $qa.Keys) { $attrs[[string]$k] = [string]$qa[$k] } }
            elseif ($null -ne $qa)   { foreach ($p in $qa.PSObject.Properties) { $attrs[$p.Name] = [string]$p.Value } }
        } elseif ($kind -in @('Exclude','Include','Reset')) {
            $attrs = @{}
        }
        return [pscustomobject]@{
            Attributes = $attrs
            Exclude    = ($kind -eq 'Exclude')
            Include    = ($kind -eq 'Include')
        }
    }

    hidden [object] getDraft() {
        if ($null -eq $this._currentCtx -or $null -eq $this._currentCtx.Token) { return $null }
        $tag = $this._currentCtx.Token.Tag
        if ($tag.PSObject.Properties.Name -contains 'Draft') { return $tag.Draft }
        return $null
    }
    hidden [object] ensureDraft() {
        $tag = $this._currentCtx.Token.Tag
        if (-not ($tag.PSObject.Properties.Name -contains 'Draft') -or $null -eq $tag.Draft) {
            $committed = $this.getActiveCommittedFilter()
            $draft = [pscustomobject]@{
                Attributes = @{}                                          # diffs vs committed only
                Exclude    = [bool]$this.cprop($committed, 'Exclude', $false)
                Include    = [bool]$this.cprop($committed, 'Include', $false)
                Reset      = $false
            }
            $tag | Add-Member -NotePropertyName Draft -NotePropertyValue $draft -Force
        }
        return $tag.Draft
    }
    # Drop the draft entirely if it no longer differs from the committed active layer.
    hidden [void] pruneDraft() {
        if ($null -eq $this._currentCtx -or $null -eq $this._currentCtx.Token) { return }
        $tag = $this._currentCtx.Token.Tag
        if (-not ($tag.PSObject.Properties.Name -contains 'Draft') -or $null -eq $tag.Draft) { return }
        $d = $tag.Draft
        $committed = $this.getActiveCommittedFilter()
        $cEx = [bool]$this.cprop($committed, 'Exclude', $false)
        $cIn = [bool]$this.cprop($committed, 'Include', $false)
        $attrCount = 0; if ($d.Attributes -is [hashtable]) { $attrCount = $d.Attributes.Count }
        if ($attrCount -eq 0 -and [bool]$d.Exclude -eq $cEx -and [bool]$d.Include -eq $cIn -and -not [bool]$d.Reset) {
            $tag.Draft = $null
        }
    }

    # Map a raw _filterNodes entry to a layerFilter { Attributes; Exclude; Include }.
    # Defensive about shape (Iteration-1: exact _filterNodes schema still being verified).
    hidden [object] filterNodeToLayer($fn) {
        $attrs = @{}
        $exclude = $false
        $include = $false
        try {
            $flt = $this.cprop($fn, 'Filter', $null)
            if ($flt -is [hashtable]) {
                foreach ($k in @($flt.Keys)) {
                    $key = [string]$k
                    $val = $flt[$k]
                    if ($key -eq 'ts_Deprecated') { if ([string]$val -ieq 'true' -or $val -eq $true) { $exclude = $true } }
                    elseif ($key -eq 'ts_CustomizationTarget') { if ([string]$val -ieq 'true' -or $val -eq $true) { $include = $true } }
                    elseif ($key -like 'ts_*') { }            # internal - hide
                    else { $attrs[$key] = [string]$val }
                }
            }
        }
        catch { Write-Host "filterNodeToLayer - $($_.Exception.Message)" }
        return [pscustomobject]@{ Attributes = $attrs; Exclude = $exclude; Include = $include }
    }

    hidden [void] notifyFiltersChanged() {
        try {
            if (-not $this.OnFiltersChanged) { return }
            if ($null -eq $this._currentCtx) { return }

            $d = $this.getDraft()
            $committed = $this.getActiveCommittedFilter()
            $reset = ($null -ne $d) -and [bool]$this.cprop($d, 'Reset', $false)

            $ex = $false; $in = $false; $mod = $false
            if (-not $reset) {
                if ($null -ne $d) {
                    $ex = [bool]$this.cprop($d, 'Exclude', $false)
                    $in = [bool]$this.cprop($d, 'Include', $false)
                    $da = $this.cattrs($d)
                    if ($da.Count -gt 0) { $mod = $true }
                }
                elseif ($null -ne $committed) {
                    $ex = [bool]$this.cprop($committed, 'Exclude', $false)
                    $in = [bool]$this.cprop($committed, 'Include', $false)
                }
                if (-not $mod -and $null -ne $committed) {
                    $ca = $this.cattrs($committed)
                    if ($ca.Count -gt 0) { $mod = $true }
                }
                # a value typed back to the template default is NOT a modification
                if ($mod) {
                    $anyDiff = $false
                    foreach ($gr in $this._dataGridView.Rows) {
                        if ($gr.IsNewRow -or -not $gr.Tag) { continue }
                        if ([string]$gr.Cells['Value'].Value -ne [string]$gr.Tag.Template) { $anyDiff = $true; break }
                    }
                    $mod = $anyDiff
                }
            }

            & $this.OnFiltersChanged ([pscustomobject]@{
                    Node     = $this._currentCtx.Token
                    XPath    = [string]$this._currentCtx.XPath
                    Exclude  = $ex
                    Include  = $in
                    Modified = $mod
                    Reset    = $reset
                    Draft    = $true
                })
        }
        catch { Write-Host "notifyFiltersChanged - $($_.Exception.Message)" }
    }

    #EndRegion

    # ========================================================== callback helpers
    hidden [void] setStatus([string]$text) { try { if ($this.OnStatus) { & $this.OnStatus $text } } catch { } }
    hidden [void] doClose() { try { if ($this.OnClose) { & $this.OnClose } } catch { } }

    # ========================================================== TODO stubs (next pass)
    # Fill these using the simplified state model (§6) + Get-SRxEffectiveFilter, and the
    # branch build from buildNodesStack (cleaned up). Stubbed so the scaffold parses & the
    # reap loop / overlay / save-button wiring all work end-to-end from day one.

    hidden [void] bindNodeToGrid($ctx) {
        try {
            $this._suppressGuard = $true
            $this._cellDiffState = @{}
            [void]$this._dataGridView.Rows.Clear()
            if ($null -ne $ctx -and $null -ne $ctx.XmlAttributes) {
                $draft = $this.getDraft()
                $showTemplate = $this.isShowingTemplateValues()
                $reset = ($null -ne $draft) -and [bool]$this.cprop($draft, 'Reset', $false)

                $committedAttrs = @{}
                if (-not $reset -and -not $showTemplate) { $committedAttrs = $this.cattrs($this.getActiveCommittedFilter()) }
                $draftAttrs = @{}
                if (-not $showTemplate) { $draftAttrs = $this.cattrs($draft) }

                $srcAttrs = $ctx.XmlAttributes
                $keys = if ($srcAttrs -is [hashtable]) { $srcAttrs.Keys } else { ($srcAttrs.PSObject.Properties | ForEach-Object { $_.Name }) }
                foreach ($k in ($keys | Sort-Object)) {
                    if ([string]$k -like 'ts_*') { continue }
                    $template     = [string]$this.cprop($srcAttrs, $k, '')
                    $committedVal = if ($committedAttrs.ContainsKey($k)) { [string]$committedAttrs[$k] } else { $template }
                    $display      = if ($draftAttrs.ContainsKey($k))     { [string]$draftAttrs[$k] }     else { $committedVal }

                    $idx = $this._dataGridView.Rows.Add()
                    $row = $this._dataGridView.Rows[$idx]
                    $row.Cells['Key'].Value   = $k
                    $row.Cells['Value'].Value = $display
                    $row.Cells['Key'].ReadOnly = $true
                    $row.Tag = [pscustomobject]@{ Key = $k; Template = $template; Committed = $committedVal }
                }
            }
            # capability
            $editable = $this.isGridEditable()
            $this._dataGridView.Columns['Value'].ReadOnly = (-not $editable)
            $this._dataGridView.ReadOnly = (-not $editable)

            $this.syncFilterMenuChecks()
            $this._dataGridView.ClearSelection()
            $this.updateSaveButton()
        } catch { Write-Host "bindNodeToGrid - $($_.Exception.Message)" }
        finally { $this._suppressGuard = $false }
    }

    hidden [boolean] nodeHasChanges() {
        if ($null -eq $this._currentCtx -or $null -eq $this._currentCtx.Token) { return $false }
        $tag = $this._currentCtx.Token.Tag
        return (($tag.PSObject.Properties.Name -contains 'Draft') -and $null -ne $tag.Draft)
    }

    hidden [void] onGridValueChanged([int]$r, [int]$c) {
        try {
            if ($this._suppressGuard) { return }
            if ($r -lt 0) { return }
            $row = $this._dataGridView.Rows[$r]
            if (-not $row.Tag) { return }
            if ($this._dataGridView.Columns[$c].Name -ne 'Value') { return }

            $val      = [string]$row.Cells['Value'].Value
            $template = [string]$row.Tag.Template
            $differs  = ($val -ne $template)
            $key      = [string]$row.Tag.Key

            $this._dataGridView.InvalidateCell($c, $r)

            # --- draft always tracks the CURRENT value (cheap, no UI work) ---
            $d = $this.ensureDraft()
            $d.Exclude = $false; $d.Include = $false; $d.Reset = $false
            if ($differs) { $d.Attributes[$key] = $val }
            elseif ($d.Attributes.ContainsKey($key)) { [void]$d.Attributes.Remove($key) }

            # --- heavy UI refresh only when the match-state FLIPPED [R5-2b] ---
            $prev = $null
            if ($this._cellDiffState.ContainsKey($key)) { $prev = [bool]$this._cellDiffState[$key] }
            $this._cellDiffState[$key] = $differs
            $flipped = ($null -eq $prev -or $prev -ne $differs)

            $anyDiff = $false
            foreach ($gr in $this._dataGridView.Rows) {
                if ($gr.IsNewRow -or -not $gr.Tag) { continue }
                if ([string]$gr.Cells['Value'].Value -ne [string]$gr.Tag.Template) { $anyDiff = $true; break }
            }

            if ($flipped) {
                $this.pruneDraft()
                $this.syncFilterMenuChecks()
                $this.updateSaveButton()
                $this.notifyFiltersChanged()
            }

            # [BUG-1] ALWAYS re-publish so the queue holds the LATEST value
            $this.raisePendingIfDeferred($(if ($anyDiff) { 'Modified' } else { 'None' }))
        } catch { Write-Host "onGridValueChanged - $($_.Exception.Message)" }
    }

    hidden [void] onGridKeyDown($e) {
        try {
            $isEsc  = ($e.KeyCode -eq [System.Windows.Forms.Keys]::Escape)
            $isUndo = ($e.Control -and ($e.KeyCode -eq [System.Windows.Forms.Keys]::Z))
            if (-not $isEsc -and -not $isUndo) { return }

            $cell = $this._dataGridView.CurrentCell
            if ($null -eq $cell) { return }
            $row = $this._dataGridView.Rows[$cell.RowIndex]
            if (-not $row.Tag) { return }
            if ($this._dataGridView.Columns[$cell.ColumnIndex].Name -ne 'Value') { return }

            # abandon the in-progress edit, then restore the committed value
            if ($this._dataGridView.IsCurrentCellInEditMode) { $this._dataGridView.CancelEdit() }
            $row.Cells['Value'].Value = $row.Tag.Committed     # triggers onGridValueChanged -> prunes draft
            $this._dataGridView.InvalidateRow($cell.RowIndex)
            $this.syncFilterMenuChecks()
            $this.updateSaveButton()
            $this.notifyFiltersChanged()
            $this.raisePendingIfDeferred('None')
            $e.Handled = $true
            $e.SuppressKeyPress = $true
        } catch { Write-Host "onGridKeyDown - $($_.Exception.Message)" }
    }

    hidden [void] onGridFormatting($e) {
        try {
            if ($e.RowIndex -lt 0) { return }
            $row = $this._dataGridView.Rows[$e.RowIndex]
            if (-not $row.Tag) { return }

            if ($this.isGridStruck()) {
                if ($null -eq $this._strikeFont) {
                    $this._strikeFont = [System.Drawing.Font]::new($this._dataGridView.Font, [System.Drawing.FontStyle]::Strikeout)
                }
                $e.CellStyle.Font = $this._strikeFont
                $e.CellStyle.ForeColor = [System.Drawing.Color]::FromArgb(160,160,160)
                return
            }
            if ($this._dataGridView.Columns[$e.ColumnIndex].Name -eq 'Value') {
                if ([string]$row.Cells['Value'].Value -ne [string]$row.Tag.Template) { $e.CellStyle.ForeColor = [System.Drawing.Color]::Red }
                else { $e.CellStyle.ForeColor = [System.Drawing.Color]::Black }
            }
        } catch { Write-Host "onGridFormatting - $($_.Exception.Message)" }
    }

    hidden [void] syncFilterMenuChecks() {
        try {
            $inherited = $this.isInheritedExcluded()
            $committed = $this.getCommittedKind()
            $pending   = $this.getPendingKind()
            $effective = if ($pending -ne 'None') { $pending } else { $committed }

            $label = switch ($effective) {
                'Include'  { 'Include XML Node ' }
                'Exclude'  { 'Exclude XML Node ' }
                'Modified' { 'XML Node Attribute ' }
                'Reset'    { 'Reset ' }
                default    { '' }
            }
            if ($inherited) { $label = 'Excluded by parent ' }        # [R4-3]
            $this._btnFilter.Text = $label
            $this._btnFilter.ForeColor = [System.Drawing.SystemColors]::ControlText
            $this._btnFilter.Enabled = (-not $inherited)              # [R4-3]
            if ($this.ReadOnlyMode) { $this._btnFilter.Enabled = $false }

            # radio marks; the CHECKED pending item doubles as Undo [R4-2]
            $this._miInclude.Checked = ($effective -eq 'Include')
            $this._miExclude.Checked = ($effective -eq 'Exclude')
            $this._miReset.Checked   = ($effective -eq 'Reset')

            # an already-saved choice cannot be re-selected, EXCEPT when it is the
            # pending one (then the check acts as Undo and must stay clickable)
            $this._miInclude.Enabled = (($pending -eq 'Include') -or ($committed -ne 'Include'))
            $this._miExclude.Enabled = (($pending -eq 'Exclude') -or ($committed -ne 'Exclude'))
            $this._miReset.Enabled   = (($committed -ne 'None') -or ($pending -ne 'None'))
        } catch { Write-Host "syncFilterMenuChecks - $($_.Exception.Message)" }
    }

    hidden [void] onFilterDropDownOpening() {
        try { $this.syncFilterMenuChecks() }
        catch { Write-Host "onFilterDropDownOpening - $($_.Exception.Message)" }
    }
    hidden [void] onToggleExclude([bool]$on) {
        try {
            if ($this._suppressGuard) { return }
            if ($null -eq $this._currentCtx -or $null -eq $this._currentCtx.Token) { return }
            $d = $this.ensureDraft()
            $d.Exclude = $on
            if ($on) { $d.Include = $false }
            $d.Reset = $false
            $this.pruneDraft()
            $this.syncFilterMenuChecks()
            $this.updateSaveButton()
            $this.notifyFiltersChanged()
        }
        catch { Write-Host "onToggleExclude - $($_.Exception.Message)" }
    }
    hidden [void] onToggleInclude([bool]$on) {
        try {
            if ($this._suppressGuard) { return }
            if ($null -eq $this._currentCtx -or $null -eq $this._currentCtx.Token) { return }
            $d = $this.ensureDraft()
            $d.Include = $on
            if ($on) { $d.Exclude = $false }
            $d.Reset = $false
            $this.pruneDraft()
            $this.syncFilterMenuChecks()
            $this.updateSaveButton()
            $this.notifyFiltersChanged()
        }
        catch { Write-Host "onToggleInclude - $($_.Exception.Message)" }
    }
    hidden [void] onResetFilter() {
        try {
            if ($null -eq $this._currentCtx -or $null -eq $this._currentCtx.Token) { return }
            $committed = $this.getActiveCommittedFilter()
            if ($null -eq $committed) {
                # nothing saved to reset -> just drop any pending draft
                $tag = $this._currentCtx.Token.Tag
                if ($tag.PSObject.Properties.Name -contains 'Draft') { $tag.Draft = $null }
            }
            else {
                $d = $this.ensureDraft()
                $d.Attributes = @{}
                $d.Exclude = $false
                $d.Include = $false
                $d.Reset = $true
            }
            $this.bindNodeToGrid($this._currentCtx)
            $this.syncFilterMenuChecks()
            $this.updateSaveButton()
            $this.notifyFiltersChanged()
        }
        catch { Write-Host "onResetFilter - $($_.Exception.Message)" }
    }

    hidden [void] updateSaveButton() {
        if ($this.ReadOnlyMode) { $this._btnSave.Visible = $false; $this._btnSave.Enabled = $false; return }
        try {
            $running = $false
            if ($null -ne $this._currentCtx -and $null -ne $this._currentCtx.Token -and $this._currentCtx.Token.Tag) {
                $tag = $this._currentCtx.Token.Tag
                $running = (($tag.PSObject.Properties.Name -contains 'FilterUpdate') -and $tag.FilterUpdate)
            }

            # [FIX 3] in Deferred mode the Filter Box never saves - the queue does
            if ($this.DeferredMode -and -not $running) {
                $this._btnSave.Visible = $false
                $this._btnSave.Enabled = $false
                return
            }

            $hasChanges = $this.nodeHasChanges()
            if ($hasChanges) {
                $this._btnSave.Visible = $true; $this._btnSave.Enabled = $true
                if ($this.Icons.ContainsKey('Save') -and $this.Icons['Save']) { $this._btnSave.Image = $this.Icons['Save'] }
            }
            elseif ($running) {
                $this._btnSave.Visible = $true; $this._btnSave.Enabled = $true
                if ($this.Icons.ContainsKey('Stop') -and $this.Icons['Stop']) { $this._btnSave.Image = $this.Icons['Stop'] }
                elseif ($this.Icons.ContainsKey('Save') -and $this.Icons['Save']) { $this._btnSave.Image = $this.Icons['Save'] }
            }
            else {
                $this._btnSave.Visible = $false; $this._btnSave.Enabled = $false
            }
        } catch { Write-Host "updateSaveButton - $($_.Exception.Message)" }
    }

    # In Deferred mode: hand the pending change to the host RIGHT NOW.
    hidden [void] raisePendingIfDeferred([string]$kindHint) {
        try {
            if (-not $this.DeferredMode) { return }
            if (-not $this.OnPendingChanged) { return }
            if ($null -eq $this._currentCtx) { return }

            $kind = $kindHint
            if ([string]::IsNullOrEmpty($kind)) { $kind = $this.getPendingKind() }

            $attrs = @{}
            if ($kind -eq 'Modified') {
                foreach ($gr in $this._dataGridView.Rows) {
                    if ($gr.IsNewRow -or -not $gr.Tag) { continue }
                    $v = [string]$gr.Cells['Value'].Value
                    if ($v -ne [string]$gr.Tag.Template) { $attrs[[string]$gr.Tag.Key] = $v }
                }
            }

            & $this.OnPendingChanged ([pscustomobject]@{
                XPath      = [string]$this._currentCtx.XPath
                Kind       = $kind
                Attributes = $attrs
                Node       = $this._currentCtx.Token
            })
        } catch { Write-Host "raisePendingIfDeferred - $($_.Exception.Message)" }
    }

    hidden [void] onSaveButtonClick() {
        try {
            $ctx = $this._currentCtx
            if ($null -eq $ctx) { return }
            if ($this.nodeHasChanges()) {
                $this.startBranchForNode($ctx)
            }
            elseif ($null -ne $ctx.Token -and ($ctx.Token.Tag.PSObject.Properties.Name -contains 'FilterUpdate') -and $ctx.Token.Tag.FilterUpdate) {
                $this.killNodeBatch($ctx)
            }
        }
        catch { Write-Host "onSaveButtonClick - $($_.Exception.Message)" }
    }

    hidden [void] startBranchForNode($ctx) {
        try {
            if ($null -eq $ctx -or $null -eq $ctx.Token) { return }
            $draft = $this.getDraft()
            if ($null -eq $draft) { return }

            $operation = $this.getFilterOperation($draft)

            if (-not $this.OnBuildBranchPack) {
                $this.setStatus("Cannot save: branch-pack builder not wired (OnBuildBranchPack).")
                return
            }
            $pack = & $this.OnBuildBranchPack $ctx $draft $operation
            if ($null -eq $pack) {
                $this.setStatus("Cannot save '$($ctx.DisplayName)': empty branch pack.")
                return
            }

            $op = Start-SRxFilterBranchOperation -Pack ([hashtable]$pack) -SourcePath $this.SourcePath -HideWindow
            if ($op.StartError) { $this.setStatus("Filter save failed to start: $($op.StartError)"); return }

            $ctx.Token.Tag | Add-Member -NotePropertyName FilterUpdate -NotePropertyValue $op -Force
            $this._activeBatches[[string]$op.ProcessId] = @{ Ctx = $ctx; Op = $op; Operation = $operation }

            # optimistic: reflect saved state now; clear the draft (a new edit re-creates it)
            $this.applyDraftToLayer($ctx, $draft, $operation)
            if ($ctx.Token.Tag.PSObject.Properties.Name -contains 'Draft') { $ctx.Token.Tag.Draft = $null }
            $this.bindNodeToGrid($ctx)

            $this.setStatus("Filter save started for '$($ctx.DisplayName)' (PID $($op.ProcessId)).")
            if (-not $this._batchTimer.Enabled) { $this._batchTimer.Start() }
            $this.updateGlobalManager()
            $this.updateSaveButton()
            $this.notifyFiltersChanged()
        }
        catch { Write-Host "startBranchForNode - $($_.Exception.Message)" }
    }

    hidden [void] pollBatches() {
        $ErrorActionPreference = 'Continue'
        $stage = 'enter'
        try {
            if ($this._activeBatches.Count -eq 0) { $this._batchTimer.Stop(); $this.updateGlobalManager(); return }
            $done = @()
            $finishedXPaths = @()
            foreach ($entry in @($this._activeBatches.GetEnumerator())) {
                $key = $entry.Key
                $rec = $entry.Value
                $ctx = $rec.Ctx
                $op = $rec.Op

                $stage = "getState:$key"
                $st = $null
                try { $st = Get-SRxFilterBranchOperationState -Operation $op }
                catch { Write-Host "pollBatches getState EXC: $($_.Exception.Message)"; continue }
                if ($null -eq $st) { continue }
                if ($st.State -eq 'Running') { continue }

                if ($st.State -eq 'Completed' -and $st.Success) {
                    $stage = "callback:$key"
                    if ($st.Data -and $st.Data.FilterNodes -and $this.OnFiltersChanged) {
                        try { & $this.OnFiltersChanged ([pscustomobject]@{ Node = $ctx.Token; FilterNodes = $st.Data.FilterNodes; Committed = $true }) }
                        catch { Write-Host "pollBatches OnFiltersChanged EXC: $($_.Exception.Message)" }
                    }
                    if ($st.Data -and $st.Data.SyncWarning) { $this.setStatus("Saved filter for '$($ctx.DisplayName)' (note: $($st.Data.SyncWarning))") }
                    else { $this.setStatus("Saved filter for '$($ctx.DisplayName)'.") }
                    $stage = "scheduleReload:$key"
                    $this.scheduleDbReload()
                }
                elseif ($st.State -eq 'Conflict') {
                    $this.setStatus("Filter conflict for '$($ctx.DisplayName)' - remote changed (merge in Iteration 2).")
                }
                else {
                    $this.setStatus("Filter save failed for '$($ctx.DisplayName)': $($st.Error)")
                }

                $stage = "cleanup:$key"
                try { Remove-SRxFilterBranchTempFiles -Operation $op } catch { Write-Host "pollBatches cleanup EXC: $($_.Exception.Message)" }
                if (($ctx.Token.Tag.PSObject.Properties.Name -contains 'FilterUpdate') -and $ctx.Token.Tag.FilterUpdate -and `
                    ([string]$ctx.Token.Tag.FilterUpdate.ProcessId -eq [string]$op.ProcessId)) {
                    $ctx.Token.Tag.FilterUpdate = $null
                }
                $done += $key
                $finishedXPaths += [string]$ctx.XPath
            }
            foreach ($k in $done) { $this._activeBatches.Remove($k) }

            $stage = 'rebind'
            $sel = $this._currentCtx
            if ($null -ne $sel -and ($finishedXPaths -contains [string]$sel.XPath)) {
                if (-not $this._dataGridView.IsCurrentCellInEditMode) { $this.bindNodeToGrid($sel) }
                else { $this.updateSaveButton() }
            }
            else {
                $this.updateSaveButton()
            }

            if ($this._activeBatches.Count -eq 0) { $this._batchTimer.Stop(); $this.setStatus('Ready') }
            $this.updateGlobalManager()
        }
        catch { Write-Host "pollBatches EXC at stage [$stage]: $($_.Exception.GetType().FullName): $($_.Exception.Message)" }
    }

    hidden [void] scheduleDbReload() { }   # Iteration 1: disabled (races worker's SRx session log)

    hidden [void] onDbReloadTick() {
        try { if ($this._dbReloadTimer) { $this._dbReloadTimer.Stop() } } catch { }
    }
    hidden [void] killAllBatches() {
        try { foreach ($entry in @($this._activeBatches.GetEnumerator())) { try { Stop-SRxFilterBranchOperation -Operation $entry.Value.Op | Out-Null } catch {} } ; $this.setStatus("Killed all running filter batches.") }
        catch { Write-Host "killAllBatches - $($_.Exception.Message)" }
    }

    hidden [void] updateGlobalManager() {
        try {
            $active = ($this._activeBatches.Count -gt 0)
            $this._globalMgr.Visible = $active
            if ($null -ne $this._globalMgrBusy) { $this._globalMgrBusy.Visible = $active }
            if ($active) { $this._globalMgrLabel.Text = "Filter update in progress ($($this._activeBatches.Count))"; if (-not $this._spinTimer.Enabled) { $this._spinTimer.Start() } }
            else { if ($this._spinTimer.Enabled) { $this._spinTimer.Stop() } }
        }
        catch { Write-Host "updateGlobalManager - $($_.Exception.Message)" }
    }
    hidden [void] spinGlobalManager() { try { $this._globalMgrLabel.Text = "Filter update in progress ($($this._activeBatches.Count))" } catch { } }

    # ---- overlay (reused pattern) ----
    hidden [void] showOverlay() {
        try {
            $name = ''
            if ($null -ne $this._currentCtx) { $name = [string]$this._currentCtx.DisplayName }
            $this._overlayMsg.Text = "You have unsaved filter changes for '$name'.`r`nWhat would you like to do?"
            if ($this.Icons['Save']) { $this._overlaySave.Image = $this.Icons['Save'] }
            $this._overlay.Dock = [System.Windows.Forms.DockStyle]::Fill
            $this._overlay.Visible = $true
            $this._overlay.BringToFront()
        }
        catch { Write-Host "showOverlay - $($_.Exception.Message)" }
    }
    hidden [void] hideOverlay() { try { $this._overlay.Visible = $false } catch { } }
    hidden [void] finishPendingAction() {
        try {
            $action = $this._pendingAction; $this._pendingAction = ''
            if ($action -eq 'close') { $this.doClose() }
            # switch case is host-driven for the customizer tree; left as TODO if needed
            $this._pendingTargetCtx = $null
        }
        catch { Write-Host "finishPendingAction - $($_.Exception.Message)" }
    }
    hidden [void] onOverlaySave() {
        try { $this.hideOverlay(); if ($null -ne $this._currentCtx) { $this.startBranchForNode($this._currentCtx) } ; $this.finishPendingAction() }
        catch { Write-Host "onOverlaySave - $($_.Exception.Message)" }
    }
    hidden [void] onOverlayDiscard() {
        try {
            if ($null -ne $this._currentCtx -and $null -ne $this._currentCtx.Token -and $this._currentCtx.Token.Tag) {
                if ($this._currentCtx.Token.Tag.PSObject.Properties.Name -contains 'Draft') { $this._currentCtx.Token.Tag.Draft = $null }
            }
            $this.hideOverlay(); $this.bindNodeToGrid($this._currentCtx); $this.finishPendingAction()
        }
        catch { Write-Host "onOverlayDiscard - $($_.Exception.Message)" }
    }
    hidden [void] onOverlayCancel() { try { $this._pendingTargetCtx = $null; $this._pendingAction = ''; $this.hideOverlay() } catch { } }

    # ---- SRxFilterBoxControl.SavePath.patch ----
    hidden [string] getFilterOperation($draft) {
        # Reset => delete the active-layer customizations; everything else is an upsert.
        if ([bool]$this.cprop($draft, 'Reset', $false)) { return 'remove' }
        return 'add'
    }

    # Optimistically fold the draft into LayerFilters[activeLayer] so the UI reflects the
    # saved state immediately; the worker confirms + syncs the cache asynchronously.
    hidden [void] applyDraftToLayer($ctx, $draft, [string]$operation) {
        try {
            $tag = $ctx.Token.Tag
            $lf = @{}
            if (($tag.PSObject.Properties.Name -contains 'LayerFilters') -and $tag.LayerFilters) { $lf = [hashtable]$tag.LayerFilters }
            $al = $this.getActiveLayer()
            if ($al -lt 0) { return }
            if ($operation -eq 'remove') {
                if ($lf.ContainsKey($al)) { [void]$lf.Remove($al) }
            }
            else {
                $committed = $null; if ($lf.ContainsKey($al)) { $committed = $lf[$al] }
                $attrs = $this.cattrs($committed)
                foreach ($k in @($draft.Attributes.Keys)) { $attrs[$k] = [string]$draft.Attributes[$k] }
                $lf[$al] = [pscustomobject]@{ Attributes = $attrs; Exclude = [bool]$draft.Exclude; Include = [bool]$draft.Include }
            }
            $tag | Add-Member -NotePropertyName LayerFilters -NotePropertyValue $lf -Force
        }
        catch { Write-Host "applyDraftToLayer - $($_.Exception.Message)" }
    }

    hidden [void] killNodeBatch($ctx) {
        try {
            if ($null -eq $ctx -or $null -eq $ctx.Token) { return }
            $tag = $ctx.Token.Tag
            if (($tag.PSObject.Properties.Name -contains 'FilterUpdate') -and $tag.FilterUpdate) {
                [void](Stop-SRxFilterBranchOperation -Operation $tag.FilterUpdate)
                $this.setStatus("Killed filter save for '$($ctx.DisplayName)'.")
            }
        }
        catch { Write-Host "killNodeBatch - $($_.Exception.Message)" }
    }

    [void] SaveNow() {
        try {
            if ($null -eq $this._currentCtx) { return }
            if (-not $this.nodeHasChanges()) { return }
            $this.startBranchForNode($this._currentCtx)
        }
        catch { Write-Host "SaveNow - $($_.Exception.Message)" }
    }
    # one entry point: 'Include' | 'Exclude' | 'Reset'
    hidden [void] onSelectFilterKind([string]$kind) {
        try {
            if ($this.ReadOnlyMode) { return }
            if ($null -eq $this._currentCtx -or $null -eq $this._currentCtx.Token) { return }

            # clicking the CHECKED pending kind = Undo
            if ($kind -eq $this.getPendingKind()) { $this.undoPending(); return }

            # [BUG-3] Deferred + Reset -> discard the pending work outright
            if ($kind -eq 'Reset' -and $this.DeferredMode) {
                $hasCommitted = $false
                $lf = $this.getLayerFilters(); $al = $this.getActiveLayer()
                if ($al -ge 0 -and $lf.ContainsKey($al) -and $null -ne $lf[$al]) { $hasCommitted = $true }
                if (-not $hasCommitted) { $this.undoPending(); return }   # nothing saved -> just cancel
            }

            $committedKind = $this.getCommittedKind()
            if ($kind -eq $committedKind) { return }

            $d = $this.ensureDraft()
            switch ($kind) {
                'Include' { $d.Include = $true;  $d.Exclude = $false; $d.Reset = $false; $d.Attributes = @{} }
                'Exclude' { $d.Include = $false; $d.Exclude = $true;  $d.Reset = $false; $d.Attributes = @{} }
                'Reset'   {
                    $d.Include = $false; $d.Exclude = $false; $d.Attributes = @{}
                    $d.Reset = ($null -ne $this.getActiveCommittedFilter())
                }
            }
            $this.pruneDraft()
            $this.raisePendingIfDeferred($kind)
            $this.bindNodeToGrid($this._currentCtx)
            $this.syncFilterMenuChecks()
            $this.updateSaveButton()
            $this.notifyFiltersChanged()
        } catch { Write-Host "onSelectFilterKind - $($_.Exception.Message)" }
    }

    # discard the PENDING change only; nothing is written to the termstore
    hidden [void] undoPending() {
        try {
            if ($null -eq $this._currentCtx -or $null -eq $this._currentCtx.Token) { return }
            $tag = $this._currentCtx.Token.Tag
            if ($tag.PSObject.Properties.Name -contains 'Draft') { $tag.Draft = $null }
            $this._cellDiffState = @{}

            # [BUG-2] tell the host to REMOVE the queued entry BEFORE re-binding,
            # so getPendingKind() no longer sees it via QueueLookup
            $this.raisePendingIfDeferred('None')

            $this.bindNodeToGrid($this._currentCtx)
            $this.syncFilterMenuChecks()
            $this.updateSaveButton()
            $this.notifyFiltersChanged()
        } catch { Write-Host "undoPending - $($_.Exception.Message)" }
    }

    # [R4-4] host reads the pending change to put it on the deferred queue
    [object] GetPendingChange() {
        try {
            if ($null -eq $this._currentCtx) { return $null }
            $kind = $this.getPendingKind()
            if ($kind -eq 'None') { return $null }
            $attrs = @{}
            if ($kind -eq 'Modified') {
                foreach ($gr in $this._dataGridView.Rows) {
                    if ($gr.IsNewRow -or -not $gr.Tag) { continue }
                    $v = [string]$gr.Cells['Value'].Value
                    if ($v -ne [string]$gr.Tag.Template) { $attrs[[string]$gr.Tag.Key] = $v }
                }
            }
            return [pscustomobject]@{
                XPath      = [string]$this._currentCtx.XPath
                Kind       = $kind
                Attributes = $attrs
                Node       = $this._currentCtx.Token
            }
        } catch { Write-Host "GetPendingChange - $($_.Exception.Message)"; return $null }
    }

    # [R4-4] drop the draft WITHOUT writing (the queue now owns it)
    [void] ClearPending() {
        try {
            if ($null -eq $this._currentCtx -or $null -eq $this._currentCtx.Token) { return }
            $tag = $this._currentCtx.Token.Tag
            if ($tag.PSObject.Properties.Name -contains 'Draft') { $tag.Draft = $null }
            $this.updateSaveButton()
        } catch { Write-Host "ClearPending - $($_.Exception.Message)" }
    }

    [void] RefreshSaveButton() { $this.updateSaveButton() }    

    # what is COMMITTED at the active layer: 'None'|'Include'|'Exclude'|'Modified'
    hidden [string] getCommittedKind() {
        $c = $this.getActiveCommittedFilter()
        if ($null -eq $c) { return 'None' }
        if ([bool]$this.cprop($c, 'Exclude', $false)) { return 'Exclude' }
        if ([bool]$this.cprop($c, 'Include', $false)) { return 'Include' }
        $a = $this.cattrs($c)
        if ($a.Count -gt 0) { return 'Modified' }
        return 'None'
    }

    # what is PENDING right now (draft overlay): same vocabulary + 'Reset'
    hidden [object] getQueuedChange() {
        try {
            if (-not $this.QueueLookup) { return $null }
            if ($null -eq $this._currentCtx) { return $null }
            $q = & $this.QueueLookup ([string]$this._currentCtx.XPath)
            if ($null -eq $q) { return $null }
            $k = [string]$this.cprop($q,'Kind','None')
            if ([string]::IsNullOrEmpty($k) -or $k -eq 'None') { return $null }
            return $q
        } catch { return $null }
    }

    hidden [string] getPendingKind() {
        # 1) a live draft wins (On-Demand, or mid-edit before enqueue)
        $d = $this.getDraft()
        if ($null -ne $d) {
            if ([bool]$this.cprop($d,'Reset',$false))   { return 'Reset' }
            if ([bool]$this.cprop($d,'Exclude',$false)) { return 'Exclude' }
            if ([bool]$this.cprop($d,'Include',$false)) { return 'Include' }
            foreach ($gr in $this._dataGridView.Rows) {
                if ($gr.IsNewRow -or -not $gr.Tag) { continue }
                if ([string]$gr.Cells['Value'].Value -ne [string]$gr.Tag.Template) { return 'Modified' }
            }
            return 'None'
        }
        # 2) otherwise a QUEUED change is still "pending" from the user's view [R5-2/R5-3]
        $q = $this.getQueuedChange()
        if ($null -ne $q) {
            $k = [string]$this.cprop($q,'Kind','None')
            if (-not [string]::IsNullOrEmpty($k)) { return $k }
        }
        return 'None'
    }
    # Changing ReadOnlyMode alone does not touch the node already on screen: its
    # grid editability and the Filter dropdown were decided at bind time. Re-bind
    # so both are re-evaluated against the new flag.
    [void] SetReadOnly([bool]$ro) {
        try {
            $this.ReadOnlyMode = $ro
            if ($null -ne $this._currentCtx) { $this.bindNodeToGrid($this._currentCtx) }
            else { $this.updateSaveButton() }

            # Q7 - diagnostic, remove once confirmed
            $editable = $null
            try { $editable = $this.isGridEditable() } catch { }
            TR ("FILTERBOX SetReadOnly ro={0} ctx={1} gridEditable={2} filterBtnEnabled={3}" -f `
                $ro, ($null -ne $this._currentCtx), $editable, $this._btnFilter.Enabled)
        } catch { Write-Host "SetReadOnly - $($_.Exception.Message)" }
    }




}

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

Export-ModuleMember -Function New-SRxFilterBoxControl