Private/Get-SPMDeltaHtml.ps1

function Merge-SPMDeltaSite {
    <#
    .SYNOPSIS
        Merges a reference and a difference site (deep-cloned) into one annotated site:
        principals/nodes/assignments carry a 'delta' field (added|removed|changed) so the
        report template can render the full matrix with the changes highlighted.
    #>

    [CmdletBinding()]
    param(
        [Parameter(Mandatory)]$RefSite,
        [Parameter(Mandatory)]$DifSite
    )

    $normUrl = { param($u) ([string]$u).TrimEnd('/').ToLowerInvariant() }
    $loginOf = { param($p) ([string]$p.loginName).ToLowerInvariant() }

    # --- principals: union by loginName; ref-only principals join as 'removed' columns ---
    $difByLogin = @{}
    foreach ($p in @($DifSite.principals)) { $difByLogin[(& $loginOf $p)] = $p }
    $refByLogin = @{}
    foreach ($p in @($RefSite.principals)) { $refByLogin[(& $loginOf $p)] = $p }

    $refIdMap = @{}   # ref principal id -> merged principal id
    $removedPrincipals = [System.Collections.Generic.List[object]]::new()
    foreach ($p in @($RefSite.principals)) {
        $l = & $loginOf $p
        if ($difByLogin.ContainsKey($l)) {
            $refIdMap[[string]$p.id] = [string]$difByLogin[$l].id
        }
        else {
            $newId = 'ref_' + [string]$p.id
            $refIdMap[[string]$p.id] = $newId
            $p.id = $newId
            $p | Add-Member -NotePropertyName delta -NotePropertyValue 'removed' -Force
            $removedPrincipals.Add($p)
        }
    }
    foreach ($p in @($DifSite.principals)) {
        if (-not $refByLogin.ContainsKey((& $loginOf $p))) {
            $p | Add-Member -NotePropertyName delta -NotePropertyValue 'added' -Force
        }
    }
    $DifSite.principals = @($DifSite.principals) + $removedPrincipals.ToArray()

    # --- nodes: dif order is the backbone; assignments merged per node ---
    $refNodesByUrl = [ordered]@{}
    foreach ($n in @($RefSite.nodes)) { $refNodesByUrl[(& $normUrl $n.url)] = $n }
    $refNodeById = @{}
    foreach ($n in @($RefSite.nodes)) { $refNodeById[[string]$n.id] = $n }
    $difUrls = [System.Collections.Generic.HashSet[string]]::new()
    foreach ($n in @($DifSite.nodes)) { [void]$difUrls.Add((& $normUrl $n.url)) }

    foreach ($dn in @($DifSite.nodes)) {
        $rn = $refNodesByUrl[(& $normUrl $dn.url)]
        if (-not $rn) {
            $dn | Add-Member -NotePropertyName delta -NotePropertyValue 'added' -Force
            continue
        }
        $refAssign = @{}
        foreach ($ra in @($rn.assignments)) { $refAssign[$refIdMap[[string]$ra.principalId]] = $ra }
        $seen = [System.Collections.Generic.HashSet[string]]::new()
        foreach ($da in @($dn.assignments)) {
            [void]$seen.Add([string]$da.principalId)
            $ra = $refAssign[[string]$da.principalId]
            if (-not $ra) {
                $da | Add-Member -NotePropertyName delta -NotePropertyValue 'added' -Force
            }
            elseif ([string]$ra.permission -ne [string]$da.permission) {
                $da | Add-Member -NotePropertyName delta -NotePropertyValue 'changed' -Force
                $da | Add-Member -NotePropertyName oldPermission -NotePropertyValue ([string]$ra.permission) -Force
            }
        }
        $removedCells = [System.Collections.Generic.List[object]]::new()
        foreach ($mid in $refAssign.Keys) {
            if (-not $seen.Contains($mid)) {
                $ra = $refAssign[$mid]
                $removedCells.Add([pscustomobject]@{
                        principalId = $mid; permission = [string]$ra.permission
                        roles = @($ra.roles); delta = 'removed'
                    })
            }
        }
        if ($removedCells.Count) { $dn.assignments = @($dn.assignments) + $removedCells.ToArray() }
    }

    # ref-only nodes join as 'removed' rows right below their parent
    $merged = [System.Collections.Generic.List[object]]::new()
    $merged.AddRange([object[]]@($DifSite.nodes))
    $mergedByUrl = @{}
    foreach ($n in $merged) { $mergedByUrl[(& $normUrl $n.url)] = $n }
    foreach ($rn in @($RefSite.nodes)) {
        $u = & $normUrl $rn.url
        if ($difUrls.Contains($u)) { continue }
        $rn | Add-Member -NotePropertyName delta -NotePropertyValue 'removed' -Force
        $rn.id = 'ref_' + [string]$rn.id
        foreach ($ra in @($rn.assignments)) { $ra.principalId = $refIdMap[[string]$ra.principalId] }
        $parent = if ($rn.parentId) { $refNodeById[([string]$rn.parentId -replace '^ref_', '')] } else { $null }
        if ($parent) {
            $mp = $mergedByUrl[(& $normUrl $parent.url)]
            if ($mp) {
                $rn.parentId = [string]$mp.id
                $merged.Insert($merged.IndexOf($mp) + 1, $rn)
            }
            else {
                $rn.parentId = $null
                $merged.Add($rn)
            }
        }
        else {
            $rn.parentId = $null
            $merged.Add($rn)
        }
        $mergedByUrl[$u] = $rn
    }
    $DifSite.nodes = $merged.ToArray()

    # custom permission level footnotes: union (current wins)
    if ($RefSite.customRoles) {
        $roles = [ordered]@{}
        foreach ($prop in $DifSite.customRoles.PSObject.Properties) { $roles[$prop.Name] = $prop.Value }
        foreach ($prop in $RefSite.customRoles.PSObject.Properties) {
            if (-not $roles.Contains($prop.Name)) { $roles[$prop.Name] = $prop.Value }
        }
        $DifSite.customRoles = [pscustomobject]$roles
    }

    return $DifSite
}

