PropertyBox/SRxTermMerge.psm1

<#
================================================================================
  SRxTermMerge.psm1 (Iteration 3)
  -----------------------------------------------------------------------------
  True 3-way merge for term custom properties, replacing the 2-way
  Overwrite/Discard prompt.

  Three legs:
    BASE = the committed property set the user STARTED editing from
             (node.Tag.EditBase, captured at edit-start).
    MINE = the user's desired set (box contents, logical/coalesced).
    THEIRS = current tenant state at conflict time
             (worker conflict payload: st.Data.RemoteProps).

  All comparisons run on LOGICAL (coalesced) values; re-chunking happens later at
  commit via Split-SRxChunkedProperties (in SRxPropertyBox.psm1). This module only
  deals in logical hashtables.

  Public surface:
    Merge-SRxTermProperties -Base -Mine -Theirs -> {
                                   Merged : hashtable (logical) - auto+resolved
                                   Rows : object[] per-key classification
                                   HasConflicts : bool (any blocking row unresolved)
                                   ConflictCount : int
                               }
    Show-SRxMergeDialog -MergeResult -TermName -> hashtable (merged logical)
                                                          OR $null on cancel

  Row classification (Class):
    Identical | MineClean | TheirsClean | AgreeSame | MineAdd | TheirsAdd |
    MineDelete | TheirsDelete | ConflictEdit | ConflictDelete | ConflictAdd
  Blocking (needs user) = ConflictEdit | ConflictDelete | ConflictAdd
================================================================================
#>


# Sentinel that is distinct from any real string value (incl. empty string),
# used to represent "key absent in this leg".
$script:SRxAbsent = [pscustomobject]@{ __SRxAbsent__ = $true }

function Test-SRxAbsent { param($v) return ($v -is [pscustomobject] -and $v.PSObject.Properties.Name -contains '__SRxAbsent__') }

function Get-SRxLegValue {
    param([hashtable]$Ht, [string]$Key)
    if ($Ht.ContainsKey($Key)) { return [string]$Ht[$Key] }
    return $script:SRxAbsent
}

function Test-SRxEqual {
    param($a, $b)
    $aAbsent = Test-SRxAbsent $a
    $bAbsent = Test-SRxAbsent $b
    if ($aAbsent -and $bAbsent) { return $true }
    if ($aAbsent -or  $bAbsent) { return $false }
    return ([string]$a -eq [string]$b)
}

