Public/Core/Invoke-IDBridge.ps1

<#
.SYNOPSIS
Top-level orchestrator: provision and synchronize AD and Google Workspace accounts from source data.

.DESCRIPTION
Entry point for an IDBridge run. Calls Initialize-IDBridge, applies the runtime switch
overrides, acquires the Google bearer token via Connect-IDBridgeGoogle (when
GoogleToken.Enabled), then runs the ordered pipeline: gather source data from the configured plugins, read
current AD/Google state, enrich and de-duplicate the source records, apply override rows, match
person IDs to existing accounts, and compute every change list (org units, deactivations,
updates/renames/moves, creates, and group membership) read-only. Before any writes it runs the
change-volume safety guard (ChangeThreshold). It then executes the AD and Google changes only
when the directory is enabled and Debug.readOnly is $false, exports the staff CSV, and (in the
finally block) pushes the run log to a Google Sheet when configured. Per-user write errors are
logged and skipped; startup/OU-creation failures and a tripped change threshold abort the run.

.PARAMETER RootPath
Base directory for Config/Logs/Exports/Plugins/Data/Vault. Defaults to C:\IDBridge.

.PARAMETER ReadOnly
Override Debug.readOnly for this run. When set, every change list is computed but nothing is written.

.PARAMETER TestRun
Override Debug.testRun; plugins process a small subset for faster iteration.

.PARAMETER SkipADCheck
Override Debug.skipADCheck; do not throw if the ActiveDirectory module fails to import.

.PARAMETER TraceLogging
Override Debug.TraceLogging; emit Trace-level logging.

.PARAMETER SkipAD
Disable all AD processing (and AD group processing) for this run.

.PARAMETER SkipGoogle
Disable all Google processing (and Google group processing) for this run.

.PARAMETER SkipChangeThreshold
Bypass the change-volume safety guard (ChangeThreshold) for this run.

.PARAMETER DisableTelemetry
Disable usage telemetry for this run (overrides Telemetry.Tier). See PRIVACY.md.

.OUTPUTS
None. Side effects: AD/Google mutations (unless ReadOnly), the UserList-Staff.csv export, and log output.

.EXAMPLE
Invoke-IDBridge -ReadOnly -TraceLogging

.EXAMPLE
Invoke-IDBridge -RootPath 'C:\IDBridge'

.NOTES
   Created by: Sam Cattanach
   Modified: 2026-06-26
#>

