GCSContact.psm1

<#
.SYNOPSIS
    GraphContact — flexible Microsoft Graph contact management module.

.DESCRIPTION
    A PowerShell module that mirrors the SetProp / CreateContact pattern from
    Glen Scales' EWS contact creation script, adapted for the Microsoft Graph API.

    Exported cmdlets
    ────────────────
      New-GCSContactProperty Add a typed property to a contact property bag
      New-GCSContact Create a contact from a property bag
      Set-GCSContact Update an existing contact from a property bag
      Get-GCSContactProperty Retrieve a contact with extended properties
      New-GCSExtendedPropertyId Build a Graph singleValueExtendedProperty id string
      New-GCSExtendedPropertyLid Build a Graph id from a MAPI LID (hex)
      New-GCSExtendedPropertyTag Build a Graph id from a raw MAPI proptag
      New-GCSContactPropertyBag Create an empty property bag hashtable


#>


# ── Module-level constants ────────────────────────────────────────────────────

# PSETID_Address — same GUID used in the EWS script as $AddressGuid
$script:PsetidAddress = '00062004-0000-0000-C000-000000000046'

# ── Lookup tables ─────────────────────────────────────────────────────────────

$script:NormalFieldMap = @{
    GivenName          = 'givenName'
    Surname            = 'surname'
    DisplayName        = 'displayName'
    FileAs             = 'fileAs'
    Subject            = 'Subject'     
    JobTitle           = 'jobTitle'
    CompanyName        = 'companyName'
    Department         = 'department'
    OfficeLocation     = 'officeLocation'
    BusinessHomePage   = 'businessHomePage'
    NickName           = 'nickName'
    Initials           = 'initials'
    MiddleName         = 'middleName'
    Generation         = 'generation'
    SpouseName         = 'spouseName'
    Manager            = 'manager'
    AssistantName      = 'assistantName'
    Profession         = 'profession'
    IMAddress          = 'imAddresses'
    BirthDay           = 'birthday'
    WeddingAnniversary = 'anniversary'
    Notes              = 'personalNotes'
}

$script:AddressKeyMap = @{
    Home     = 'homeAddress'
    Business = 'businessAddress'
    Other    = 'otherAddress'
}

$script:PhoneMap = @{
    # First-class Graph fields
    MobilePhone      = @{ Field = 'mobilePhone';    Kind = 'direct' }
    BusinessPhone    = @{ Field = 'businessPhones'; Kind = 'array'  }
    BusinessPhone2   = @{ Field = 'businessPhones'; Kind = 'array'  }
    HomePhone        = @{ Field = 'homePhones';     Kind = 'array'  }
    HomePhone2       = @{ Field = 'homePhones';     Kind = 'array'  }
    # No first-class Graph field — stored as MAPI proptag extended properties
    AssistantPhone   = @{ Field = 'String 0x3A2E';  Kind = 'extended' }
    BusinessFax      = @{ Field = 'String 0x3A24';  Kind = 'extended' }
    HomeFax          = @{ Field = 'String 0x3A25';  Kind = 'extended' }
    OtherFax         = @{ Field = 'String 0x3A23';  Kind = 'extended' }
    Pager            = @{ Field = 'String 0x3A21';  Kind = 'extended' }
    PrimaryPhone     = @{ Field = 'String 0x3A1A';  Kind = 'extended' }
    RadioPhone       = @{ Field = 'String 0x3A1D';  Kind = 'extended' }
    CarPhone         = @{ Field = 'String 0x3A1E';  Kind = 'extended' }
    Isdn             = @{ Field = 'String 0x3A2D';  Kind = 'extended' }
    OtherTelephone   = @{ Field = 'String 0x3A1F';  Kind = 'extended' }
    Telex            = @{ Field = 'String 0x3A2C';  Kind = 'extended' }
    Callback         = @{ Field = 'String 0x3A02';  Kind = 'extended' }
    CompanyMainPhone = @{ Field = 'String 0x3A57';  Kind = 'extended' }
    TtyTddPhone      = @{ Field = 'String 0x3A4B';  Kind = 'extended' }
}

# ─────────────────────────────────────────────────────────────────────────────
# Extended property id helpers
# ─────────────────────────────────────────────────────────────────────────────

