Private/Invoke-ProposalApply.ps1

# Copyright (c) 2026 Jeffrey Snover. All rights reserved.
# Licensed under the MIT License. See LICENSE file in the project root.

function Invoke-ProposalApply {
    <#
    .SYNOPSIS
        Applies a single taxonomy proposal (NEW/SPLIT/MERGE/RELABEL) to the taxonomy files.
    .DESCRIPTION
        Internal helper called by Approve-TaxonomyProposal. Mutates the taxonomy
        JSON file on disk and returns a result object.
    #>

    [CmdletBinding()]
    param(
        [Parameter(Mandatory)]
        [PSObject]$Proposal,

        [string]$RepoRoot = $script:RepoRoot
    )

    Set-StrictMode -Version Latest

    $TaxDir = Get-TaxonomyDir

    $PovFileMap = @{
        accelerationist = 'accelerationist.json'
        safetyist       = 'safetyist.json'
        skeptic         = 'skeptic.json'
        'situations' = 'situations.json'
    }

    $FileName = $PovFileMap[$Proposal.pov]
    if (-not $FileName) {
        return [PSCustomObject]@{ Success = $false; Error = "Unknown POV: $($Proposal.pov)" }
    }

    $FilePath = Join-Path $TaxDir $FileName
    if (-not (Test-Path $FilePath)) {
        return [PSCustomObject]@{ Success = $false; Error = "Taxonomy file not found: $FileName" }
    }

    try {
        $Raw = Get-Content -Raw -Path $FilePath | ConvertFrom-Json
    } catch {
        return [PSCustomObject]@{ Success = $false; Error = "Failed to parse $FileName`: $_" }
    }

    $IsCrossCutting = $Proposal.pov -eq 'situations'
    $Today = (Get-Date).ToString('yyyy-MM-dd')

    switch ($Proposal.action) {
        'NEW' {
            # Check for ID collision
            $Existing = $Raw.nodes | Where-Object { $_.id -eq $Proposal.suggested_id }
            if ($Existing) {
                return [PSCustomObject]@{ Success = $false; Error = "Node ID '$($Proposal.suggested_id)' already exists" }
            }

            if ($IsCrossCutting) {
                $NewNode = [ordered]@{
                    id              = $Proposal.suggested_id
                    label           = $Proposal.label
                    description     = $Proposal.description
                    interpretations = [ordered]@{
                        accelerationist = ''
                        safetyist       = ''
                        skeptic         = ''
                    }
                    linked_nodes    = @()
                    conflict_ids    = @()
                }
            } else {
                $NewNode = [ordered]@{
                    id                 = $Proposal.suggested_id
                    category           = $Proposal.category
                    label              = $Proposal.label
                    description        = $Proposal.description
                    parent_id          = $null
                    children           = @()
                    situation_refs = @()
                }
            }

            $Raw.nodes += $NewNode
        }

        'RELABEL' {
            $Target = $Raw.nodes | Where-Object { $_.id -eq $Proposal.target_node_id }
            if (-not $Target) {
                return [PSCustomObject]@{ Success = $false; Error = "Target node '$($Proposal.target_node_id)' not found" }
            }

            if ($Proposal.label) { $Target.label = $Proposal.label }
            if ($Proposal.description) { $Target.description = $Proposal.description }
        }

        'MERGE' {
            $SurvivorId = $Proposal.surviving_node_id
            $MergeIds   = @($Proposal.merge_node_ids)

            $Survivor = $Raw.nodes | Where-Object { $_.id -eq $SurvivorId }
            if (-not $Survivor) {
                return [PSCustomObject]@{ Success = $false; Error = "Surviving node '$SurvivorId' not found" }
            }

            # Update survivor label/description if proposal provides them
            if ($Proposal.label) { $Survivor.label = $Proposal.label }
            if ($Proposal.description) { $Survivor.description = $Proposal.description }

            # Remove merged nodes (except survivor)
            $RemoveIds = $MergeIds | Where-Object { $_ -ne $SurvivorId }
            $Raw.nodes = @($Raw.nodes | Where-Object { $_.id -notin $RemoveIds })

            # Update references in remaining nodes
            foreach ($Node in $Raw.nodes) {
                if ($Node.PSObject.Properties['children'] -and $Node.children) {
                    $Node.children = @($Node.children | ForEach-Object {
                        if ($_ -in $RemoveIds) { $SurvivorId } else { $_ }
                    } | Select-Object -Unique)
                }
                if ($Node.PSObject.Properties['situation_refs'] -and $Node.situation_refs) {
                    $Node.situation_refs = @($Node.situation_refs | ForEach-Object {
                        if ($_ -in $RemoveIds) { $SurvivorId } else { $_ }
                    } | Select-Object -Unique)
                }
                if ($Node.PSObject.Properties['parent_id'] -and $Node.parent_id -in $RemoveIds) {
                    $Node.parent_id = $SurvivorId
                }
            }

            Write-Warning "Merged nodes removed: $($RemoveIds -join ', '). Summaries and edges referencing these IDs may need updating."
        }

        'SPLIT' {
            $TargetId = $Proposal.target_node_id
            $Target = $Raw.nodes | Where-Object { $_.id -eq $TargetId }
            if (-not $Target) {
                return [PSCustomObject]@{ Success = $false; Error = "Target node '$TargetId' not found for SPLIT" }
            }

            $ChildProposals = @($Proposal.children)
            if ($ChildProposals.Count -eq 0) {
                return [PSCustomObject]@{ Success = $false; Error = "SPLIT proposal has no children" }
            }

            # Create child nodes
            foreach ($Child in $ChildProposals) {
                if ($Child.PSObject.Properties['category']) { $ChildCat = $Child.category } else { $ChildCat = $Target.category }
                if ($Target.PSObject.Properties['situation_refs']) { $ChildSitRefs = $Target.situation_refs } else { $ChildSitRefs = @() }
                $ChildNode = [ordered]@{
                    id                 = $Child.suggested_id
                    category           = $ChildCat
                    label              = $Child.label
                    description        = $Child.description
                    parent_id          = $TargetId
                    children           = @()
                    situation_refs = $ChildSitRefs
                }
                $Raw.nodes += $ChildNode
            }

            # Update parent to reference children
            $Target.children = @($ChildProposals | ForEach-Object { $_.suggested_id })

            Write-Warning "Split '$TargetId' into $($ChildProposals.Count) children. Summaries referencing '$TargetId' may need re-processing."
        }

        'REORDER' {
            $TargetId = $Proposal.target_node_id
            $NewParentId = $Proposal.new_parent_id

            $Target = $Raw.nodes | Where-Object { $_.id -eq $TargetId }
            if (-not $Target) {
                return [PSCustomObject]@{ Success = $false; Error = "Target node '$TargetId' not found for REORDER" }
            }

            $NewParent = $Raw.nodes | Where-Object { $_.id -eq $NewParentId }
            if (-not $NewParent) {
                return [PSCustomObject]@{ Success = $false; Error = "New parent '$NewParentId' not found — exact match required" }
            }

            # Remove from old parent's children array
            $OldParentId = $Target.parent_id
            if ($OldParentId) {
                $OldParent = $Raw.nodes | Where-Object { $_.id -eq $OldParentId }
                if ($OldParent -and $OldParent.PSObject.Properties['children']) {
                    $OldParent.children = @($OldParent.children | Where-Object { $_ -ne $TargetId })
                }
            }

            # Set new parent
            $Target.parent_id = $NewParentId

            # Add to new parent's children
            if ($NewParent.PSObject.Properties['children']) {
                if ($TargetId -notin @($NewParent.children)) {
                    $NewParent.children = @($NewParent.children) + @($TargetId)
                }
            }
            else {
                $NewParent | Add-Member -NotePropertyName 'children' -NotePropertyValue @($TargetId) -Force
            }
        }

        'DEPTH_EXPAND' {
            $TargetId = $Proposal.target_node_id
            $Target = $Raw.nodes | Where-Object { $_.id -eq $TargetId }
            if (-not $Target) {
                return [PSCustomObject]@{ Success = $false; Error = "Target node '$TargetId' not found for DEPTH_EXPAND" }
            }

            $SubGroups = @($Proposal.children)
            if ($SubGroups.Count -eq 0) {
                return [PSCustomObject]@{ Success = $false; Error = "DEPTH_EXPAND has no sub-group proposals" }
            }
            if ($SubGroups.Count -gt 3) {
                return [PSCustomObject]@{ Success = $false; Error = "DEPTH_EXPAND exceeds max 3 node changes per proposal" }
            }

            # Create intermediate parent nodes under the dense parent
            foreach ($SubGroup in $SubGroups) {
                if ($SubGroup.PSObject.Properties['category']) { $SubGrpCat = $SubGroup.category } else { $SubGrpCat = $Target.category }
                $IntNode = [ordered]@{
                    id          = $SubGroup.suggested_id
                    category    = $SubGrpCat
                    label       = $SubGroup.label
                    description = $SubGroup.description
                    parent_id   = $TargetId
                    children    = @()
                    situation_refs = @()
                }
                $Raw.nodes += [PSCustomObject]$IntNode

                # Move assigned children under the new intermediate node
                if ($SubGroup.PSObject.Properties['assigned_children']) {
                    foreach ($ChildId in @($SubGroup.assigned_children)) {
                        $Child = $Raw.nodes | Where-Object { $_.id -eq $ChildId }
                        if ($Child) {
                            $Child.parent_id = $SubGroup.suggested_id
                            $IntNode.children = @($IntNode.children) + @($ChildId)
                        }
                    }
                    # Remove moved children from original parent's children array
                    if ($Target.PSObject.Properties['children']) {
                        $Target.children = @($Target.children | Where-Object { $_ -notin @($SubGroup.assigned_children) })
                    }
                }
            }

            # Add new intermediate nodes to parent's children
            if ($Target.PSObject.Properties['children']) {
                $Target.children = @($Target.children) + @($SubGroups | ForEach-Object { $_.suggested_id })
            }
        }

        'WIDTH_EXPAND' {
            # Same as NEW but motivated by density signals
            if ($Raw.nodes | Where-Object { $_.id -eq $Proposal.suggested_id }) {
                return [PSCustomObject]@{ Success = $false; Error = "Node ID '$($Proposal.suggested_id)' already exists" }
            }

            $NewNode = [ordered]@{
                id          = $Proposal.suggested_id
                category    = $Proposal.category
                label       = $Proposal.label
                description = $Proposal.description
                parent_id   = $null
                children    = @()
                situation_refs = @()
            }
            $Raw.nodes += [PSCustomObject]$NewNode
        }

        default {
            return [PSCustomObject]@{ Success = $false; Error = "Unknown action: $($Proposal.action)" }
        }
    }

    $Raw.last_modified = $Today
    $Json = $Raw | ConvertTo-Json -Depth 20
    try {
        Set-Content -Path $FilePath -Value $Json -Encoding UTF8
    }
    catch {
        return [PSCustomObject]@{ Success = $false; Error = "Failed to write $FileName — $($_.Exception.Message)" }
    }

    return [PSCustomObject]@{ Success = $true; Error = $null }
}