# ------------------------------------------------------------------------------
# Core 3-way classification.
# ------------------------------------------------------------------------------
function Merge-SRxTermProperties {
    [CmdletBinding()]
    param(
        [Parameter(Mandatory=$false)] [hashtable]$Base   = @{},
        [Parameter(Mandatory=$false)] [hashtable]$Mine   = @{},
        [Parameter(Mandatory=$false)] [hashtable]$Theirs = @{}
    )

    if ($null -eq $Base)   { $Base   = @{} }
    if ($null -eq $Mine)   { $Mine   = @{} }
    if ($null -eq $Theirs) { $Theirs = @{} }

    $allKeys = New-Object System.Collections.Generic.HashSet[string]
    foreach ($k in $Base.Keys)   { [void]$allKeys.Add([string]$k) }
    foreach ($k in $Mine.Keys)   { [void]$allKeys.Add([string]$k) }
    foreach ($k in $Theirs.Keys) { [void]$allKeys.Add([string]$k) }

    $rows   = @()
    $merged = @{}

    foreach ($key in ($allKeys | Sort-Object)) {
        $bV = Get-SRxLegValue -Ht $Base   -Key $key
        $mV = Get-SRxLegValue -Ht $Mine   -Key $key
        $tV = Get-SRxLegValue -Ht $Theirs -Key $key

        $mineChanged   = -not (Test-SRxEqual $bV $mV)
        $theirsChanged = -not (Test-SRxEqual $bV $tV)

        $class    = $null
        $auto     = $true
        $resolved = $script:SRxAbsent   # absent == delete/omit from merged set

        if (-not $mineChanged -and -not $theirsChanged) {
            # neither side touched it
            if (Test-SRxAbsent $bV) { $class = 'Identical'; $resolved = $script:SRxAbsent }
            else                    { $class = 'Identical'; $resolved = $bV }
        }
        elseif ($mineChanged -and -not $theirsChanged) {
            # only I changed it
            if (Test-SRxAbsent $mV) { $class = 'MineDelete'; $resolved = $script:SRxAbsent }
            elseif (Test-SRxAbsent $bV) { $class = 'MineAdd'; $resolved = $mV }
            else { $class = 'MineClean'; $resolved = $mV }
        }
        elseif (-not $mineChanged -and $theirsChanged) {
            # only they changed it
            if (Test-SRxAbsent $tV) { $class = 'TheirsDelete'; $resolved = $script:SRxAbsent }
            elseif (Test-SRxAbsent $bV) { $class = 'TheirsAdd'; $resolved = $tV }
            else { $class = 'TheirsClean'; $resolved = $tV }
        }
        else {
            # both changed
            if (Test-SRxEqual $mV $tV) {
                # converged on the same value (incl. both deleted)
                if (Test-SRxAbsent $mV) { $class = 'AgreeSame'; $resolved = $script:SRxAbsent }
                else                    { $class = 'AgreeSame'; $resolved = $mV }
            }
            else {
                $auto = $false
                if     ((Test-SRxAbsent $mV) -or (Test-SRxAbsent $tV)) { $class = 'ConflictDelete' }
                elseif (Test-SRxAbsent $bV)                            { $class = 'ConflictAdd' }
                else                                                   { $class = 'ConflictEdit' }
                $resolved = $script:SRxAbsent   # undecided until user picks
            }
        }

        $rows += [pscustomobject]@{
            Key      = $key
            Base     = $bV
            Theirs   = $tV
            Mine     = $mV
            Class    = $class
            Auto     = $auto
            Chosen   = if ($auto) { 'Auto' } else { $null }   # user picks: 'Mine'|'Theirs'|'Edit'
            EditText = if (-not (Test-SRxAbsent $mV)) { [string]$mV } else { '' }
            Resolved = $resolved
        }

        if ($auto -and -not (Test-SRxAbsent $resolved)) { $merged[$key] = [string]$resolved }
    }

    $conflicts = @($rows | Where-Object { -not $_.Auto })
    return [pscustomobject]@{
        Merged        = $merged
        Rows          = $rows
        HasConflicts  = ($conflicts.Count -gt 0)
        ConflictCount = $conflicts.Count
    }
}

# ------------------------------------------------------------------------------
# Helper: display text for an (absent-aware) value.
# ------------------------------------------------------------------------------
function Format-SRxLeg {
    param($v)
    if (Test-SRxAbsent $v) { return '(absent)' }
    if ([string]::IsNullOrEmpty([string]$v)) { return '(empty)' }
    return [string]$v
}

# ------------------------------------------------------------------------------
# Recompute the merged set from current row choices; returns @{ Merged; Complete }.
# ------------------------------------------------------------------------------
function Resolve-SRxMergeRows {
    param([Parameter(Mandatory)] $Rows)
    $merged   = @{}
    $complete = $true
    foreach ($r in $Rows) {
        if ($r.Auto) {
            if (-not (Test-SRxAbsent $r.Resolved)) { $merged[$r.Key] = [string]$r.Resolved }
            continue
        }
        switch ($r.Chosen) {
            'Mine'   { if (-not (Test-SRxAbsent $r.Mine))   { $merged[$r.Key] = [string]$r.Mine } }
            'Theirs' { if (-not (Test-SRxAbsent $r.Theirs)) { $merged[$r.Key] = [string]$r.Theirs } }
            'Edit'   { $merged[$r.Key] = [string]$r.EditText }
            default  { $complete = $false }   # still undecided
        }
    }
    return [pscustomobject]@{ Merged = $merged; Complete = $complete }
}