function New-GCSExtendedPropertyId {
    <#
    .SYNOPSIS
        Builds a Graph singleValueExtendedProperty id for a MAPI named property
        in the PSETID_Address property set.

    .PARAMETER DataType
        MAPI data type string: String, Integer, Double, Boolean, DateTime, etc.

    .PARAMETER Name
        Canonical property name, e.g. dispidEmail1AddrType

    .PARAMETER Guid
        Optional. Property set GUID. Defaults to PSETID_Address
        ({00062004-0000-0000-C000-000000000046}).

    .EXAMPLE
        New-GCSExtendedPropertyId -DataType String -Name dispidEmail1AddrType
        # Returns: "String {00062004-0000-0000-C000-000000000046} Name dispidEmail1AddrType"

    .EXAMPLE
        ExtPropId String dispidEmail1AddrType # alias form
    #>

    [CmdletBinding()]
    [OutputType([string])]
    param(
        [Parameter(Mandatory, Position = 0)]
        [string]$DataType,

        [Parameter(Mandatory, Position = 1)]
        [string]$Name,

        [Parameter(Position = 2)]
        [string]$Guid = $script:PsetidAddress
    )
    "$DataType {$Guid} Name $Name"
}

function New-GCSExtendedPropertyLid {
    <#
    .SYNOPSIS
        Builds a Graph singleValueExtendedProperty id from a MAPI LID (numeric
        property identifier) in the PSETID_Address property set.

    .PARAMETER DataType
        MAPI data type string.

    .PARAMETER Lid
        MAPI Long ID (LID) as an integer. e.g. 0x8080 for PidLidEmail1DisplayName.

    .PARAMETER Guid
        Optional. Defaults to PSETID_Address.

    .EXAMPLE
        New-GCSExtendedPropertyLid -DataType String -Lid 0x8080
        # Returns: "String {00062004-0000-0000-C000-000000000046} Id 0x8080"
    #>

    [CmdletBinding()]
    [OutputType([string])]
    param(
        [Parameter(Mandatory, Position = 0)]
        [string]$DataType,

        [Parameter(Mandatory, Position = 1)]
        [int]$Lid,

        [Parameter(Position = 2)]
        [string]$Guid = $script:PsetidAddress
    )
    "$DataType {$Guid} Id 0x{0:X4}" -f $Lid
}

function New-GCSExtendedPropertyTag {
    <#
    .SYNOPSIS
        Builds a Graph singleValueExtendedProperty id from a raw MAPI property
        tag (for properties that are not in a named-property set).

    .PARAMETER DataType
        MAPI data type string.

    .PARAMETER Tag
        MAPI property tag as an integer. e.g. 0x3A2C for PR_TELEX_NUMBER.

    .EXAMPLE
        New-GCSExtendedPropertyTag -DataType String -Tag 0x3A2C
        # Returns: "String 0x3A2C"
    #>

    [CmdletBinding()]
    [OutputType([string])]
    param(
        [Parameter(Mandatory, Position = 0)]
        [string]$DataType,

        [Parameter(Mandatory, Position = 1)]
        [int]$Tag
    )
    "$DataType 0x{0:X4}" -f $Tag
}

# ─────────────────────────────────────────────────────────────────────────────
# Property bag
# ─────────────────────────────────────────────────────────────────────────────

function New-GCSContactPropertyBag {
    <#
    .SYNOPSIS
        Creates a new empty contact property bag hashtable.

    .DESCRIPTION
        Returns an ordered hashtable ready to be populated with
        New-GCSContactProperty calls and passed to New-GCSContact or
        Set-GCSContact.

    .EXAMPLE
        $bag = New-GCSContactPropertyBag
        New-GCSContactProperty -Bag $bag -Type Normal -Name GivenName -Value "John"
        New-GCSContact -UserId user@contoso.com -PropertyBag $bag
    #>

    [CmdletBinding()]
    [OutputType([System.Collections.IDictionary])]
    param()
    # Return a plain Hashtable — [ordered]@{} returns an OrderedDictionary
    # which does not bind to [System.Collections.Hashtable] parameters in PowerShell,
    # causing $bag to appear empty when passed to New-GCSContactProperty.
    $h = @{}
    $h
}