function Get-SPMEffectivePermission {
    <#
    .SYNOPSIS
        Best (strongest) permission a person effectively has on a node - direct or via
        any group whose resolved member set contains the person. Sharing links are
        excluded, matching the person view's default.
    #>

    [CmdletBinding()]
    param($Node, [string]$PersonKey, $PersonMap, [hashtable]$PrincipalById)

    $best = $null
    $bestRank = 99
    foreach ($a in @($Node.assignments)) {
        $p = $PrincipalById[[string]$a.principalId]
        if (-not $p -or $p.type -eq 'SharingLink') { continue }
        $set = $PersonMap.PerPrincipal[[string]$p.id]
        if ($set -and $set.Contains($PersonKey)) {
            $r = Get-SPMPermRank -Permission ([string]$a.permission)
            if ($r -lt $bestRank) { $bestRank = $r; $best = [string]$a.permission }
        }
    }
    return $best
}

function Add-SPMPersonDelta {
    <#
    .SYNOPSIS
        Annotates a merged site with person-level deltas so the matrix can highlight
        changes that happen through group membership (the assignments themselves stay
        identical): membersDelta on group columns, personDelta (added/removed/changed
        people with their effective per-node permission changes) for the person view.
    #>

    [CmdletBinding()]
    param(
        [Parameter(Mandatory)]$MergedSite,
        [Parameter(Mandatory)]$RefSite,
        [Parameter(Mandatory)]$DifSite
    )

    $normUrl = { param($u) ([string]$u).TrimEnd('/').ToLowerInvariant() }
    $loginOf = { param($p) ([string]$p.loginName).ToLowerInvariant() }

    $refMap = Get-SPMPersonMap -Site $RefSite
    $difMap = Get-SPMPersonMap -Site $DifSite
    $refPById = @{}
    foreach ($p in @($RefSite.principals)) { $refPById[[string]$p.id] = $p }
    $difPById = @{}
    foreach ($p in @($DifSite.principals)) { $difPById[[string]$p.id] = $p }
    $refByLogin = @{}
    foreach ($p in @($RefSite.principals)) { $refByLogin[(& $loginOf $p)] = $p }
    $difByLogin = @{}
    foreach ($p in @($DifSite.principals)) { $difByLogin[(& $loginOf $p)] = $p }

    $refPersons = @{}
    foreach ($pers in @($refMap.Persons)) { $refPersons[[string]$pers.key] = $pers }
    $difPersons = @{}
    foreach ($pers in @($difMap.Persons)) { $difPersons[[string]$pers.key] = $pers }

    $candidates = [System.Collections.Generic.HashSet[string]]::new()

    # 1. group membership changes -> membersDelta on the merged principal columns
    foreach ($mp in @($MergedSite.principals)) {
        if ($mp.delta) { continue }   # added/removed columns are already marked
        $l = & $loginOf $mp
        $rp = $refByLogin[$l]
        $dp = $difByLogin[$l]
        if (-not $rp -or -not $dp) { continue }
        $refSet = $refMap.PerPrincipal[[string]$rp.id]
        $difSet = $difMap.PerPrincipal[[string]$dp.id]
        if ($null -eq $refSet -or $null -eq $difSet) { continue }
        $addedKeys = @($difSet | Where-Object { -not $refSet.Contains($_) })
        $removedKeys = @($refSet | Where-Object { -not $difSet.Contains($_) })
        if (-not $addedKeys.Count -and -not $removedKeys.Count) { continue }
        $nameOf = { param($k, $map) $p = $map[$k]; if ($p) { [string]$p.name } else { $k } }
        $mp | Add-Member -NotePropertyName membersDelta -NotePropertyValue ([pscustomobject]@{
                added   = @($addedKeys | ForEach-Object { & $nameOf $_ $difPersons })
                removed = @($removedKeys | ForEach-Object { & $nameOf $_ $refPersons })
            }) -Force
        foreach ($k in $addedKeys + $removedKeys) { [void]$candidates.Add($k) }
    }

    # 2. persons touched by assignment-level changes (a group level change shifts
    # the effective permission of every member)
    foreach ($n in @($MergedSite.nodes)) {
        foreach ($a in @($n.assignments)) {
            if (-not $a.delta) { continue }
            $pid = [string]$a.principalId
            $set = if ($pid.StartsWith('ref_')) { $refMap.PerPrincipal[$pid.Substring(4)] } else { $difMap.PerPrincipal[$pid] }
            if ($set) { foreach ($k in $set) { [void]$candidates.Add($k) } }
        }
    }

    # 3. persons that exist on only one side
    foreach ($k in $refPersons.Keys) { if (-not $difPersons.ContainsKey($k)) { [void]$candidates.Add($k) } }
    foreach ($k in $difPersons.Keys) { if (-not $refPersons.ContainsKey($k)) { [void]$candidates.Add($k) } }

    if (-not $candidates.Count) { return }

    $refNodesByUrl = @{}
    foreach ($n in @($RefSite.nodes)) { $refNodesByUrl[(& $normUrl $n.url)] = $n }
    $difNodesByUrl = @{}
    foreach ($n in @($DifSite.nodes)) { $difNodesByUrl[(& $normUrl $n.url)] = $n }

    $added = [System.Collections.Generic.List[object]]::new()
    $removed = [System.Collections.Generic.List[object]]::new()
    $changed = [System.Collections.Generic.List[object]]::new()

    foreach ($key in $candidates) {
        $inRef = $refPersons.ContainsKey($key)
        $inDif = $difPersons.ContainsKey($key)
        $pers = if ($inDif) { $difPersons[$key] } else { $refPersons[$key] }

        if ($inDif -and -not $inRef) {
            $added.Add([pscustomobject]@{ key = $key; name = $pers.name; upn = $pers.upn })
            continue
        }
        if ($inRef -and -not $inDif) {
            $cells = [System.Collections.Generic.List[object]]::new()
            foreach ($n in @($MergedSite.nodes)) {
                $rn = $refNodesByUrl[(& $normUrl $n.url)]
                if (-not $rn) { continue }
                $perm = Get-SPMEffectivePermission -Node $rn -PersonKey $key -PersonMap $refMap -PrincipalById $refPById
                if ($perm) { $cells.Add([pscustomobject]@{ nodeId = [string]$n.id; perm = $perm }) }
            }
            $removed.Add([pscustomobject]@{ key = $key; name = $pers.name; upn = $pers.upn; cells = $cells.ToArray() })
            continue
        }

        $changes = [System.Collections.Generic.List[object]]::new()
        foreach ($n in @($MergedSite.nodes)) {
            $rn = $refNodesByUrl[(& $normUrl $n.url)]
            $dn = $difNodesByUrl[(& $normUrl $n.url)]
            $from = if ($rn) { Get-SPMEffectivePermission -Node $rn -PersonKey $key -PersonMap $refMap -PrincipalById $refPById } else { $null }
            $to = if ($dn) { Get-SPMEffectivePermission -Node $dn -PersonKey $key -PersonMap $difMap -PrincipalById $difPById } else { $null }
            if ([string]$from -ne [string]$to) {
                $changes.Add([pscustomobject]@{ nodeId = [string]$n.id; from = $from; to = $to })
            }
        }
        if ($changes.Count) {
            $changed.Add([pscustomobject]@{ key = $key; name = $pers.name; upn = $pers.upn; changes = $changes.ToArray() })
        }
    }

    if ($added.Count -or $removed.Count -or $changed.Count) {
        $MergedSite | Add-Member -NotePropertyName personDelta -NotePropertyValue ([pscustomobject]@{
                added = $added.ToArray(); removed = $removed.ToArray(); changed = $changed.ToArray()
            }) -Force
    }
}