function Invoke-IDBridge {
    [CmdletBinding()]
    param (
        [string]$RootPath = "C:\IDBridge",

        [switch]$ReadOnly,
        [switch]$TestRun,
        [switch]$SkipADCheck,
        [switch]$TraceLogging,
        [switch]$SkipAD,
        [switch]$SkipGoogle,
        [switch]$SkipChangeThreshold,
        [switch]$DisableTelemetry
    )

    $runStart = Get-Date

    try{
        #region Import Configuration
        try { Initialize-IDBridge -RootPath $RootPath } catch { Throw }

        try { $IDConfig = Get-IDBridgeConfig } catch { Throw }
        #endregion Import Configuration

        

        #region Apply Runtime Overrides
        if ($PSBoundParameters.ContainsKey('ReadOnly'))       { $IDConfig.Debug.readOnly       = [bool]$ReadOnly }
        if ($PSBoundParameters.ContainsKey('TestRun'))        { $IDConfig.Debug.testRun        = [bool]$TestRun }
        if ($PSBoundParameters.ContainsKey('SkipADCheck'))    { $IDConfig.Debug.skipADCheck    = [bool]$SkipADCheck }
        if ($PSBoundParameters.ContainsKey('TraceLogging'))   { $IDConfig.Debug.TraceLogging   = [bool]$TraceLogging }
        if ($SkipAD)      { $IDConfig.AD.enabled     = $false; $IDConfig.AD.enableGroupProcessing     = $false }
        if ($SkipGoogle)  { $IDConfig.Google.enabled = $false; $IDConfig.Google.enableGroupProcessing = $false }
        if ($SkipChangeThreshold -and $IDConfig.ContainsKey('ChangeThreshold')) { $IDConfig.ChangeThreshold.Enabled = $false }
        if ($DisableTelemetry) { $IDConfig.Telemetry = @{ Tier = 'Off' } }

        foreach ($key in $PSBoundParameters.Keys | Where-Object { $_ -ne 'RootPath' }) {
            Write-Log -Message "OVERRIDE: $key = $($PSBoundParameters[$key])" -Level Info
        }
        #endregion Apply Runtime Overrides




        #region Google Auth
        # Acquired here (not in Initialize-IDBridge) so setup sessions initialize cleanly
        # before the key secret exists. Gated on GoogleToken.Enabled only: -SkipGoogle runs
        # still need headers for Sheets plugins and Google Sheet logging.
        if ($IDConfig.GoogleToken.Enabled -eq $true) {
            try { Connect-IDBridgeGoogle } catch { Throw }
        } else {
            Write-Log -Message "Google API integration is disabled. Google-related functions will be skipped." -Level Trace
        }
        #endregion Google Auth




        #region Plugins
        try {
            $plugins = Invoke-SourcePlugins
            
            $sourceData = $plugins.SourceData
            $overrideData = $plugins.OverrideData
        }
        catch { Throw }
        #endregion Plugins




        #region Get Google Data
        if ($IDConfig.Google.enabled -eq $true) {
            try {
                $googleData = Get-TargetDataGoogle -ErrorAction Stop
            }
            catch { Throw }
        }
        #endregion Get Google Data




        #region Get Data AD
        if ($IDConfig.AD.enabled -eq $true) {
            try {
                $adData = Get-TargetDataAD -ErrorAction Stop
            }
            catch { Throw }
        }
        #endregion Get Data AD




        #region Target Data Preparation
        if ($IDConfig.AD.enabled -eq $true) {
            $sourceData = Add-TargetDataAD -SourceData $sourceData -ADData $adData
        }

        if ($IDConfig.Google.enabled -eq $true) {
            $sourceData = Add-TargetDataGoogle -SourceData $sourceData -GoogleData $googleData
        }
        #endregion Target Data Preparation




        #region Remove Duplicate IDs
        $sourceData = Remove-IDBridgeDuplicateID -SourceData $sourceData
        #endregion Remove Duplicate IDs




        #region Process Override Data
        $sourceData = Merge-IDBridgeOverrideData -SourceData $sourceData -OverrideData $overrideData
        #endregion Process Override Data




        #region Groups Not Processed
        if ($IDConfig.Debug.TraceLogging -eq $true) {
            #AD Checks
            if ($IDConfig.AD.enabled -eq $true -and ($IDConfig.AD.enableGroupProcessing -eq $true -or $IDConfig.AD.enableGroupProcessingWhatIf -eq $true)) {
                Write-Log -Message "AD: Checking for Groups Proposed for Processing that do not exist in the Target Data." -Level Trace
                Show-GroupsNotProcessed -ProposedGroups $sourceData.GroupsProposed -TargetGroups $adData.Groups
            }

            #Google Checks
            if ($IDConfig.Google.enabled -eq $true -and ($IDConfig.Google.enableGroupProcessing -eq $true -or $IDConfig.Google.enableGroupProcessingWhatIf -eq $true)) {
                Write-Log -Message "Google: Checking for Groups Proposed for Processing that do not exist in the Target Data." -Level Trace
                Show-GroupsNotProcessed -ProposedGroups $sourceData.GroupsProposed -TargetGroups $googleData.Groups.name
            }
        }
        #endregion Groups Not Processed




        #region Match PersonIDs
        if ($IDConfig.AD.enabled -eq $true) {
            #Update filteredData list and ADLookupByID Table with AD User Info if No EmployeeID is Set and an existing user is found that matches
            $ADUsersToSetEmployeeID = Get-ADUsersToSetEmployeeID -UserList $sourceData -CurrentADUsers $adData.Users

            foreach ($item in $SourceData | Where-Object { $ADUsersToSetEmployeeID.ContainsKey($_.personID) }) {
                $matchAD = $ADUsersToSetEmployeeID[$item.personID]
                Write-Log -Message ("AD: Matched $($matchAD.User.UserPrincipalName) with EmployeeID: $($item.personID).")
                $item | Add-Member -MemberType NoteProperty -Name 'ADObject' -Value $matchAD.User -Force
                $item | Add-Member -MemberType NoteProperty -Name 'ADCurrentUserID' -Value $matchAD.ID -Force
                $item | Add-Member -MemberType NoteProperty -Name 'ADCurrentGroups' -Value $matchAD.Groups -Force
                $item | Add-Member -MemberType NoteProperty -Name 'ADCurrentUserEnabledStatus' -Value $matchAD.EnabledStatus -Force
                $ADData.LookupByID[$item.personID] = $matchAD.User
            }
        }

        if ($IDConfig.Google.enabled -eq $true) {
            #Update filteredData list and GoogleLookupByID Table with Google User Info if No EmployeeID is Set and an existing user is found that matches
            $GoogleUsersToSetEmployeeID = Get-GoogleUsersToSetEmployeeID -UserList $sourceData -GoogleUsers $googleData.Users

            foreach ($item in $SourceData | Where-Object { $GoogleUsersToSetEmployeeID.ContainsKey($_.personID) }) {
                $matchGoogle = $GoogleUsersToSetEmployeeID[$item.personID]
                Write-Log -Message ("Google: Matched $($matchGoogle.User.primaryEmail) with EmployeeID: $($item.personID).")
                $item | Add-Member -MemberType NoteProperty -Name 'GoogleObject' -Value $matchGoogle.User -Force
                $item | Add-Member -MemberType NoteProperty -Name 'GoogleCurrentUserID' -Value $matchGoogle.ID -Force
                $item | Add-Member -MemberType NoteProperty -Name 'GoogleCurrentGroups' -Value $matchGoogle.Groups -Force
                $item | Add-Member -MemberType NoteProperty -Name 'GoogleCurrentUserSuspendedStatus' -Value $matchGoogle.SuspendedStatus -Force
                $item | Add-Member -MemberType NoteProperty -Name 'GoogleCurrentLicenses' -Value $matchGoogle.User.CurrentLicenses -Force
                $googleData.LookupByID[$item.personID] = $matchGoogle.User
            }
        }
        #endregion Match PersonIDs




        #region AD Processing Lists
        if ($IDConfig.AD.enabled -eq $true) {
            #Org Units to Create
            $ADOrgUnitsForProcessing = Get-ADOrgUnitsForProcessing -UserList $sourceData -CurrentOrgUnits $adData.OrgUnits
            #Users to Deactivate
            $ADUsersToDeactivate = Get-ADUsersToDeactivate -UserList $sourceData
            #Users to Update
            $ADUsersToUpdate = Get-ADUsersToUpdate -UserList $sourceData -LookupByID $adData.LookupByID
            #Users to Create
            $ADUsersToCreate = Get-ADUsersToCreate -UserList $sourceData -CurrentADUsers $adData.Users
            #Groups to Update
            if ($IDConfig.AD.enableGroupProcessing -eq $true -or $IDConfig.AD.enableGroupProcessingWhatIf -eq $true) {
                $ADUserGroupsToUpdate = Get-ADUserGroupsToUpdate -UserList $sourceData -CurrentADGroups $adData.Groups
            }
        }
        #endregion AD Processing Lists




        #region Google Processing Lists
        if ($IDConfig.Google.enabled -eq $true) {
            #Org Units to Create
            $GoogleOrgUnitsForProcessing = Get-GoogleOrgUnitsForProcessing -UserList $sourceData -CurrentOrgUnits $googleData.OrgUnits.orgUnitPath
            #Users to Update
            $GoogleUsersToUpdate = Get-GoogleUsersToUpdate -UserList $sourceData -LookupByID $googleData.LookupByID -GoogleUsers $googleData.Users
            #Users to Deactivate
            $GoogleUsersToDeactivate = Get-GoogleUsersToDeactivate -UserList $sourceData
            #Users to Create
            $GoogleUsersToCreate = Get-GoogleUsersToCreate -UserList $sourceData -GoogleUsers $googleData.Users
            #Groups to Update
            if ($IDConfig.Google.enableGroupProcessing -eq $true -or $IDConfig.Google.enableGroupProcessingWhatIf -eq $true) {
                $GoogleUserGroupsToUpdate = Get-GoogleUserGroupsToUpdate -UserList $sourceData -GoogleGroups $googleData.Groups
            }
        }
        #endregion Google Processing Lists




        #region Change Threshold Safety Check
        # Guard against a broken source feed mass-changing a directory: if the proposed lifecycle
        # changes exceed a percentage of the existing managed (root-OU) population, abort before any
        # writes. Bypassed by ChangeThreshold.Enabled = $false in config or the -SkipChangeThreshold switch.
        if ($IDConfig.ContainsKey('ChangeThreshold') -and $IDConfig.ChangeThreshold.Enabled -eq $true) {
            $thresholdResults = [System.Collections.Generic.List[object]]::new()

            if ($IDConfig.AD.enabled -eq $true) {
                $adManagedPopulation = @($adData.Users | Where-Object { $_.DistinguishedName -like "*,$($IDConfig.AD.userRootOU)" }).Count
                # Count distinct affected users: a single user needing update+rename+move is one change,
                # not three (so the ratio is comparable to Google's per-user count). CN uniquely identifies the user.
                # Wrap each list in @() so a single-element list (scalar string) concatenates as an
                # array rather than via string addition.
                $adModifiedUsers = [System.Collections.Generic.HashSet[string]]::new(
                    [string[]]@(@($ADUsersToUpdate.UpdateList.CN) + @($ADUsersToUpdate.RenameList.CN) + @($ADUsersToUpdate.MoveList.CN) | Where-Object { $_ })
                )
                $adChangeCount = @($ADUsersToCreate).Count + @($ADUsersToDeactivate).Count + $adModifiedUsers.Count
                $thresholdResults.Add( (Test-IDBridgeChangeThreshold -Directory 'AD' -ChangeCount $adChangeCount -PopulationCount $adManagedPopulation -ThresholdPercent $IDConfig.ChangeThreshold.Percentage) )
            }

            if ($IDConfig.Google.enabled -eq $true) {
                $googleManagedPopulation = @($googleData.Users | Where-Object { $_.orgUnitPath -eq $IDConfig.Google.userRootOU -or $_.orgUnitPath -like "$($IDConfig.Google.userRootOU)/*" }).Count
                $googleChangeCount = @($GoogleUsersToCreate).Count + @($GoogleUsersToDeactivate).Count + @($GoogleUsersToUpdate).Count
                $thresholdResults.Add( (Test-IDBridgeChangeThreshold -Directory 'Google' -ChangeCount $googleChangeCount -PopulationCount $googleManagedPopulation -ThresholdPercent $IDConfig.ChangeThreshold.Percentage) )
            }

            $breaches = @($thresholdResults | Where-Object { $_.Exceeded })
            if ($breaches.Count -gt 0) {
                $breachSummary = ($breaches | ForEach-Object { "$($_.Directory) $($_.Percent)%" }) -join ', '
                Write-Log -Message "Change threshold exceeded ($breachSummary > $($IDConfig.ChangeThreshold.Percentage)%). Aborting run before any writes. Set ChangeThreshold.Enabled = `$false or run with -SkipChangeThreshold to override." -Level Error
                Throw "Change threshold exceeded: $breachSummary (limit $($IDConfig.ChangeThreshold.Percentage)%)."
            }
        }
        #endregion Change Threshold Safety Check




        #region Process AD Changes
        if ($IDConfig.AD.enabled -eq $true -and $IDConfig.Debug.readOnly -eq $false) {
            #Create Org Units (Get-ADOrgUnitsForProcessing already returns these deduped and parents-first)
            foreach ($item in $ADOrgUnitsForProcessing) {
                try {
                    New-IDBridgeADOrgUnit -OrgUnit $item
                }
                catch {
                    Write-Log -Message "AD: Error Creating Org Unit. Please check RunAS user permisisons in AD or Detailed Error for more information" -Level Error
                    Throw
                }
            }

            #Disable Users
            foreach ($item in $ADUsersToDeactivate) {
                try {
                    Disable-IDBridgeADUser -User $item -GroupRemovalProcessingStatus $IDConfig.AD.enableGroupProcessingTrash
                }
                catch {
                    Write-Log -Message ($_.Exception.Message) -Level Error
                }
            }


            #Update Users
            foreach ($item in $ADUsersToUpdate.UpdateList) {
                try {
                    Write-Log -Message "AD: Updating User: $($item.CN) Properties: $($item.splat | ConvertTo-Json -Compress)"
                    $itemSplat = $item.splat
                    Set-ADUser @itemSplat -ErrorAction Stop
                }
                catch {
                    Write-Log -Message ($_.Exception.Message) -Level Error
                }
            }

            #Rename Users
            foreach ($item in $ADUsersToUpdate.RenameList) {
                try {
                    Write-Log -Message "AD: Renaming User: $($item.CN) to $($item.NewName)"
                    Set-ADUser -Identity $item.ADUserID -Division (Get-Date -format yyyy-MM-dd-HH:mm)
                    Rename-ADObject -Identity $item.ADUserID -NewName $item.NewName -ErrorAction Stop
                }
                catch {
                    Write-Log -Message ($_.Exception.Message) -Level Error
                }
            }

            #Move Users
            foreach ($item in $ADUsersToUpdate.MoveList) {
                try {
                    Write-Log -Message "AD: Moving User: $($item.CN) to $($item.NewOrgUnit)"
                    Set-ADUser -Identity $item.ADUserID -Division (Get-Date -format yyyy-MM-dd-HH:mm)
                    Move-ADObject -Identity $item.ADUserID -TargetPath $item.NewOrgUnit -ErrorAction Stop
                }
                catch {
                    Write-Log -Message ($_.Exception.Message) -Level Error
                }
            }

            #Create Users
            foreach ($item in $ADUsersToCreate) {
                try {
                    Write-Log -Message "AD: Creating User: $($item.PersonID) Properties: $($item.splat | ConvertTo-Json -Compress)"
                    $itemSplat = $item.splat
                    $newUser = New-ADUser @itemSplat -ErrorAction Stop

                    #Add the GUID to the data object
                    ($sourceData | Where-Object UPN -eq $itemSplat.UserPrincipalName).ADCurrentUserID = $newUser.ObjectGUID

                }
                catch {
                    Write-Log -Message ($_.Exception.Message) -Level Error
                }
            }

            #Process Group Membership (enableGroupProcessingWhatIf suppresses the writes - the change lists were already logged above)
            if ($IDConfig.AD.enableGroupProcessing -eq $true -and $IDConfig.AD.enableGroupProcessingWhatIf -eq $true) {
                Write-Log -Message "AD: Group processing WhatIf is enabled - group changes were logged but NOT applied."
            }
            if ($IDConfig.AD.enableGroupProcessing -eq $true -and $IDConfig.AD.enableGroupProcessingWhatIf -ne $true) {
                #If there are users to create, refresh the group membership updates to ensure any new users are included in the group processing
                if ($ADUsersToCreate.Count -gt 0) {
                    #Refresh AD User Groups to Update List to include newly created users
                    Write-Log -Message "AD: Refreshing User Groups to Update List to include newly created users." -Level Trace

                    $ADUserGroupsToUpdate = Get-ADUserGroupsToUpdate -UserList $sourceData -CurrentADGroups $adData.Groups
                }
                #Process Group Membership Add
                foreach ($item in $ADUserGroupsToUpdate.Add) {
                    foreach ($group in $item.Groups) {
                        try {
                            Write-Log -Message "AD: Adding Group: $group to $($item.PersonID)"
                            Add-ADPrincipalGroupMembership -Identity $item.ADCurrentUserID -MemberOf $group
                        }
                        catch {
                            Write-Log -Message ($_.Exception.Message) -Level Error
                        }
                    }
                }

                #Process Group Membership Remove
                if ($IDConfig.AD.enableGroupProcessingRemove -eq $true) {
                    foreach ($item in $ADUserGroupsToUpdate.Remove) {
                        foreach ($group in $item.Groups) {
                            try {
                                Write-Log -Message "AD: Removing Group: $group from $($item.PersonID)"
                                Remove-ADGroupMember -Identity $group -Members $item.ADCurrentUserID -Confirm:$false
                            }
                            catch {
                                Write-Log -Message ($_.Exception.Message) -Level Error
                            }
                        }
                    }
                }
            }
        }
        #endregion Process AD Changes




        #region Process Google Changes
        if ($IDConfig.Google.enabled -eq $true -and $IDConfig.Debug.readOnly -eq $false) {
            #Create Org Units
            foreach ($item in $GoogleOrgUnitsForProcessing) {
                try {
                    New-IDBridgeGoogleOrgUnit -OrgUnit $item 
                }
                catch {
                    Write-Log -Message "Google: Error Creating Org Unit. Please check API permissions in Google or Detailed Error for more information" -Level Error
                    Throw
                }
            }

            #Disable Users (archive + move to trash sent as one batch; groups/licenses follow per user)
            #Archiving (not suspending) self-releases the base Education Fundamentals license;
            #paid add-on licenses are removed below. Pre-archive suspended users are grandfathered.
            $googleBatchRequests = @()
            foreach ($item in $GoogleUsersToDeactivate) {
                try {
                    Write-Log -Message ("Google: Archiving account for $($item.UPN)")
                    Write-Log -Message  ("Google: Moving account to trash: $($item.UPN)")
                    $deactivateSplat = @{
                        GoogleUserID = $item.GoogleCurrentUserID
                        OrgUnitPath  = $item.GoogleOrganizationalUnitTrash
                        Archived     = 'true'
                    }

                    #Persist the personID link on accounts matched by name - the update list only covers active users, so without this the account is re-matched every run
                    if ($item.personID -notin $item.GoogleObject.externalIds.value) {
                        $deactivateSplat['PersonID'] = $item.personID
                    }

                    $googleBatchRequests += Update-IDBridgeGoogleUser @deactivateSplat -AsBatchRequest
                }
                catch {
                    Write-Log -Message ($_.Exception.Message) -Level Error
                }
            }
            if ($googleBatchRequests.Count -gt 0) {
                Invoke-GoogleBatchRequest -Requests $googleBatchRequests | Out-Null
            }

            #Strip group memberships on deactivate (batched) and remove licenses
            $googleBatchRequests = @()
            foreach ($item in $GoogleUsersToDeactivate) {
                if ($IDConfig.Google.enableGroupProcessing -eq $true -and $IDConfig.Google.enableGroupProcessingTrash -eq $true) {
                    foreach ($group in $item.GoogleCurrentGroups) {
                        try {
                            Write-Log -Message ("Google: Removing Group: $group from $($item.personID)")
                            $googleBatchRequests += Update-GoogleGroupMembers -GroupEmail $group -PersonID $item.GoogleCurrentUserID -UpdateType "Remove" -AsBatchRequest -ContentId "$($item.personID)|$group"
                        }
                        catch {
                            Write-Log -Message ($_.Exception.Message) -Level Error
                        }
                    }
                }

                #Remove licenses (full deactivate path only, never on ForceDisable updates; on by default)
                if ($IDConfig.Google.enableLicenseRemoval -ne $false) {
                    Remove-IDBridgeGoogleUserLicense -UserEmail $item.GoogleObject.primaryEmail -Assignments $item.GoogleCurrentLicenses
                }
            }
            if ($googleBatchRequests.Count -gt 0) {
                Invoke-GoogleBatchRequest -Requests $googleBatchRequests | Out-Null
            }

            #Update, Move, Rename Users (batched; any RemoveAlias pre-step runs immediately at collect time)
            $googleBatchRequests = @()
            foreach ($item in $GoogleUsersToUpdate) {
                try {
                    Write-Log -Message "Google: Updating User: $($item.UPN) Properties: $($item.Splat | ConvertTo-Json -Compress)"
                    $itemSplat = $item.splat
                    $itemRequest = Update-IDBridgeGoogleUser @itemSplat -AsBatchRequest

                    #A failed RemoveAlias pre-step returns an ErrorRecord instead of a descriptor - skip those
                    if ($itemRequest -is [hashtable]) {
                        $googleBatchRequests += $itemRequest
                    }
                }
                catch {
                    Write-Log -Message ($_.Exception.Message) -Level Error
                }
            }
            if ($googleBatchRequests.Count -gt 0) {
                Invoke-GoogleBatchRequest -Requests $googleBatchRequests | Out-Null
            }

            #Create Users (batched; new Google IDs are matched back by primaryEmail from each batch response)
            $googleBatchRequests = @()
            foreach ($item in $GoogleUsersToCreate) {
                try {
                    Write-Log -Message "Google: Creating User: $($item.UPN) Properties: $($item.Splat | ConvertTo-Json -Compress)"
                    $itemSplat = $item.splat
                    $googleBatchRequests += New-IDBridgeGoogleUser @itemSplat -AsBatchRequest -ErrorAction Stop
                }
                catch {
                    Write-Log -Message ($_.Exception.Message) -Level Error
                }
            }
            if ($googleBatchRequests.Count -gt 0) {
                $newUserResponses = Invoke-GoogleBatchRequest -Requests $googleBatchRequests

                #Add the Google IDs to the data objects
                foreach ($newUserResponse in $newUserResponses | Where-Object { $_.StatusCode -lt 400 -and $_.Body.id }) {
                    ($sourceData | Where-Object UPN -eq $newUserResponse.Body.primaryEmail).GoogleCurrentUserID = $newUserResponse.Body.id
                }
            }

            #Process Group Membership (enableGroupProcessingWhatIf suppresses the writes - the change lists were already logged above)
            if ($IDConfig.Google.enableGroupProcessing -eq $true -and $IDConfig.Google.enableGroupProcessingWhatIf -eq $true) {
                Write-Log -Message "Google: Group processing WhatIf is enabled - group changes were logged but NOT applied."
            }
            if ($IDConfig.Google.enableGroupProcessing -eq $true -and $IDConfig.Google.enableGroupProcessingWhatIf -ne $true) {
                if ($GoogleUsersToCreate.Count -gt 0) {
                    #Refresh Google User Groups to Update List to include newly created users
                    Write-Log -Message "Google: Refreshing User Groups to Update List to include newly created users." -Level Trace
                    
                    $GoogleUserGroupsToUpdate = Get-GoogleUserGroupsToUpdate -UserList $sourceData -GoogleGroups $googleData.Groups
                }

                #Process Group Membership Add (batched; adds and removes stay in separate batches since batch execution order is not guaranteed)
                $googleBatchRequests = @()
                foreach ($item in $GoogleUserGroupsToUpdate.Add) {
                    foreach ($group in $item.Groups) {
                        try {
                            Write-Log -Message "Google: Adding Group: $group to $($item.PersonID)"
                            $googleBatchRequests += Update-GoogleGroupMembers -GroupEmail ($googleData.Groups | Where-Object {$_.name -eq $group}).email -PersonID $item.GoogleCurrentUserID -UpdateType "Add" -AsBatchRequest -ContentId "$($item.PersonID)|$group"
                        }
                        catch {
                            Write-Log -Message ($_.Exception.Message) -Level Error
                        }
                    }
                }
                if ($googleBatchRequests.Count -gt 0) {
                    Invoke-GoogleBatchRequest -Requests $googleBatchRequests | Out-Null
                }

                #Process Group Membership Remove (batched)
                if ($IDConfig.Google.enableGroupProcessingRemove -eq $true) {
                    $googleBatchRequests = @()
                    foreach ($item in $GoogleUserGroupsToUpdate.Remove) {
                        foreach ($group in $item.Groups) {
                            try {
                                Write-Log -Message "Google: Removing Group: $group from $($item.PersonID)"
                                $googleBatchRequests += Update-GoogleGroupMembers -GroupEmail $group -PersonID $item.GoogleCurrentUserID -UpdateType "Remove" -AsBatchRequest -ContentId "$($item.PersonID)|$group"
                            }
                            catch {
                                Write-Log -Message ($_.Exception.Message) -Level Error
                            }
                        }
                    }
                    if ($googleBatchRequests.Count -gt 0) {
                        Invoke-GoogleBatchRequest -Requests $googleBatchRequests | Out-Null
                    }
                }
            }
        }
        #endregion Process Google Changes




        #region Run Summary
        # Per-directory totals from the computed change lists. In ReadOnly these are PROPOSED (nothing
        # was written); otherwise APPLIED (best-effort - any per-user failures were logged inline above).
        $summaryMode = if ($IDConfig.Debug.readOnly -eq $true) { 'PROPOSED (ReadOnly)' } else { 'APPLIED' }
        Write-Log -Message "Run Summary [$summaryMode]:"

        # Group-update lists are only assigned when group processing is on; .Where filters the lone
        # $null that @() wraps when the variable is unset, so a disabled group sync reports 0 (not 1).
        if ($IDConfig.AD.enabled -eq $true) {
            Write-Log -Message ("AD: Create={0} Update={1} Rename={2} Move={3} Deactivate={4} GroupAdd={5} GroupRemove={6}" -f `
                @($ADUsersToCreate).Count, @($ADUsersToUpdate.UpdateList).Count, @($ADUsersToUpdate.RenameList).Count, `
                @($ADUsersToUpdate.MoveList).Count, @($ADUsersToDeactivate).Count, `
                @($ADUserGroupsToUpdate.Add.Groups).Where({ $null -ne $_ }).Count, @($ADUserGroupsToUpdate.Remove.Groups).Where({ $null -ne $_ }).Count)
        }

        if ($IDConfig.Google.enabled -eq $true) {
            Write-Log -Message ("Google: Create={0} Update={1} Deactivate={2} GroupAdd={3} GroupRemove={4}" -f `
                @($GoogleUsersToCreate).Count, @($GoogleUsersToUpdate).Count, @($GoogleUsersToDeactivate).Count, `
                @($GoogleUserGroupsToUpdate.Add.Groups).Where({ $null -ne $_ }).Count, @($GoogleUserGroupsToUpdate.Remove.Groups).Where({ $null -ne $_ }).Count)
        }

        if ($thresholdResults) {
            foreach ($result in $thresholdResults) {
                $pct = if ($result.Skipped) { 'skipped (no managed population)' } else { "$($result.Percent)% of $($result.PopulationCount) (limit $($IDConfig.ChangeThreshold.Percentage)%)" }
                Write-Log -Message "$($result.Directory) change volume: $pct"
            }
        }
        #endregion Run Summary




        #region Export User Staff List
        $sourceData | Where-Object {$_.PersonTypeID -ne "1"} | Export-Csv -Path "$($IDConfig.Paths.ExportsRoot)\UserList-Staff.csv" -NoTypeInformation -Force
        #endregion Export User Staff List

    } catch {
        $runError = $_

        # Write-Log needs an initialized config; if the failure happened before/at config load it would
        # throw again here, so fall back to Write-Error in that case.
        try {
            Write-Log -Message "Invoke-IDBridge Failed" -Level Error
            Write-Log -Message ($_ | Out-String) -Level Error
        }
        catch {
            Write-Error "Invoke-IDBridge Failed: $($_.Exception.Message)"
        }
    } finally {
        # $IDConfig is only set once the config loads successfully; guard so a pre-config failure
        # doesn't throw out of the finally block.
        if ($IDConfig) {
            #region Telemetry
            # Usage telemetry (see PRIVACY.md) - fully self-contained: any failure here is swallowed
            # and logged locally so it can never mask the run's real outcome or throw out of finally.
            try {
                # Counts are APPLIED work, so ReadOnly runs report zeros (the readOnly flag in the
                # payload tells the story). .Where filters the lone $null that @() wraps when a list
                # was never assigned (failed/partial runs), matching the Run Summary counting above.
                $telemetryCounts = @{ Create = 0; Update = 0; Deactivate = 0 }
                if ($IDConfig.Debug.readOnly -eq $false) {
                    $telemetryCounts.Create     = @($ADUsersToCreate).Where({ $null -ne $_ }).Count + @($GoogleUsersToCreate).Where({ $null -ne $_ }).Count
                    $telemetryCounts.Update     = @($ADUsersToUpdate.UpdateList).Where({ $null -ne $_ }).Count + @($ADUsersToUpdate.RenameList).Where({ $null -ne $_ }).Count + @($ADUsersToUpdate.MoveList).Where({ $null -ne $_ }).Count + @($GoogleUsersToUpdate).Where({ $null -ne $_ }).Count
                    $telemetryCounts.Deactivate = @($ADUsersToDeactivate).Where({ $null -ne $_ }).Count + @($GoogleUsersToDeactivate).Where({ $null -ne $_ }).Count
                }

                $telemetrySplat = @{
                    Success         = (-not $runError)
                    DurationSeconds = [int]((Get-Date) - $runStart).TotalSeconds
                    ManagedCount    = @($sourceData).Where({ $null -ne $_ }).Count
                    CreateCount     = $telemetryCounts.Create
                    UpdateCount     = $telemetryCounts.Update
                    DeactivateCount = $telemetryCounts.Deactivate
                }
                if ($runError) { $telemetrySplat.RunError = $runError }

                Send-IDBridgeTelemetry @telemetrySplat
            }
            catch {
                Write-Log -Message "Telemetry: Skipped ($($_.Exception.GetType().Name))." -Level Trace
            }
            #endregion Telemetry

            Write-Log -Message "######## End of Script Run: $((Get-Date -Format "yyyy-MM-dd-HH.mm.ss")) ########"

            if ($IDConfig.Logging.GoogleSheetLoggingEnabled) {
                Push-LogsToSheet -spreadsheetId $IDConfig.Logging.SheetID -sheetName 'Logs'
            }
        }
    }
}