function New-GCSContactProperty {
    <#
    .SYNOPSIS
        Adds a typed property entry to a contact property bag.

    .DESCRIPTION
        Equivalent to SetProp in the original EWS script. Call this once per
        property, then pass the accumulated bag to New-GCSContact or
        Set-GCSContact.

    .PARAMETER Bag
        The property bag hashtable created by New-GCSContactPropertyBag.
        When omitted, the cmdlet writes to $script:ContactProps which is
        created automatically if it does not exist.

    .PARAMETER Type
        Property category:
          Normal – top-level Graph contact field (GivenName, Surname, etc.)
          Email – email address slot (Email1.Address, Email2.Name, etc.)
          Phone – phone number (MobilePhone, BusinessPhone, etc.)
          Address – physical address (Home.City, Business.Street, etc.)
          Extended – singleValueExtendedProperty (use ExtPropId/Lid/Tag for Name)

    .PARAMETER Name
        Property name within its type category, or an extended property id string.

    .PARAMETER Value
        Property value.

    .EXAMPLE
        $bag = New-GCSContactPropertyBag
        New-GCSContactProperty $bag Normal GivenName "John"
        New-GCSContactProperty $bag Normal Surname "Doe"
        New-GCSContactProperty $bag Email Email1.Address "john@example.com"
        New-GCSContactProperty $bag Phone MobilePhone "0400000000"
        New-GCSContactProperty $bag Address Home.City "Sydney"

    .EXAMPLE
        # Extended property (MAPI named property)
        New-GCSContactProperty $bag Extended (ExtPropId String dispidEmail1AddrType) "SMTP"
    #>

    [CmdletBinding()]
    param(
        [Parameter(Position = 0)]
        [System.Collections.IDictionary]$Bag,

        [Parameter(Mandatory, Position = 1)]
        [ValidateSet('Normal','Email','Phone','Address','Extended')]
        [string]$Type,

        [Parameter(Mandatory, Position = 2)]
        [object]$Name,

        [Parameter(Mandatory, Position = 3)]
        [object]$Value
    )

    if (-not $Bag) {
        if (-not (Get-Variable -Name ContactProps -Scope Script -ErrorAction SilentlyContinue)) {
            $script:ContactProps = [ordered]@{}
        }
        $Bag = $script:ContactProps
    }

    $Bag[$Name] = [PSCustomObject]@{
        PropType = $Type
        Name     = $Name
        Value    = $Value
    }
}

# ─────────────────────────────────────────────────────────────────────────────
# Body builder (internal)
# ─────────────────────────────────────────────────────────────────────────────