function Get-SPMDeltaScan {
    <#
    .SYNOPSIS
        Builds the merged, delta-annotated scan object that the report template renders
        in delta mode (deltaMode=true, per-site deltaSummary, per-cell delta flags).
    #>

    [CmdletBinding()]
    param(
        [Parameter(Mandatory)]$Delta,
        [Parameter(Mandatory)]$Reference,
        [Parameter(Mandatory)]$Difference
    )

    $normUrl = { param($u) ([string]$u).TrimEnd('/').ToLowerInvariant() }

    # deep clones - annotation must never mutate the caller's data
    $ref = $Reference | ConvertTo-Json -Depth 64 | ConvertFrom-Json
    $dif = $Difference | ConvertTo-Json -Depth 64 | ConvertFrom-Json

    $refSites = @{}
    foreach ($s in @($ref.sites)) { $refSites[(& $normUrl $s.url)] = $s }
    $summaries = @{}
    foreach ($s in @($Delta.sites)) { $summaries[(& $normUrl $s.url)] = $s }

    $sites = [System.Collections.Generic.List[object]]::new()
    foreach ($ds in @($dif.sites)) {
        $key = & $normUrl $ds.url
        $site = if ($refSites.ContainsKey($key)) {
            $refClone = $refSites[$key] | ConvertTo-Json -Depth 64 | ConvertFrom-Json
            $m = Merge-SPMDeltaSite -RefSite $refSites[$key] -DifSite $ds
            Add-SPMPersonDelta -MergedSite $m -RefSite $refClone -DifSite ($Difference.sites | Where-Object { (& $normUrl $_.url) -eq $key } | Select-Object -First 1)
            $m
        }
        else {
            $ds | Add-Member -NotePropertyName deltaStatus -NotePropertyValue 'added' -Force
            $ds
        }
        $site | Add-Member -NotePropertyName deltaSummary -NotePropertyValue $summaries[$key] -Force
        $sites.Add($site)
    }
    foreach ($key in $refSites.Keys) {
        $found = $false
        foreach ($ds in @($dif.sites)) { if ((& $normUrl $ds.url) -eq $key) { $found = $true; break } }
        if ($found) { continue }
        $rs = $refSites[$key]
        $rs | Add-Member -NotePropertyName deltaStatus -NotePropertyValue 'removed' -Force
        $rs | Add-Member -NotePropertyName deltaSummary -NotePropertyValue $summaries[$key] -Force
        $sites.Add($rs)
    }

    [pscustomobject]@{
        scanDate     = $dif.scanDate
        refScanDate  = $ref.scanDate
        depth        = $dif.depth
        generator    = $dif.generator
        siteCount    = $sites.Count
        deltaMode    = $true
        totalChanges = [int]$Delta.totalChanges
        sites        = $sites.ToArray()
    }
}

