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-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)) { Merge-SPMDeltaSite -RefSite $refSites[$key] -DifSite $ds }
        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)
}