function ConvertTo-GCSContactBody {
    <#
    .SYNOPSIS
        Converts a property bag into the JSON body hashtable for a Graph API call.
    #>

    [CmdletBinding()]
    param(
        [Parameter(Mandatory)]
        [System.Collections.IDictionary]$PropertyBag
    )

    $body           = [ordered]@{}
    $emailSlots     = [ordered]@{}
    $businessPhones = [System.Collections.Generic.List[string]]::new()
    $homePhones     = [System.Collections.Generic.List[string]]::new()
    $addresses      = @{}
    $extProps       = [System.Collections.Generic.List[hashtable]]::new()

    foreach ($entry in $PropertyBag.Values) {

        switch ($entry.PropType) {

            'Normal' {
                $graphField = $script:NormalFieldMap[$entry.Name]
                if (-not $graphField) {
                    Write-Warning "GCSContact: Normal property '$($entry.Name)' has no Graph mapping — skipping"
                    continue
                }
                $val = $entry.Value
                if ($entry.Name -in @('BirthDay','WeddingAnniversary') -and $val -is [datetime]) {
                    $val = $val.ToUniversalTime().ToString("yyyy-MM-ddTHH:mm:ssZ")
                }
                $body[$graphField] = $val
            }

            'Email' {
                # Accepts "Email1.Address" / "Email1.Name"
                # also "EmailAddress1.Address" (EWS form) — strip prefix
                $cleanName = $entry.Name -replace '^EmailAddress','Email'
                $parts  = $cleanName.Split('.')
                $slot   = $parts[0] -replace '^Email',''   # "1","2","3"
                $field  = $parts[1]                         # "Address" or "Name"

                if (-not $emailSlots.Contains($slot)) {
                    $emailSlots[$slot] = @{ address = $null; name = $null }
                }
                switch ($field) {
                    'Address' { $emailSlots[$slot].address = $entry.Value }
                    'Name'    { $emailSlots[$slot].name    = $entry.Value }
                }
            }

            'Phone' {
                $mapEntry = $script:PhoneMap[$entry.Name]
                if (-not $mapEntry) {
                    Write-Warning "GCSContact: Phone key '$($entry.Name)' unknown — skipping"
                    continue
                }
                switch ($mapEntry.Kind) {
                    'direct'   { $body[$mapEntry.Field] = $entry.Value }
                    'array'    {
                        if ($mapEntry.Field -eq 'businessPhones') { $businessPhones.Add($entry.Value) }
                        else                                       { $homePhones.Add($entry.Value)     }
                    }
                    'extended' { $extProps.Add(@{ id = $mapEntry.Field; value = $entry.Value }) }
                }
            }

            'Address' {
                $parts      = $entry.Name.Split('.')
                $addressKey = $script:AddressKeyMap[$parts[0]]
                if (-not $addressKey) {
                    Write-Warning "GCSContact: Address key '$($parts[0])' unknown — skipping"
                    continue
                }
                $subField = switch ($parts[1]) {
                    'CountryOrRegion' { 'countryOrRegion' }
                    'PostalCode'      { 'postalCode'      }
                    'Street'          { 'street'          }
                    'City'            { 'city'            }
                    'State'           { 'state'           }
                    default           { $parts[1].Substring(0,1).ToLower() + $parts[1].Substring(1) }
                }
                if (-not $addresses.ContainsKey($addressKey)) { $addresses[$addressKey] = @{} }
                $addresses[$addressKey][$subField] = $entry.Value
            }

            'Extended' {
                $extProps.Add(@{ id = $entry.Name.ToString(); value = $entry.Value.ToString() })
            }
        }
    }

    # Collapse email slots
    if ($emailSlots.Count -gt 0) {
        $emailArray = @()
        foreach ($slot in ($emailSlots.Keys | Sort-Object)) {
            $s        = $emailSlots[$slot]
            $dispName = if ($s.name) { $s.name } else { $s.address }
            $emailObj = @{}
            if ($s.address) { $emailObj.address = $s.address }
            if ($s.name)    { $emailObj.name    = $s.name    }
            $emailArray += $emailObj

            # Inject MAPI Email<N> extended properties so Outlook renders correctly
            $n = $slot
            $extProps.Add(@{ id = (New-GCSExtendedPropertyId String "dispidEmail${n}AddrType");            value = 'SMTP'        })
            $extProps.Add(@{ id = (New-GCSExtendedPropertyId String "dispidEmail${n}DisplayName");         value = $dispName     })
            $extProps.Add(@{ id = (New-GCSExtendedPropertyId String "dispidEmail${n}EmailAddress");        value = $s.address    })
            $extProps.Add(@{ id = (New-GCSExtendedPropertyId String "dispidEmail${n}OriginalDisplayName"); value = $s.address    })
        }
        $body.emailAddresses = $emailArray
    }

    if ($businessPhones.Count -gt 0) { $body.businessPhones = $businessPhones.ToArray() }
    if ($homePhones.Count     -gt 0) { $body.homePhones     = $homePhones.ToArray()     }
    foreach ($key in $addresses.Keys) { $body[$key] = $addresses[$key] }
    if ($extProps.Count -gt 0) { $body.singleValueExtendedProperties = $extProps.ToArray() }

    $body
}

# ─────────────────────────────────────────────────────────────────────────────
# Public cmdlets
# ─────────────────────────────────────────────────────────────────────────────