function Get-SPMDeltaHtml {
    <#
    .SYNOPSIS
        Renders a delta (from Compare-SPPermissionScan) as the interactive permission
        matrix with changes highlighted (yellow = new/changed, red = removed), a
        "Delta only" toggle and a Changes tab with the classic change tables.
    #>

    [CmdletBinding()]
    param(
        [Parameter(Mandatory)]$Delta,
        [Parameter(Mandatory)]$Reference,
        [Parameter(Mandatory)]$Difference,
        [string]$Title = 'SharePoint Permission Delta',
        [string]$LogoBase64,
        [string]$AccentColor
    )

    $merged = Get-SPMDeltaScan -Delta $Delta -Reference $Reference -Difference $Difference
    $json = $merged | ConvertTo-Json -Depth 64
    $safeJson = $json -replace '</', '<\/'

    $logoHtml = @'
<svg class="logo" viewBox="0 0 46 46" xmlns="http://www.w3.org/2000/svg" aria-hidden="true">
      <rect x="1.5" y="1.5" width="43" height="43" rx="11" fill="rgba(255,255,255,.06)" stroke="rgba(255,255,255,.35)" stroke-width="1.5"/>
      <rect x="10" y="10" width="7.5" height="7.5" rx="2" fill="#5eead4"/>
      <rect x="19.5" y="10" width="7.5" height="7.5" rx="2" fill="rgba(255,255,255,.28)"/>
      <rect x="29" y="10" width="7.5" height="7.5" rx="2" fill="#38bdf8"/>
      <rect x="10" y="19.5" width="7.5" height="7.5" rx="2" fill="rgba(255,255,255,.16)"/>
      <rect x="19.5" y="19.5" width="7.5" height="7.5" rx="2" fill="#818cf8"/>
      <rect x="29" y="19.5" width="7.5" height="7.5" rx="2" fill="rgba(255,255,255,.28)"/>
      <rect x="10" y="29" width="7.5" height="7.5" rx="2" fill="#38bdf8"/>
      <rect x="19.5" y="29" width="7.5" height="7.5" rx="2" fill="rgba(255,255,255,.16)"/>
      <rect x="29" y="29" width="7.5" height="7.5" rx="2" fill="#5eead4"/>
    </svg>
'@

    if ($LogoBase64) {
        $src = if ($LogoBase64.StartsWith('data:')) { $LogoBase64 } else { "data:image/png;base64,$LogoBase64" }
        $logoHtml = '<img class="logo" src="' + $src + '" alt="logo" style="object-fit:contain;background:#fff;border-radius:10px;padding:3px">'
    }
    $brandCss = if ($AccentColor) { ":root { --accent: $AccentColor; } header { background: $AccentColor; }" } else { '' }

    (Get-SPMHtmlTemplate).
    Replace('__REPORT_TITLE__', [System.Net.WebUtility]::HtmlEncode($Title)).
    Replace('__GENERATED__', (Get-Date -Format 'yyyy-MM-dd HH:mm')).
    Replace('__LOGO_HTML__', $logoHtml).
    Replace('__BRAND_CSS__', $brandCss).
    Replace('"__SCAN_DATA__"', $safeJson)
}