# ------------------------------------------------------------------------------
# Modal WinForms resolution dialog. Returns merged logical hashtable, or $null.
# Only shown when $MergeResult.HasConflicts is true (caller enforces).
# ------------------------------------------------------------------------------
function Show-SRxMergeDialog {
    [CmdletBinding()]
    param(
        [Parameter(Mandatory)] $MergeResult,
        [Parameter(Mandatory=$false)] [string]$TermName = 'term',
        [Parameter(Mandatory=$false)] [string]$RemoteModified = '',
        [Parameter(Mandatory=$false)] [string]$BaselineModified = ''
    )

    Add-Type -AssemblyName System.Windows.Forms -ErrorAction SilentlyContinue
    Add-Type -AssemblyName System.Drawing       -ErrorAction SilentlyContinue

    $rows = $MergeResult.Rows

    $form = [System.Windows.Forms.Form]::new()
    $form.Text = "Resolve property conflicts - $TermName"
    $form.Width = 900
    $form.Height = 520
    $form.StartPosition = [System.Windows.Forms.FormStartPosition]::CenterParent
    $form.MinimizeBox = $false
    $form.MaximizeBox = $false
    $form.FormBorderStyle = [System.Windows.Forms.FormBorderStyle]::Sizable

    $header = [System.Windows.Forms.Label]::new()
    $header.AutoSize = $false
    $header.Dock = [System.Windows.Forms.DockStyle]::Top
    $header.Height = 40
    $header.Padding = [System.Windows.Forms.Padding]::new(8, 6, 8, 0)
    $ht = "Both you and the tenant changed '$TermName'."
    if ($RemoteModified) { $ht += " Remote modified: $RemoteModified" }
    if ($BaselineModified) { $ht += " (your baseline: $BaselineModified)" }
    $ht += " Resolve the conflicting keys below, then Commit merged."
    $header.Text = $ht
    $form.Controls.Add($header)

    # bottom button bar
    $bar = [System.Windows.Forms.Panel]::new()
    $bar.Dock = [System.Windows.Forms.DockStyle]::Bottom
    $bar.Height = 84
    $form.Controls.Add($bar)

    $preview = [System.Windows.Forms.TextBox]::new()
    $preview.Multiline = $true
    $preview.ReadOnly = $true
    $preview.ScrollBars = [System.Windows.Forms.ScrollBars]::Vertical
    $preview.Dock = [System.Windows.Forms.DockStyle]::Top
    $preview.Height = 44
    $preview.BackColor = [System.Drawing.SystemColors]::Info
    $bar.Controls.Add($preview)

    $btnCommit = [System.Windows.Forms.Button]::new()
    $btnCommit.Text = 'Commit merged'
    $btnCommit.Width = 130
    $btnCommit.Height = 28
    $btnCommit.Location = [System.Drawing.Point]::new(620, 50)
    $bar.Controls.Add($btnCommit)

    $btnCancel = [System.Windows.Forms.Button]::new()
    $btnCancel.Text = 'Cancel'
    $btnCancel.Width = 90
    $btnCancel.Height = 28
    $btnCancel.Location = [System.Drawing.Point]::new(760, 50)
    $btnCancel.DialogResult = [System.Windows.Forms.DialogResult]::Cancel
    $bar.Controls.Add($btnCancel)

    $btnAllMine = [System.Windows.Forms.Button]::new()
    $btnAllMine.Text = 'Take all Mine'
    $btnAllMine.Width = 110
    $btnAllMine.Height = 28
    $btnAllMine.Location = [System.Drawing.Point]::new(8, 50)
    $bar.Controls.Add($btnAllMine)

    $btnAllTheirs = [System.Windows.Forms.Button]::new()
    $btnAllTheirs.Text = 'Take all Theirs'
    $btnAllTheirs.Width = 110
    $btnAllTheirs.Height = 28
    $btnAllTheirs.Location = [System.Drawing.Point]::new(124, 50)
    $bar.Controls.Add($btnAllTheirs)

    $chkHideAuto = [System.Windows.Forms.CheckBox]::new()
    $chkHideAuto.Text = 'Hide auto-resolved rows'
    $chkHideAuto.AutoSize = $true
    $chkHideAuto.Location = [System.Drawing.Point]::new(250, 55)
    $bar.Controls.Add($chkHideAuto)

    # grid
    $grid = [System.Windows.Forms.DataGridView]::new()
    $grid.Dock = [System.Windows.Forms.DockStyle]::Fill
    $grid.AllowUserToAddRows = $false
    $grid.AllowUserToDeleteRows = $false
    $grid.RowHeadersVisible = $false
    $grid.SelectionMode = [System.Windows.Forms.DataGridViewSelectionMode]::CellSelect
    $grid.AutoSizeColumnsMode = [System.Windows.Forms.DataGridViewAutoSizeColumnsMode]::None
    $form.Controls.Add($grid)
    $grid.BringToFront()

    $colKey = [System.Windows.Forms.DataGridViewTextBoxColumn]::new()
    $colKey.HeaderText = 'Key'; $colKey.Width = 130; $colKey.ReadOnly = $true
    [void]$grid.Columns.Add($colKey)

    $colBase = [System.Windows.Forms.DataGridViewTextBoxColumn]::new()
    $colBase.HeaderText = 'Base (ancestor)'; $colBase.Width = 150; $colBase.ReadOnly = $true
    [void]$grid.Columns.Add($colBase)

    $colTheirs = [System.Windows.Forms.DataGridViewTextBoxColumn]::new()
    $colTheirs.HeaderText = 'Theirs (remote)'; $colTheirs.Width = 150; $colTheirs.ReadOnly = $true
    [void]$grid.Columns.Add($colTheirs)

    $colMine = [System.Windows.Forms.DataGridViewTextBoxColumn]::new()
    $colMine.HeaderText = 'Mine (yours)'; $colMine.Width = 150; $colMine.ReadOnly = $true
    [void]$grid.Columns.Add($colMine)

    $colChoice = [System.Windows.Forms.DataGridViewComboBoxColumn]::new()
    $colChoice.HeaderText = 'Resolution'; $colChoice.Width = 90
    [void]$colChoice.Items.AddRange(@('Theirs','Mine','Edit'))
    $colChoice.FlatStyle = [System.Windows.Forms.FlatStyle]::Flat
    [void]$grid.Columns.Add($colChoice)

    $colEdit = [System.Windows.Forms.DataGridViewTextBoxColumn]::new()
    $colEdit.HeaderText = 'Edit value'; $colEdit.Width = 150
    [void]$grid.Columns.Add($colEdit)

    # map grid row index -> merge row object
    $rowMap = @{}

    $script:refreshPreview = {
        $state = Resolve-SRxMergeRows -Rows $rows
        $parts = @()
        foreach ($k in ($state.Merged.Keys | Sort-Object)) { $parts += "$k=$($state.Merged[$k])" }
        if ($parts.Count -eq 0) { $preview.Text = 'Merged preview: (all properties removed)' }
        else { $preview.Text = 'Merged preview: ' + ($parts -join ' | ') }
        $btnCommit.Enabled = $state.Complete
    }.GetNewClosure()

    $populate = {
        $grid.Rows.Clear()
        $rowMap.Clear()
        foreach ($r in $rows) {
            if ($chkHideAuto.Checked -and $r.Auto) { continue }
            $choiceVal = if ($r.Auto) { '' } elseif ($r.Chosen) { $r.Chosen } else { '' }
            $editVal   = if ($r.Chosen -eq 'Edit') { [string]$r.EditText } elseif (-not (Test-SRxAbsent $r.Mine)) { [string]$r.Mine } else { '' }
            $idx = $grid.Rows.Add(
                $r.Key,
                (Format-SRxLeg $r.Base),
                (Format-SRxLeg $r.Theirs),
                (Format-SRxLeg $r.Mine),
                $choiceVal,
                $editVal
            )
            $gr = $grid.Rows[$idx]
            $rowMap[$idx] = $r

            if ($r.Auto) {
                $gr.DefaultCellStyle.BackColor = [System.Drawing.Color]::FromArgb(240,240,240)
                $gr.DefaultCellStyle.ForeColor = [System.Drawing.Color]::Gray
                $gr.Cells[4].ReadOnly = $true
                $gr.Cells[5].ReadOnly = $true
            } else {
                $gr.Cells[0].Style.ForeColor = [System.Drawing.Color]::DarkRed
                $gr.Cells[0].Style.Font = [System.Drawing.Font]::new($grid.Font, [System.Drawing.FontStyle]::Bold)
                $gr.Cells[5].ReadOnly = ($r.Chosen -ne 'Edit')
            }
        }
        & $script:refreshPreview
    }.GetNewClosure()

    $grid.add_CellValueChanged({
        param($s, $e)
        if ($e.RowIndex -lt 0) { return }
        if (-not $rowMap.ContainsKey($e.RowIndex)) { return }
        $r = $rowMap[$e.RowIndex]
        if ($r.Auto) { return }
        if ($e.ColumnIndex -eq 4) {
            $val = [string]$s.Rows[$e.RowIndex].Cells[4].Value
            $r.Chosen = $val
            $s.Rows[$e.RowIndex].Cells[5].ReadOnly = ($val -ne 'Edit')
            if ($val -eq 'Mine'   -and -not (Test-SRxAbsent $r.Mine))   { $s.Rows[$e.RowIndex].Cells[5].Value = [string]$r.Mine }
            if ($val -eq 'Theirs' -and -not (Test-SRxAbsent $r.Theirs)) { $s.Rows[$e.RowIndex].Cells[5].Value = [string]$r.Theirs }
        }
        elseif ($e.ColumnIndex -eq 5) {
            $r.EditText = [string]$s.Rows[$e.RowIndex].Cells[5].Value
        }
        & $script:refreshPreview
    })
    # commit combo edits immediately
    $grid.add_CurrentCellDirtyStateChanged({
        param($s, $e)
        if ($s.IsCurrentCellDirty) { $s.CommitEdit([System.Windows.Forms.DataGridViewDataErrorContexts]::Commit) }
    })

    $btnAllMine.add_click({
        foreach ($r in $rows) { if (-not $r.Auto) { $r.Chosen = 'Mine' } }
        & $populate
    }.GetNewClosure())

    $btnAllTheirs.add_click({
        foreach ($r in $rows) { if (-not $r.Auto) { $r.Chosen = 'Theirs' } }
        & $populate
    }.GetNewClosure())

    $chkHideAuto.add_CheckedChanged({ & $populate }.GetNewClosure())

    $script:mergeDialogResult = $null
    $btnCommit.add_click({
        $state = Resolve-SRxMergeRows -Rows $rows
        if (-not $state.Complete) {
            [void][System.Windows.Forms.MessageBox]::Show('Every conflicting key needs a resolution.', 'Incomplete',
                [System.Windows.Forms.MessageBoxButtons]::OK, [System.Windows.Forms.MessageBoxIcon]::Information)
            return
        }
        $script:mergeDialogResult = $state.Merged
        $form.DialogResult = [System.Windows.Forms.DialogResult]::OK
        $form.Close()
    }.GetNewClosure())

    $form.AcceptButton = $btnCommit
    $form.CancelButton = $btnCancel

    & $populate

    $dr = $form.ShowDialog()
    if ($dr -eq [System.Windows.Forms.DialogResult]::OK) { return $script:mergeDialogResult }
    return $null
}

Export-ModuleMember -Function `
    Merge-SRxTermProperties, `
    Resolve-SRxMergeRows, `
    Show-SRxMergeDialog, `
    Format-SRxLeg