function New-GCSContact {
    <#
    .SYNOPSIS
        Creates a new contact in a user's mailbox from a property bag.

    .DESCRIPTION
        Converts the property bag built with New-GCSContactProperty into a
        single POST request to the Microsoft Graph contacts endpoint, including
        all standard fields and MAPI extended properties in one call.

    .PARAMETER UserId
        UPN or object ID of the target mailbox (e.g. user@contoso.com).

    .PARAMETER PropertyBag
        Hashtable built with New-GCSContactPropertyBag and populated with
        New-GCSContactProperty. When omitted, uses $script:ContactProps.

    .PARAMETER FolderId
        Optional. Contact folder ID to create the contact in. When omitted,
        the contact is created in the default Contacts folder.

    .OUTPUTS
        Microsoft.Graph.PowerShell.Models.MicrosoftGraphContact

    .EXAMPLE
        $bag = New-GCSContactPropertyBag
        New-GCSContactProperty $bag Normal GivenName "John"
        New-GCSContactProperty $bag Normal Surname "Doe"
        New-GCSContactProperty $bag Email Email1.Address "john@example.com"
        New-GCSContact -UserId user@contoso.com -PropertyBag $bag
    #>

    [CmdletBinding(SupportsShouldProcess)]
    param(
        [Parameter(Mandatory, Position = 0)]
        [string]$UserId,

        [Parameter(Position = 1)]
        [System.Collections.IDictionary]$PropertyBag,

        [Parameter()]
        [string]$FolderId
    )

    if (-not $PropertyBag) {
        if (-not (Get-Variable -Name ContactProps -Scope Script -ErrorAction SilentlyContinue)) {
            throw "No PropertyBag supplied and no script-level ContactProps found. " +
                  "Call New-GCSContactPropertyBag first."
        }
        $PropertyBag = $script:ContactProps
    }

    $body = ConvertTo-GCSContactBody -PropertyBag $PropertyBag

    $displayName = if ($body.Contains('displayName')) { $body.displayName }
                   elseif ($body.Contains('fileAs'))   { $body.fileAs }
                   else                               { 'New Contact' }

    if ($PSCmdlet.ShouldProcess($displayName, 'Create Graph contact')) {
        try {
            $params = @{
                UserId        = $UserId
                BodyParameter = $body
                ErrorAction   = 'Stop'
            }
            if ($FolderId) {
                $contact = New-MgUserContactFolderContact @params -ContactFolderId $FolderId
            } else {
                $contact = New-MgUserContact @params
            }
            Write-Verbose "Contact created: $($contact.DisplayName) (id: $($contact.Id))"
            $contact
        }
        catch {
            Write-Error "Failed to create contact '$displayName': $_"
        }
    }
}

function Set-GCSContact {
    <#
    .SYNOPSIS
        Updates an existing contact from a property bag.

    .DESCRIPTION
        Issues a PATCH request against an existing contact, applying only the
        properties present in the property bag.

    .PARAMETER UserId
        UPN or object ID of the target mailbox.

    .PARAMETER ContactId
        ID of the contact to update (from New-GCSContact or Get-MgUserContact).

    .PARAMETER PropertyBag
        Hashtable of properties to update. Unspecified properties are unchanged.

    .EXAMPLE
        $bag = New-GCSContactPropertyBag
        New-GCSContactProperty $bag Extended (ExtPropId String dispidEmail1DisplayName) "dD"
        Set-GCSContact -UserId user@contoso.com -ContactId $contact.Id -PropertyBag $bag
    #>

    [CmdletBinding(SupportsShouldProcess)]
    param(
        [Parameter(Mandatory, Position = 0)]
        [string]$UserId,

        [Parameter(Mandatory, Position = 1)]
        [string]$ContactId,

        [Parameter(Mandatory, Position = 2)]
        [System.Collections.IDictionary]$PropertyBag
    )

    $body = ConvertTo-GCSContactBody -PropertyBag $PropertyBag

    if ($PSCmdlet.ShouldProcess($ContactId, 'Update Graph contact')) {
        try {
            Update-MgUserContact `
                -UserId    $UserId `
                -ContactId $ContactId `
                -BodyParameter $body `
                -ErrorAction Stop
            Write-Verbose "Contact $ContactId updated"
        }
        catch {
            Write-Error "Failed to update contact '$ContactId': $_"
        }
    }
}

function Get-GCSContactProperty {
    <#
    .SYNOPSIS
        Retrieves a contact and expands one or more extended properties.

    .PARAMETER UserId
        UPN or object ID of the mailbox.

    .PARAMETER ContactId
        Contact ID to retrieve.

    .PARAMETER ExtendedPropertyIds
        One or more extended property id strings (from ExtPropId / ExtPropLid /
        ExtPropTag) to expand on the returned contact object.

    .EXAMPLE
        Get-GCSContactProperty `
            -UserId user@contoso.com `
            -ContactId $contact.Id `
            -ExtendedPropertyIds (ExtPropId String dispidEmail1DisplayName),
                                 (ExtPropId String dispidEmail1EmailAddress)
    #>

    [CmdletBinding()]
    param(
        [Parameter(Mandatory, Position = 0)]
        [string]$UserId,

        [Parameter(Mandatory, Position = 1)]
        [string]$ContactId,

        [Parameter()]
        [string[]]$ExtendedPropertyIds
    )

    $expandParts = @()
    foreach ($id in $ExtendedPropertyIds) {
        $expandParts += "singleValueExtendedProperties(`$filter=id eq '$id')"
    }

    $params = @{
        UserId    = $UserId
        ContactId = $ContactId
    }
    if ($expandParts.Count -gt 0) {
        $params.ExpandProperty = $expandParts -join ','
    }

    Get-MgUserContact @params
}

