Public/Import-OrganizationEdge.ps1

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

function Import-OrganizationEdge {
    <#
    .SYNOPSIS
        Create or update an organization actor-relationship edge in organization_edges.json.
    .DESCRIPTION
        Upserts by composite key (source, target, type). Adds a new edge if the triple
        doesn't exist, otherwise updates the existing record's rationale/source_refs/status/
        discovered_at. Type is validated via Resolve-OrganizationEdgeType — unknown types
        are rejected before any write.
 
        Post-write, runs Test-OrganizationEdgeIntegrity and emits a warning (not an
        exception) if it fails — mirroring Import-Organization's rollback signal
        pattern. Git is the safety net for data-repo files.
 
        Writes are atomic: staged to a temp file, then File.Move'd over the target.
 
        Invalidates the module-scope cache so subsequent reads see the change.
    .PARAMETER Source
        The source org id (org-NNN). Must match ^org-\d{3,}$.
    .PARAMETER Target
        The target id — org-*, sit-*, pol-*, src-*, or POV BDI node id.
        Family must match the -Type per the HLD; integrity check confirms.
    .PARAMETER Type
        Edge type — any casing; validated + normalized via Resolve-OrganizationEdgeType.
    .PARAMETER Rationale
        Free-text justification for the relationship.
    .PARAMETER SourceRefs
        Optional citation/source ids supporting the relationship.
    .PARAMETER Status
        approved (default) | proposed | disputed | rejected.
    .PARAMETER DiscoveredAt
        YYYY-MM-DD. Defaults to today.
    .PARAMETER InputObject
        Alternative: supply a full pre-built hashtable / PSCustomObject with fields
        source, target, type, and optional rationale, source_refs, status, discovered_at.
    .OUTPUTS
        [OrganizationEdge]
    .EXAMPLE
        Import-OrganizationEdge -Source org-001 -Target org-002 -Type COMPETES_WITH -Rationale 'Direct rivals in frontier model training.'
    .EXAMPLE
        Import-OrganizationEdge -InputObject @{ source='org-001'; target='sit-042'; type='ADVOCATES_FOR'; rationale='...' }
    .LINK
        Show-AITriadHelp
    .LINK
        Get-OrganizationEdge
    .LINK
        Get-Organization
    #>

    [CmdletBinding(SupportsShouldProcess, DefaultParameterSetName = 'Fields')]
    [OutputType('OrganizationEdge')]
    param(
        [Parameter(Mandatory, ParameterSetName='Fields')]
        [ValidatePattern('^org-\d{3,}$')]
        [string]$Source,

        [Parameter(Mandatory, ParameterSetName='Fields')]
        [string]$Target,

        [Parameter(Mandatory, ParameterSetName='Fields')]
        [string]$Type,

        [Parameter(ParameterSetName='Fields')]
        [string]$Rationale = '',

        [Parameter(ParameterSetName='Fields')]
        [string[]]$SourceRefs = @(),

        [Parameter(ParameterSetName='Fields')]
        [ValidateSet('approved','proposed','disputed','rejected')]
        [string]$Status = 'approved',

        [Parameter(ParameterSetName='Fields')]
        [string]$DiscoveredAt,

        [Parameter(Mandatory, ParameterSetName='InputObject')]
        [object]$InputObject
    )

    Set-StrictMode -Version Latest

    $path = Get-OrganizationEdgesFilePath
    $store = Get-OrganizationEdgesStore -Force

    # Build the incoming record
    if ($PSCmdlet.ParameterSetName -eq 'InputObject') {
        foreach ($f in @('source','target','type')) {
            if (-not $InputObject.PSObject.Properties[$f]) {
                throw (New-ActionableError `
                    -Goal 'Import organization edge' `
                    -Problem "-InputObject is missing required field: $f" `
                    -Location 'Import-OrganizationEdge' `
                    -NextSteps @("Add $f to the input record"))
            }
        }
        $srcIn  = [string]$InputObject.source
        $tgtIn  = [string]$InputObject.target
        $typeIn = [string]$InputObject.type
        $ratIn  = if ($InputObject.PSObject.Properties['rationale'])   { [string]$InputObject.rationale }   else { '' }
        $refsIn = if ($InputObject.PSObject.Properties['source_refs']) { @($InputObject.source_refs | ForEach-Object { [string]$_ }) } else { @() }
        $statIn = if ($InputObject.PSObject.Properties['status'])      { [string]$InputObject.status }     else { 'approved' }
        $daIn   = if ($InputObject.PSObject.Properties['discovered_at']) { [string]$InputObject.discovered_at } else { (Get-Date).ToString('yyyy-MM-dd') }
    } else {
        $srcIn  = $Source
        $tgtIn  = $Target
        $typeIn = $Type
        $ratIn  = $Rationale
        $refsIn = @($SourceRefs)
        $statIn = $Status
        $daIn   = if ($DiscoveredAt) { $DiscoveredAt } else { (Get-Date).ToString('yyyy-MM-dd') }
    }

    # Validate + normalize the type BEFORE writing. Unknown types are rejected outright —
    # different from the integrity validator (which reports on already-written data).
    $res = Resolve-OrganizationEdgeType -Type $typeIn
    if ($res.Action -ne 'accept') {
        throw (New-ActionableError `
            -Goal 'Import organization edge' `
            -Problem "type '$typeIn' rejected: $($res.Reason)" `
            -Location 'Import-OrganizationEdge' `
            -NextSteps @(
                'List valid types with: Get-OrganizationEdgeType',
                'Registry lives in scripts/AITriad/Private/Resolve-OrganizationEdgeType.ps1'
            ))
    }
    $typeIn = $res.Type

    # Prevent self-loops early — the integrity check would flag them post-write, but no
    # reason to write them and then complain.
    if ($srcIn -eq $tgtIn) {
        throw (New-ActionableError `
            -Goal 'Import organization edge' `
            -Problem "self-loop rejected: source and target are both '$srcIn'" `
            -Location 'Import-OrganizationEdge' `
            -NextSteps @('Choose a distinct target — actor edges are directed and reflexive edges have no meaning here'))
    }

    # source_refs must be a typed array so ConvertTo-Json preserves the
    # array shape on single-element inputs (t/1553#7 — CL flagged that
    # Stage 0's one-element proposals were being unwrapped to bare strings
    # in the serialized store).
    $incoming = [PSCustomObject]@{
        source        = $srcIn
        target        = $tgtIn
        type          = $typeIn
        rationale     = $ratIn
        source_refs   = [string[]]$refsIn
        status        = $statIn
        discovered_at = $daIn
    }

    # Upsert by composite key (source, target, type)
    if ($store.PSObject.Properties['edges']) {
        $existing = @($store.edges)
    } else {
        $existing = @()
    }
    $index = -1
    for ($i = 0; $i -lt @($existing).Count; $i++) {
        $es = if ($existing[$i].PSObject.Properties['source']) { [string]$existing[$i].source } else { '' }
        $et = if ($existing[$i].PSObject.Properties['target']) { [string]$existing[$i].target } else { '' }
        $ey = if ($existing[$i].PSObject.Properties['type'])   { [string]$existing[$i].type }   else { '' }
        if ($es -eq $srcIn -and $et -eq $tgtIn -and $ey.ToUpperInvariant() -eq $typeIn) {
            $index = $i; break
        }
    }
    if ($index -ge 0) {
        $existing[$index] = $incoming
        Write-Verbose "Updating existing organization edge: $srcIn --[$typeIn]--> $tgtIn"
    } else {
        $existing += $incoming
        Write-Verbose "Adding new organization edge: $srcIn --[$typeIn]--> $tgtIn"
    }

    if ($store.PSObject.Properties['edges']) {
        $store.edges = @($existing)
    } else {
        Add-Member -InputObject $store -MemberType NoteProperty -Name 'edges' -Value (@($existing))
    }
    $stamp = (Get-Date).ToString('yyyy-MM-dd')
    if ($store.PSObject.Properties['last_modified']) {
        $store.last_modified = $stamp
    } else {
        Add-Member -InputObject $store -MemberType NoteProperty -Name 'last_modified' -Value $stamp
    }

    if (-not $PSCmdlet.ShouldProcess($path, "Upsert organization edge $srcIn -[$typeIn]-> $tgtIn")) {
        return (ConvertTo-OrganizationEdgeObject -Raw $incoming)
    }

    # Atomic write: temp + File.Move
    $temp = "$path.tmp"
    $json = $store | ConvertTo-Json -Depth 8
    try {
        Set-Content -Path $temp -Value $json -Encoding utf8NoBOM
        [System.IO.File]::Move($temp, $path, $true)
    } catch {
        if (Test-Path $temp) { Remove-Item $temp -Force -ErrorAction SilentlyContinue }
        throw (New-ActionableError `
            -Goal 'Write organization_edges.json' `
            -Problem "Failed to write registry: $($_.Exception.Message)" `
            -Location 'Import-OrganizationEdge' `
            -NextSteps @(
                "Verify write access to $path",
                'Check disk space',
                'Restore from git if the file is corrupted'
            ))
    }

    Clear-OrganizationEdgesCache

    # Post-write validation — warn-only, matches Import-Organization (git is the safety net)
    $report = Test-OrganizationEdgeIntegrity
    if (-not $report.Pass) {
        Write-Warning "Post-write integrity check FAILED: $($report.Errors) errors, $($report.Warnings) warnings. Review with: (Test-OrganizationEdgeIntegrity).Issues"
    }

    return (ConvertTo-OrganizationEdgeObject -Raw $incoming)
}