# ─────────────────────────────────────────────────────────────────────────────
# Batch contact creation
# ─────────────────────────────────────────────────────────────────────────────

function New-GCSContactBatch {
    <#
    .SYNOPSIS
        Creates multiple contacts in batches of up to 20 using the Graph
        JSON batch endpoint ($batch).

    .DESCRIPTION
        Takes a collection of property bags (each built with
        New-GCSContactPropertyBag / New-GCSContactProperty) and submits them
        to the Microsoft Graph \$batch endpoint in groups of up to 20 requests.

        Using \$batch reduces round-trips from one HTTP call per contact to one
        call per 20 contacts, which is significantly faster for bulk imports.

        Authentication is handled by the existing Invoke-MgGraphRequest session
        (Connect-MgGraph) — no manual token management is required.

        Throttling (HTTP 429) is handled automatically: when any response in a
        batch returns 429 the function honours the Retry-After header and
        re-submits the failed requests.

    .PARAMETER UserId
        UPN or object ID of the target mailbox (e.g. user@contoso.com).

    .PARAMETER PropertyBags
        An array or list of property bag hashtables, each built with
        New-GCSContactPropertyBag and populated with New-GCSContactProperty.

    .PARAMETER FolderId
        Optional. Contact folder ID to create contacts in. When omitted,
        contacts are created in the default Contacts folder.

    .PARAMETER BatchSize
        Number of requests per batch call. Defaults to 20, which is the
        Graph \$batch maximum. Reduce for troubleshooting.

    .OUTPUTS
        PSCustomObject with properties:
          SuccessCount – number of contacts successfully created
          ErrorCount – number of failed requests
          ThrottleCount – number of 429 throttle responses handled
          TimeToRun – total elapsed seconds

    .EXAMPLE
        $bags = 1..50 | ForEach-Object {
            $b = New-GCSContactPropertyBag
            New-GCSContactProperty $b Normal GivenName "User$_"
            New-GCSContactProperty $b Normal Surname "Test"
            New-GCSContactProperty $b Email Email1.Address "user$_@contoso.com"
            $b
        }
        New-GCSContactBatch -UserId admin@contoso.com -PropertyBags $bags

    .EXAMPLE
        # Create into a specific contact folder
        New-GCSContactBatch -UserId admin@contoso.com -PropertyBags $bags -FolderId $folderId
    #>

    [CmdletBinding(SupportsShouldProcess)]
    [OutputType([PSCustomObject])]
    param(
        [Parameter(Mandatory, Position = 0)]
        [string]$UserId,

        [Parameter(Mandatory, Position = 1)]
        [System.Collections.IList]$PropertyBags,

        [Parameter()]
        [string]$FolderId,

        [Parameter()]
        [ValidateRange(1, 20)]
        [int]$BatchSize = 20
    )

    $report = [PSCustomObject]@{
        SuccessCount  = 0
        ErrorCount    = 0
        ThrottleCount = 0
        TimeToRun     = 0
    }
    $startTime = Get-Date

    # Build the relative URL for each contact POST
    $contactUrl = if ($FolderId) {
        "/users/$UserId/contactFolders/$FolderId/contacts"
    } else {
        "/users/$UserId/contacts"
    }

    # Convert every property bag to a Graph body hashtable up front
    $bodies = $PropertyBags | ForEach-Object {
        ConvertTo-GCSContactBody -PropertyBag $_
    }

    # ── Slice into batches of $BatchSize ─────────────────────────────────────
    $total     = $bodies.Count
    $processed = 0

    while ($processed -lt $total) {

        $slice = $bodies[$processed..([Math]::Min($processed + $BatchSize - 1, $total - 1))]
        $processed += $slice.Count

        if ($PSCmdlet.ShouldProcess("$UserId ($($slice.Count) contacts)", 'Batch create contacts')) {
            Invoke-GCSBatchSlice `
                -UserId     $UserId `
                -Slice      $slice `
                -ContactUrl $contactUrl `
                -Report     $report
        }
    }

    $report.TimeToRun = [Math]::Round(
        (New-TimeSpan -Start $startTime -End (Get-Date)).TotalSeconds, 2)

    Write-Verbose ("Batch complete — Success: $($report.SuccessCount) " +
                   "Errors: $($report.ErrorCount) " +
                   "Throttles: $($report.ThrottleCount) " +
                   "Elapsed: $($report.TimeToRun)s")
    $report
}

# ── Internal: submit one batch slice, handling 429 retry ─────────────────────

function Invoke-GCSBatchSlice {
    <#
    .SYNOPSIS
        Submits a single slice of up to 20 contact POST requests via \$batch
        and processes the responses. Retries throttled requests automatically.
    #>

    [CmdletBinding()]
    param(
        [string]                          $UserId,
        [object[]]                        $Slice,
        [string]                          $ContactUrl,
        [PSCustomObject]                  $Report
    )

    # Build the \$batch request body
    $requests = for ($i = 0; $i -lt $Slice.Count; $i++) {
        @{
            id      = ($i + 1).ToString()
            method  = 'POST'
            url     = $ContactUrl
            headers = @{ 'Content-Type' = 'application/json' }
            body    = $Slice[$i]
        }
    }

    $batchBody = @{ requests = $requests }

    # Invoke-MgGraphRequest uses the SDK's active session — no manual token needed
    try {
        $response = Invoke-MgGraphRequest `
            -Method      POST `
            -Uri         'https://graph.microsoft.com/v1.0/$batch' `
            -Body        ($batchBody | ConvertTo-Json -Depth 20 -Compress) `
            -ContentType 'application/json' `
            -ErrorAction Stop
    }
    catch {
        Write-Error "Batch request failed: $_"
        $Report.ErrorCount += $Slice.Count
        return
    }

    if (-not $response.responses) {
        Write-Error 'Batch returned no responses'
        $Report.ErrorCount += $Slice.Count
        return
    }

    # Track which items need a retry due to throttling
    $retryBodies    = [System.Collections.Generic.List[object]]::new()
    $retryAfterSecs = 0

    foreach ($r in $response.responses) {

        $status = [int]$r.status

        switch ($status) {

            201 {
                # Created successfully
                $Report.SuccessCount++
                $displayName = if ($r.body.displayName) { $r.body.displayName }
                               else                      { "(id $($r.id))" }
                Write-Verbose "Contact created: $displayName"
            }

            429 {
                # Throttled — queue for retry
                $Report.ThrottleCount++
                $retryAfterSecs = [Math]::Max(
                    $retryAfterSecs,
                    [int]($r.headers.'Retry-After' ?? 10))

                # The id is 1-based and matches the slice index
                $originalIndex = [int]$r.id - 1
                if ($originalIndex -ge 0 -and $originalIndex -lt $Slice.Count) {
                    $retryBodies.Add($Slice[$originalIndex])
                }
                Write-Warning "Request $($r.id) throttled (429) — will retry after ${retryAfterSecs}s"
            }

            default {
                $Report.ErrorCount++
                $errorMsg = if ($r.body.error.message) { $r.body.error.message }
                            else                        { "HTTP $status" }
                Write-Warning "Request $($r.id) failed: $errorMsg"
            }
        }
    }

    # ── Throttle retry ────────────────────────────────────────────────────────
    if ($retryBodies.Count -gt 0) {
        Write-Verbose "Sleeping ${retryAfterSecs}s before retrying $($retryBodies.Count) throttled request(s)"
        Start-Sleep -Seconds $retryAfterSecs

        # Recursive call — retried items are a new slice so ids reset to 1-based
        Invoke-GCSBatchSlice `
            -UserId     $UserId `
            -Slice      $retryBodies.ToArray() `
            -ContactUrl $ContactUrl `
            -Report     $Report
    }
}


Export-ModuleMember -Function @(
    'New-GCSContactProperty',
    'New-GCSContactPropertyBag',
    'New-GCSContact',
    'New-GCSContactBatch',
    'Set-GCSContact',
    'Get-GCSContactProperty',
    'New-GCSExtendedPropertyId',
    'New-GCSExtendedPropertyLid',
    'New-GCSExtendedPropertyTag'
) -Alias @(
    'SetProp',
    'ExtPropId',
    'ExtPropLid',
    'ExtPropTag'
)