modules/ISC.Microsoft365AccessProfiles.psm1

#Requires -Version 5.1
Set-StrictMode -Version Latest

function Test-IsEntraIscSource {
    param([Parameter(Mandatory)]$Source)

    $type = [string]($Source.type)
    $connector = ''
    if ($Source.PSObject.Properties['connector'] -and $Source.connector) {
        $connector = [string]$Source.connector
    }
    elseif ($Source.PSObject.Properties['connectorName'] -and $Source.connectorName) {
        $connector = [string]$Source.connectorName
    }

    $name = [string]$Source.name
    $blob = "$type|$connector|$name".ToLowerInvariant()
    return $blob -match 'entra|azure\s*ad|azuread|microsoft.?entra|ms.?graph|azure active directory'
}

function Get-ServicePlanEntitlementsForSource {
    param(
        [Parameter(Mandatory)][string]$SourceId,
        [scriptblock]$EntitlementFetcher
    )

    $filters = "source.id eq `"$SourceId`" and type eq `"servicePlan`""
    $items = if ($EntitlementFetcher) {
        @(& $EntitlementFetcher $filters)
    }
    else {
        @(
            Invoke-SailPointSdkPagedList -Fetcher {
                param($page)
                @(Get-EntitlementsV1 -Filters $filters -Offset $page.Offset -Limit $page.Limit -ErrorAction Stop)
            }
        )
    }

    # Flatten accidental nested arrays from SDK/`return ,` callers.
    return [object[]]@(Expand-EntitlementList -Entitlements $items)
}

function Find-EntraSourcesWithServicePlans {
    param(
        [scriptblock]$SourceFetcher,
        [scriptblock]$EntitlementFetcher
    )

    $sources = if ($SourceFetcher) {
        @(& $SourceFetcher)
    }
    else {
        @(
            Invoke-SailPointSdkPagedList -Fetcher {
                param($page)
                @(Get-SourcesV1 -Offset $page.Offset -Limit $page.Limit -ErrorAction Stop)
            }
        )
    }

    $matchedSources = [System.Collections.Generic.List[object]]::new()
    foreach ($source in $sources) {
        if (-not (Test-IsEntraIscSource -Source $source)) { continue }
        $plans = @(Get-ServicePlanEntitlementsForSource -SourceId ([string]$source.id) -EntitlementFetcher $EntitlementFetcher)
        if ($plans.Count -eq 0) { continue }
        $matchedSources.Add([pscustomobject]@{
                Id           = [string]$source.id
                Name         = [string]$source.name
                Type         = [string]$source.type
                PlanCount    = $plans.Count
                Source       = $source
                ServicePlans = $plans
            })
    }
    # Always return a flat object array. Avoid `,$array` which nests and breaks single-hit callers.
    return [object[]]@($matchedSources.ToArray())
}

function Get-ServicePlanFriendlyNameQuality {
    param(
        [AllowNull()][string]$PlanName,
        [AllowNull()][string]$Friendly
    )

    if ([string]::IsNullOrWhiteSpace($Friendly)) { return -1 }
    $friendly = $Friendly.Trim()
    $plan = if ($PlanName) { $PlanName.Trim() } else { '' }

    # Microsoft CSV often repeats the raw plan code as the "friendly" value.
    if ($plan -and $friendly.Equals($plan, [System.StringComparison]::OrdinalIgnoreCase)) { return 0 }
    # ALL-CAPS labels (even with spaces/punctuation) are weak CSV leftovers.
    if ($friendly -ceq $friendly.ToUpperInvariant() -and $friendly -match '[A-Z]') {
        return 1
    }

    $score = 10
    if ($friendly -match '\s') { $score += 20 }
    # Prefer current Entra branding over legacy Azure AD labels when both exist.
    if ($friendly -match '(?i)Microsoft Entra') { $score += 15 }
    elseif ($friendly -match '(?i)Azure Active Directory') { $score += 5 }
    # Prefer title-style product names over shouty CSV fragments.
    if ($friendly -cmatch '[a-z]') { $score += 10 }
    $score += [Math]::Min($friendly.Length, 40)
    return $score
}

function Set-ServicePlanFriendlyMapEntry {
    param(
        [Parameter(Mandatory)][hashtable]$Map,
        [Parameter(Mandatory)][string]$Key,
        [AllowNull()][string]$PlanName,
        [Parameter(Mandatory)][string]$Friendly,
        [switch]$Force
    )

    $quality = Get-ServicePlanFriendlyNameQuality -PlanName $PlanName -Friendly $Friendly
    if ($quality -lt 0) { return }
    if (-not $Force -and $Map.ContainsKey($Key)) {
        $existingQuality = Get-ServicePlanFriendlyNameQuality -PlanName $PlanName -Friendly ([string]$Map[$Key])
        if ($quality -le $existingQuality) { return }
    }
    $Map[$Key] = $Friendly.Trim()
}

function Get-ServicePlanFriendlyNameOverrides {
    # Microsoft's CSV leaves a few plan codes without a human label (or only the code).
    return [ordered]@{
        'YAMMER_ENTERPRISE'   = 'Yammer Enterprise'
        'YAMMER_MIDSIZE'      = 'Yammer Midsize'
        'NUCLEUS'             = 'Microsoft Nucleus'
        'ONEDRIVEENTERPRISE'  = 'OneDrive for Business (Plan 2)'
        'SHAREPOINTLITE'      = 'SharePoint Lite'
        'ONEDRIVESTANDARD'    = 'OneDrive for Business (Plan 1)'
        'MCOPSTN3'            = 'Skype for Business PSTN Calling'
        'BI_AZURE_P0'         = 'Power BI (free)'
        'AAD_PREMIUM'         = 'Microsoft Entra ID P1'
        'AAD_PREMIUM_P2'      = 'Microsoft Entra ID P2'
        'EXCHANGE_S_ENTERPRISE' = 'Exchange Online (Plan 2)'
        'EXCHANGE_S_STANDARD'   = 'Exchange Online (Plan 1)'
        'EXCHANGE_S_DESKLESS'   = 'Exchange Online Kiosk'
        'EXCHANGE_B_STANDARD'   = 'Exchange Online POP'
        'EXCHANGE_L_STANDARD'   = 'Exchange Online (Plan 1)'
        'EXCHANGE_S_ESSENTIALS' = 'Exchange Online Essentials'
        'EXCHANGE_S_STANDARD_MIDMARKET' = 'Exchange Online (Plan 1)'
        'EXCHANGE_S_ARCHIVE'    = 'Exchange Online Archiving for Exchange Server'
        'SHAREPOINTENTERPRISE'  = 'SharePoint Online (Plan 2)'
        'SHAREPOINTSTANDARD'    = 'SharePoint Online (Plan 1)'
        'OFFICESUBSCRIPTION'    = 'Microsoft 365 Apps for enterprise'
        'SHAREPOINTWAC'         = 'Office for the web'
        '7547a3fe-08ee-4ccb-b430-5077c5041653' = 'Yammer Enterprise'
        'db4d623d-b514-490b-b7ef-8885eee514de' = 'Microsoft Nucleus'
        'afcafa6a-d966-4462-918c-ec0b4e0fe642' = 'OneDrive for Business (Plan 2)'
        '41781fb2-bc02-4b7c-bd55-b576c07bb09d' = 'Microsoft Entra ID P1'
        'eec0eb4f-6444-4f95-aba0-50c24d67f998' = 'Microsoft Entra ID P2'
        'efb87545-963c-4e0d-99df-69c6916d9eb0' = 'Exchange Online (Plan 2)'
        '9aaf7827-d63c-4b61-89c3-182f06f82e5c' = 'Exchange Online (Plan 1)'
        '5dbe027f-2339-4123-9542-606e4d348a72' = 'SharePoint Online (Plan 2)'
        '43de0ff5-c92c-492b-9116-175376d08c38' = 'Microsoft 365 Apps for enterprise'
        'e95bec33-7c88-4a70-8e19-b10bd9d0c014' = 'Office for the web'
    }
}

function Resolve-LicensingCsvFriendlyFragment {
    param(
        [Parameter(Mandatory)][string]$FriendlyColumn,
        [AllowNull()][string]$PlanId
    )

    $friendly = $FriendlyColumn.Trim()
    if (-not $friendly) { return $friendly }

    if ($PlanId -and $friendly -match [regex]::Escape($PlanId)) {
        # Allow nested display parens like "SharePoint (Plan 2) (guid)".
        $pattern = "((?:[^();]|\([^)]*\))+?)\s*\(\s*$([regex]::Escape($PlanId))\s*\)"
        if ($friendly -match $pattern) {
            $fragment = $Matches[1].Trim()
            # CSV sometimes glues names: "SharePoint (Plan 2)Dynamics 365..."
            if ($fragment -match '\)(?=[A-Za-z])') {
                $parts = [regex]::Split($fragment, '(?<=\))(?=[A-Za-z])')
                $fragment = $parts[-1].Trim()
            }
            return $fragment
        }
    }

    if ($friendly -notmatch ';' -and $friendly -notmatch '\([0-9a-fA-F-]{36}\)') {
        return $friendly
    }

    $first = ($friendly -split ';')[0].Trim()
    if ($first -match '^(.*?)\s*\([0-9a-fA-F-]{36}\)\s*$') {
        return $Matches[1].Trim()
    }
    return $first
}

function ConvertFrom-MicrosoftLicensingCsv {
    param([Parameter(Mandatory)][string]$CsvText)

    $rows = $CsvText | ConvertFrom-Csv
    $byPlanId = @{}
    $byPlanName = @{}

    foreach ($row in $rows) {
        $props = @{}
        foreach ($p in $row.PSObject.Properties) {
            $props[$p.Name.Trim().Trim([char]0xFEFF)] = [string]$p.Value
        }

        $planId = $null
        $planName = $null
        $friendly = $null

        foreach ($key in $props.Keys) {
            $normalized = $key.ToLowerInvariant().Replace(' ', '_')
            switch -Regex ($normalized) {
                '^service_plan_id$' { $planId = $props[$key]; break }
                '^service_plan_name$' { $planName = $props[$key]; break }
                'friendly' {
                    if (-not $friendly) { $friendly = $props[$key] }
                }
            }
        }

        if (-not $friendly) {
            foreach ($key in $props.Keys) {
                if ($key -match 'Service_Plans_Included_Friendly_Names|ServicePlanFriendly') {
                    $friendly = $props[$key]
                    break
                }
            }
        }

        if (-not $friendly) { continue }

        $matchedFriendly = Resolve-LicensingCsvFriendlyFragment -FriendlyColumn $friendly -PlanId $planId

        if ($planId) {
            Set-ServicePlanFriendlyMapEntry -Map $byPlanId -Key $planId.ToLowerInvariant() -PlanName $planName -Friendly $matchedFriendly
        }
        if ($planName) {
            Set-ServicePlanFriendlyMapEntry -Map $byPlanName -Key $planName.ToUpperInvariant() -PlanName $planName -Friendly $matchedFriendly
        }
    }

    foreach ($entry in (Get-ServicePlanFriendlyNameOverrides).GetEnumerator()) {
        $key = [string]$entry.Key
        $value = [string]$entry.Value
        if ($key -match '^[0-9a-fA-F-]{36}$') {
            Set-ServicePlanFriendlyMapEntry -Map $byPlanId -Key $key.ToLowerInvariant() -PlanName $null -Friendly $value -Force
        }
        else {
            Set-ServicePlanFriendlyMapEntry -Map $byPlanName -Key $key.ToUpperInvariant() -PlanName $key -Friendly $value -Force
        }
    }

    return [pscustomobject]@{
        ByPlanId   = $byPlanId
        ByPlanName = $byPlanName
    }
}

function Get-MicrosoftServicePlanFriendlyNameMap {
    param(
        [string]$CsvUrl,
        [string]$CsvText
    )

    if (-not $CsvText) {
        $url = if ($CsvUrl) { $CsvUrl } else { Get-MicrosoftLicensingCsvUrl }
        $response = Invoke-WebRequest -Uri $url -UseBasicParsing -ErrorAction Stop
        $content = $response.Content
        if ($content -is [byte[]]) {
            $CsvText = [System.Text.Encoding]::UTF8.GetString($content)
        }
        else {
            $CsvText = [string]$content
        }
    }
    return ConvertFrom-MicrosoftLicensingCsv -CsvText $CsvText
}

function Get-ServicePlanLookupKeys {
    param(
        [AllowNull()][string]$Name,
        [AllowNull()][string]$Value
    )

    $keys = [System.Collections.Generic.List[string]]::new()
    foreach ($raw in @($Name, $Value)) {
        if ([string]::IsNullOrWhiteSpace($raw)) { continue }
        $trimmed = $raw.Trim()
        if (-not $keys.Contains($trimmed)) { $keys.Add($trimmed) }

        # Entra connector entitlement names: "AAD_PREMIUM [of] DEVELOPERPACK_E5"
        if ($trimmed -match '^\s*(?<plan>.+?)\s*\[of\]\s*(?<sku>.+)\s*$') {
            $plan = $Matches['plan'].Trim()
            if ($plan -and -not $keys.Contains($plan)) { $keys.Add($plan) }
        }
    }
    return [string[]]@($keys.ToArray())
}

function Get-ObjectPropertySafe {
    param(
        [AllowNull()]$Object,
        [Parameter(Mandatory)][string]$Name,
        [string[]]$AlternateNames = @()
    )

    if ($null -eq $Object) { return $null }

    $names = @($Name) + @($AlternateNames)

    # PSSailpoint array responses deserialize with ConvertFrom-Json -AsHashtable.
    # Hashtable PSObject.Properties are Keys/Values/Count — not the JSON fields.
    if ($Object -is [System.Collections.IDictionary]) {
        foreach ($candidate in $names) {
            foreach ($key in @($Object.Keys)) {
                if ([string]$key -ieq $candidate) {
                    $val = $Object[$key]
                    if ($null -ne $val -and "$val" -ne '') { return $val }
                }
            }
        }
        return $null
    }

    foreach ($candidate in $names) {
        $prop = $null
        foreach ($p in @($Object.PSObject.Properties)) {
            if ($p.Name -ieq $candidate) {
                $prop = $p
                break
            }
        }
        if ($null -ne $prop -and $null -ne $prop.Value -and "$($prop.Value)" -ne '') {
            return $prop.Value
        }
    }
    return $null
}

function Resolve-ServicePlanFriendlyName {
    param(
        [Parameter(Mandatory)]$Entitlement,
        [Parameter(Mandatory)]$FriendlyNameMap
    )

    $value = [string](Get-ObjectPropertySafe -Object $Entitlement -Name 'value' -AlternateNames @('Value', 'attributeValue'))
    $name = [string](Get-ObjectPropertySafe -Object $Entitlement -Name 'name' -AlternateNames @('Name', 'attribute'))
    $displayName = [string](Get-ObjectPropertySafe -Object $Entitlement -Name 'displayName' -AlternateNames @('DisplayName'))
    $id = [string](Get-ObjectPropertySafe -Object $Entitlement -Name 'id' -AlternateNames @('Id'))

    $attributes = Get-ObjectPropertySafe -Object $Entitlement -Name 'attributes' -AlternateNames @('Attributes')
    $attrPlanId = [string](Get-ObjectPropertySafe -Object $attributes -Name 'servicePlanId' -AlternateNames @('ServicePlanId', 'GUID', 'guid'))
    $attrPlanName = [string](Get-ObjectPropertySafe -Object $attributes -Name 'servicePlanName' -AlternateNames @('ServicePlanName', 'skuPartNumber'))

    foreach ($candidate in @($value, $attrPlanId)) {
        if (-not $candidate) { continue }
        $key = $candidate.ToLowerInvariant()
        if ($FriendlyNameMap.ByPlanId.ContainsKey($key)) {
            return [string]$FriendlyNameMap.ByPlanId[$key]
        }
    }

    foreach ($candidate in (Get-ServicePlanLookupKeys -Name $name -Value $value) + @($attrPlanName)) {
        if ([string]::IsNullOrWhiteSpace($candidate)) { continue }
        $key = $candidate.ToUpperInvariant()
        if ($FriendlyNameMap.ByPlanName.ContainsKey($key)) {
            return [string]$FriendlyNameMap.ByPlanName[$key]
        }
    }

    if ($displayName) { return $displayName }
    # Prefer bare plan name over "PLAN [of] SKU" when no CSV hit.
    foreach ($candidate in (Get-ServicePlanLookupKeys -Name $name -Value $value)) {
        if ($candidate -notmatch '\[of\]') { return $candidate }
    }
    if ($name) { return $name }
    if ($value) { return $value }
    if ($id) { return $id }
    return 'Unknown service plan'
}

function Expand-EntitlementList {
    param([AllowNull()]$Entitlements)

    $flat = [System.Collections.Generic.List[object]]::new()
    foreach ($item in @($Entitlements)) {
        if ($null -eq $item) { continue }
        if ($item -is [System.Array] -and -not ($item -is [string])) {
            foreach ($inner in (Expand-EntitlementList -Entitlements $item)) {
                $flat.Add($inner)
            }
        }
        else {
            $flat.Add($item)
        }
    }
    return [object[]]@($flat.ToArray())
}

function Build-ServicePlanSelectionRows {
    param(
        [Parameter(Mandatory)][object[]]$Entitlements,
        [Parameter(Mandatory)]$FriendlyNameMap
    )

    $list = [System.Collections.Generic.List[object]]::new()
    foreach ($ent in (Expand-EntitlementList -Entitlements $Entitlements)) {
        $friendly = Resolve-ServicePlanFriendlyName -Entitlement $ent -FriendlyNameMap $FriendlyNameMap
        $value = [string](Get-ObjectPropertySafe -Object $ent -Name 'value' -AlternateNames @('Value', 'attributeValue'))
        $name = [string](Get-ObjectPropertySafe -Object $ent -Name 'name' -AlternateNames @('Name', 'attribute'))
        $id = [string](Get-ObjectPropertySafe -Object $ent -Name 'id' -AlternateNames @('Id'))
        $lookupKeys = @(Get-ServicePlanLookupKeys -Name $name -Value $value)
        $barePlan = @($lookupKeys | Where-Object { $_ -notmatch '\[of\]' } | Select-Object -First 1)
        $internal = if ($barePlan) { [string]$barePlan[0] } elseif ($name) { $name } elseif ($value) { $value } else { $id }
        if (-not $internal) { $internal = 'unknown' }
        # Menu -Options cannot be empty strings; prefer id, then value, then internal name.
        $optionKey = if ($id) { $id } elseif ($value) { $value } else { $internal }
        $list.Add([pscustomobject]@{
                Id           = $optionKey
                Value        = $value
                InternalName = $internal
                FriendlyName = $friendly
                Label        = "$friendly ($internal)"
                Entitlement  = $ent
            })
    }
    $sorted = @($list | Sort-Object FriendlyName, InternalName)
    return [object[]]@($sorted)
}

function New-AccessProfileName {
    param(
        [Parameter(Mandatory)][AllowEmptyString()][string]$Prefix,
        [Parameter(Mandatory)][string]$FriendlyName
    )

    # Normalize "...-" / "... -" / "...- " to "... - " so the friendly name is separated cleanly.
    $prefix = $Prefix.TrimEnd()
    if ($prefix -match '-\s*$') {
        $prefix = ($prefix -replace '-\s*$', '').TrimEnd() + ' - '
    }
    elseif ($prefix -and -not $prefix.EndsWith(' ')) {
        $prefix = "$prefix "
    }

    return ($prefix + $FriendlyName.Trim()).Trim()
}

function Get-M365ReplayAccessProfileName {
    param(
        [Parameter(Mandatory)]$ManifestRow,
        [Parameter(Mandatory)][string]$CurrentFriendlyName,
        [AllowEmptyString()][string]$ManifestPrefix
    )

    if (-not [string]::IsNullOrWhiteSpace($ManifestPrefix)) {
        return New-AccessProfileName -Prefix $ManifestPrefix -FriendlyName $CurrentFriendlyName
    }

    $previousName = [string](Get-ObjectPropertySafe -Object $ManifestRow -Name 'AccessProfileName')
    $previousFriendlyName = [string](Get-ObjectPropertySafe -Object $ManifestRow -Name 'FriendlyName')
    if ($previousName -and $previousFriendlyName -and
        $previousName.EndsWith($previousFriendlyName, [StringComparison]::OrdinalIgnoreCase)) {
        $inferredPrefix = $previousName.Substring(0, $previousName.Length - $previousFriendlyName.Length)
        return New-AccessProfileName -Prefix $inferredPrefix -FriendlyName $CurrentFriendlyName
    }

    if ($previousName) { return $previousName }
    return New-AccessProfileName -Prefix '' -FriendlyName $CurrentFriendlyName
}

function Get-M365AccessProfileLookupNames {
    param(
        [Parameter(Mandatory)]$ManifestRow,
        [Parameter(Mandatory)][string]$CurrentFriendlyName,
        [AllowEmptyString()][string]$ManifestPrefix
    )

    $names = [System.Collections.Generic.List[string]]::new()
    $add = {
        param([string]$Candidate)
        if ([string]::IsNullOrWhiteSpace($Candidate)) { return }
        if (-not $names.Contains($Candidate)) { $names.Add($Candidate) }
    }

    & $add ([string](Get-ObjectPropertySafe -Object $ManifestRow -Name 'AccessProfileName'))
    if (-not [string]::IsNullOrWhiteSpace($ManifestPrefix)) {
        & $add (New-AccessProfileName -Prefix $ManifestPrefix -FriendlyName $CurrentFriendlyName)
    }
    & $add (Get-M365ReplayAccessProfileName -ManifestRow $ManifestRow `
            -CurrentFriendlyName $CurrentFriendlyName -ManifestPrefix $ManifestPrefix)
    & $add (Get-M365ReplayAccessProfileName -ManifestRow $ManifestRow `
            -CurrentFriendlyName $CurrentFriendlyName -ManifestPrefix '')
    return [string[]]@($names)
}

function Find-AccessProfilesByName {
    param(
        [Parameter(Mandatory)][string]$Name,
        [string]$SourceId,
        [scriptblock]$AccessProfileFetcher
    )

    $escaped = $Name.Replace('"', '\"')
    $filters = "name eq `"$escaped`""
    if ($PSBoundParameters.ContainsKey('AccessProfileFetcher') -and $null -ne $AccessProfileFetcher) {
        $profiles = @(& $AccessProfileFetcher $filters)
    }
    else {
        $profiles = @(Get-AccessProfilesV1 -Filters $filters -Limit 250 -ErrorAction Stop)
    }

    if ($SourceId) {
        $profiles = @(
            $profiles | Where-Object {
                $src = Get-ObjectPropertySafe -Object $_ -Name 'source' -AlternateNames @('Source')
                [string](Get-ObjectIdSafe -Object $src) -eq $SourceId
            }
        )
    }
    return @($profiles)
}

function Get-ObjectIdSafe {
    param($Object)
    if ($null -eq $Object) { return $null }
    if ($Object -is [string]) { return $Object }
    if ($Object -is [System.Collections.IDictionary]) {
        foreach ($key in @('id', 'Id')) {
            if ($Object.Contains($key) -and $Object[$key]) { return [string]$Object[$key] }
            # Hashtable Contains is case-sensitive for some implementations; scan keys.
        }
        foreach ($key in @($Object.Keys)) {
            if ([string]$key -ieq 'id' -and $Object[$key]) { return [string]$Object[$key] }
        }
        return $null
    }
    $id = Get-ObjectPropertySafe -Object $Object -Name 'id' -AlternateNames @('Id')
    if ($id) { return [string]$id }
    return $null
}

function Get-AccessProfileEntitlementIds {
    param(
        [Parameter(Mandatory)]$AccessProfile,
        [scriptblock]$EntitlementListFetcher
    )

    $entitlements = Get-ObjectPropertySafe -Object $AccessProfile -Name 'entitlements' -AlternateNames @('Entitlements')
    if ($null -ne $entitlements) {
        $ids = foreach ($ent in @($entitlements)) {
            if ($null -eq $ent) { continue }
            if ($ent -is [string]) { $ent; continue }
            $id = Get-ObjectIdSafe -Object $ent
            if ($id) { $id }
        }
        return @($ids)
    }

    if ($EntitlementListFetcher) {
        return @(& $EntitlementListFetcher (Get-ObjectIdSafe -Object $AccessProfile) | ForEach-Object {
                if ($_ -is [string]) { $_ } else { Get-ObjectIdSafe -Object $_ }
            })
    }

    if (-not (Get-Command Get-AccessProfileEntitlementsV1 -ErrorAction SilentlyContinue)) {
        return @()
    }

    $listed = @(Get-AccessProfileEntitlementsV1 -Id (Get-ObjectIdSafe -Object $AccessProfile) -Limit 250 -ErrorAction Stop)
    return @($listed | ForEach-Object { [string]$_.id })
}

function Build-AccessProfileCreatePayload {
    param(
        [Parameter(Mandatory)][string]$Name,
        [Parameter(Mandatory)][string]$SourceId,
        [string]$SourceName,
        [Parameter(Mandatory)][string]$OwnerId,
        [Parameter(Mandatory)][string]$EntitlementId,
        [string]$Description,
        [bool]$Enabled = $true,
        [bool]$Requestable = $true
    )

    return [pscustomobject]@{
        name        = $Name
        description = $(if ($Description) { $Description } else { "Microsoft 365 service plan access profile for $Name" })
        enabled     = $Enabled
        requestable = $Requestable
        owner       = [pscustomobject]@{ type = 'IDENTITY'; id = $OwnerId }
        source      = [pscustomobject]@{
            type = 'SOURCE'
            id   = $SourceId
            name = $SourceName
        }
        entitlements = @(
            [pscustomobject]@{ type = 'ENTITLEMENT'; id = $EntitlementId }
        )
    }
}

function Resolve-OrCreateAccessProfileForPlan {
    param(
        [Parameter(Mandatory)][string]$Name,
        [Parameter(Mandatory)][string]$SourceId,
        [string]$SourceName,
        [Parameter(Mandatory)][string]$OwnerId,
        [Parameter(Mandatory)][string]$EntitlementId,
        [string]$Description,
        [ValidateSet('Skip', 'Update')]
        [string]$ExistingItemAction = 'Skip',
        [string[]]$PreviousNames = @(),
        [switch]$WhatIf,
        [scriptblock]$AccessProfileFetcher,
        [scriptblock]$CreateFetcher,
        [scriptblock]$EntitlementListFetcher,
        [scriptblock]$PatchFetcher
    )

    $lookupNames = [System.Collections.Generic.List[string]]::new()
    foreach ($candidate in @($Name) + @($PreviousNames)) {
        if ([string]::IsNullOrWhiteSpace($candidate)) { continue }
        if (-not $lookupNames.Contains($candidate)) { $lookupNames.Add($candidate) }
    }

    $existing = @()
    foreach ($candidate in $lookupNames) {
        $hits = @(Find-AccessProfilesByName -Name $candidate -SourceId $SourceId -AccessProfileFetcher $AccessProfileFetcher)
        if ($hits.Count -eq 0) { continue }
        $existing = $hits
        break
    }
    if ($existing.Count -gt 1) {
        return [pscustomobject]@{
            Status          = 'conflict'
            AccessProfileId = $null
            Name            = $Name
            Message         = "Multiple access profiles named '$Name' exist on this source."
        }
    }

    if ($existing.Count -eq 1) {
        $existingId = Get-ObjectIdSafe -Object $existing[0]
        if ($ExistingItemAction -eq 'Skip') {
            return [pscustomobject]@{
                Status          = 'skipped'
                AccessProfileId = $existingId
                Name            = $Name
                Message         = 'Existing access profile skipped.'
            }
        }

        $desiredDescription = if ($Description) { $Description } else { "Microsoft 365 service plan access profile for $Name" }
        $ops = @(
            [pscustomobject]@{ op = 'replace'; path = '/name'; value = $Name }
            [pscustomobject]@{ op = 'replace'; path = '/description'; value = $desiredDescription }
            [pscustomobject]@{ op = 'replace'; path = '/enabled'; value = $true }
            [pscustomobject]@{ op = 'replace'; path = '/requestable'; value = $true }
            [pscustomobject]@{ op = 'replace'; path = '/owner'; value = [pscustomobject]@{ type = 'IDENTITY'; id = $OwnerId } }
            [pscustomobject]@{ op = 'replace'; path = '/source'; value = [pscustomobject]@{ type = 'SOURCE'; id = $SourceId; name = $SourceName } }
            [pscustomobject]@{ op = 'replace'; path = '/entitlements'; value = @([pscustomobject]@{ type = 'ENTITLEMENT'; id = $EntitlementId }) }
        )
        if ($WhatIf) {
            return [pscustomobject]@{
                Status          = 'would-update'
                AccessProfileId = $existingId
                Name            = $Name
                Message         = 'WhatIf: would reconcile existing access profile.'
                Operations      = $ops
            }
        }
        if ($PatchFetcher) {
            $null = & $PatchFetcher $existingId $ops
        }
        else {
            $null = Update-AccessProfileV1 -Id $existingId -JsonPatchOperation $ops -ErrorAction Stop
        }
        return [pscustomobject]@{
            Status          = 'updated'
            AccessProfileId = $existingId
            Name            = $Name
            Message         = 'Existing access profile reconciled.'
        }
    }

    $payload = Build-AccessProfileCreatePayload -Name $Name -SourceId $SourceId -SourceName $SourceName `
        -OwnerId $OwnerId -EntitlementId $EntitlementId -Description $Description

    if ($WhatIf) {
        return [pscustomobject]@{
            Status          = 'would-create'
            AccessProfileId = $null
            Name            = $Name
            Message         = 'WhatIf: would create access profile.'
            Payload         = $payload
        }
    }

    $created = if ($CreateFetcher) {
        & $CreateFetcher $payload
    }
    else {
        New-AccessProfileV1 -AccessProfile $payload -ErrorAction Stop
    }

    $newId = Get-ObjectIdSafe -Object $created
    if (-not $newId) {
        # Some SDK/API responses omit the body; re-read by name so callers can link the AP to an app.
        $newId = Get-ObjectIdSafe -Object (
            @(Find-AccessProfilesByName -Name $Name -SourceId $SourceId -AccessProfileFetcher $AccessProfileFetcher) |
                Select-Object -First 1
        )
    }

    return [pscustomobject]@{
        Status          = 'created'
        AccessProfileId = $newId
        Name            = $Name
        Message         = 'Access profile created.'
    }
}

function Find-SourceAppByName {
    param(
        [Parameter(Mandatory)][string]$Name,
        [string]$SourceId,
        [scriptblock]$SourceAppFetcher
    )

    $escaped = $Name.Replace('"', '\"')
    $filters = "name eq `"$escaped`""
    if ($PSBoundParameters.ContainsKey('SourceAppFetcher') -and $null -ne $SourceAppFetcher) {
        $apps = @(& $SourceAppFetcher $filters)
    }
    else {
        $apps = @(Get-AllSourceAppV1 -Filters $filters -Limit 250 -XSailPointExperimental 'true' -ErrorAction Stop)
    }

    if ($SourceId) {
        $apps = @(
            $apps | Where-Object {
                $accountSource = Get-ObjectPropertySafe -Object $_ -Name 'accountSource' -AlternateNames @('AccountSource')
                [string](Get-ObjectIdSafe -Object $accountSource) -eq $SourceId
            }
        )
    }
    return @($apps)
}

function Resolve-OrCreateSourceApp {
    param(
        [Parameter(Mandatory)][string]$Name,
        [Parameter(Mandatory)][string]$SourceId,
        [string]$Description = 'Microsoft 365 service plan access profiles',
        [string]$OwnerId,
        [ValidateSet('Skip', 'Update')]
        [string]$ExistingItemAction = 'Skip',
        [switch]$WhatIf,
        [scriptblock]$SourceAppFetcher,
        [scriptblock]$CreateFetcher,
        [scriptblock]$OwnerPatchFetcher
    )

    $existing = @(Find-SourceAppByName -Name $Name -SourceId $SourceId -SourceAppFetcher $SourceAppFetcher)
    if ($existing.Count -gt 1) {
        throw "Multiple source apps named '$Name' exist for this source."
    }
    if ($existing.Count -eq 1) {
        $appId = Get-ObjectIdSafe -Object $existing[0]
        if ($ExistingItemAction -eq 'Skip') {
            return [pscustomobject]@{
                Status = 'skipped'
                AppId  = $appId
                Name   = $Name
                App    = $existing[0]
            }
        }

        $ops = @(
            [pscustomobject]@{ op = 'replace'; path = '/name'; value = $Name }
            [pscustomobject]@{ op = 'replace'; path = '/description'; value = $Description }
            [pscustomobject]@{ op = 'replace'; path = '/owner'; value = [pscustomobject]@{ type = 'IDENTITY'; id = $OwnerId } }
            [pscustomobject]@{ op = 'replace'; path = '/accountSource'; value = [pscustomobject]@{ id = $SourceId } }
        )
        if ($WhatIf) {
            return [pscustomobject]@{
                Status     = 'would-update'
                AppId      = $appId
                Name       = $Name
                App        = $existing[0]
                Operations = $ops
            }
        }
        if ($OwnerPatchFetcher) {
            $null = & $OwnerPatchFetcher $appId $ops
        }
        else {
            $null = Update-SourceAppV1 -Id $appId -JsonPatchOperation $ops -XSailPointExperimental 'true' -ErrorAction Stop
        }
        return [pscustomobject]@{
            Status = 'updated'
            AppId  = $appId
            Name   = $Name
            App    = $existing[0]
        }
    }

    $payload = [pscustomobject]@{
        name           = $Name
        description    = $Description
        accountSource  = [pscustomobject]@{ id = $SourceId }
    }

    if ($WhatIf) {
        return [pscustomobject]@{
            Status  = 'would-create'
            AppId   = $null
            Name    = $Name
            Payload = $payload
            App     = $null
        }
    }

    $created = if ($CreateFetcher) {
        & $CreateFetcher $payload
    }
    else {
        New-SourceAppV1 -SourceAppCreateDto $payload -XSailPointExperimental 'true' -ErrorAction Stop
    }

    $appId = Get-ObjectIdSafe -Object $created
    if (-not $appId) {
        # The create response can omit the body; re-read by name so access profiles can still be linked.
        $appId = Get-ObjectIdSafe -Object (
            @(Find-SourceAppByName -Name $Name -SourceId $SourceId -SourceAppFetcher $SourceAppFetcher) |
                Select-Object -First 1
        )
    }

    if ($OwnerId -and $appId) {
        $ops = @(
            [pscustomobject]@{
                op    = 'replace'
                path  = '/owner'
                value = [pscustomobject]@{ type = 'IDENTITY'; id = $OwnerId }
            }
        )
        if ($OwnerPatchFetcher) {
            $null = & $OwnerPatchFetcher $appId $ops
        }
        else {
            $null = Update-SourceAppV1 -Id $appId -JsonPatchOperation $ops -XSailPointExperimental 'true' -ErrorAction Stop
        }
    }

    return [pscustomobject]@{
        Status = 'created'
        AppId  = $appId
        Name   = $Name
        App    = $created
    }
}

function Build-SourceAppAccessProfilePatch {
    param(
        [Parameter(Mandatory)][string[]]$AccessProfileIds,
        [string[]]$ExistingAccessProfileIds = @()
    )

    $existing = [System.Collections.Generic.HashSet[string]]::new([string[]]@($ExistingAccessProfileIds))
    $ops = foreach ($id in $AccessProfileIds) {
        if ($existing.Contains($id)) { continue }
        [pscustomobject]@{
            op    = 'add'
            path  = '/accessProfiles/-'
            value = $id
        }
    }
    return , @($ops)
}

function Add-AccessProfilesToSourceApp {
    param(
        [Parameter(Mandatory)][string]$AppId,
        [Parameter(Mandatory)][string[]]$AccessProfileIds,
        [switch]$WhatIf,
        [scriptblock]$ExistingFetcher,
        [scriptblock]$PatchFetcher
    )

    $existing = if ($ExistingFetcher) {
        @(& $ExistingFetcher $AppId)
    }
    else {
        @(Get-AccessProfilesForSourceAppV1 -Id $AppId -Limit 250 -XSailPointExperimental 'true' -ErrorAction SilentlyContinue)
    }
    $existingIds = @($existing | ForEach-Object {
            if ($_ -is [string]) { $_ }
            elseif ($_.PSObject.Properties['id']) { [string]$_.id }
            else { [string]$_ }
        })

    $ops = @(Build-SourceAppAccessProfilePatch -AccessProfileIds $AccessProfileIds -ExistingAccessProfileIds $existingIds)
    if ($ops.Count -eq 0) {
        return [pscustomobject]@{ Status = 'unchanged'; Added = @(); Operations = @() }
    }

    if ($WhatIf) {
        return [pscustomobject]@{
            Status     = 'would-patch'
            Added      = @($ops | ForEach-Object { $_.value })
            Operations = $ops
        }
    }

    if ($PatchFetcher) {
        $null = & $PatchFetcher $AppId $ops
    }
    else {
        $null = Update-SourceAppV1 -Id $AppId -JsonPatchOperation $ops -XSailPointExperimental 'true' -ErrorAction Stop
    }

    return [pscustomobject]@{
        Status     = 'patched'
        Added      = @($ops | ForEach-Object { $_.value })
        Operations = $ops
    }
}

function Find-IdentitiesByQuery {
    param(
        [Parameter(Mandatory)][string]$Query,
        [scriptblock]$IdentityFetcher
    )

    if ($IdentityFetcher) {
        return [object[]]@(Expand-EntitlementList -Entitlements (& $IdentityFetcher $Query))
    }

    $escaped = $Query.Replace('"', '\"').Trim()
    if (-not $escaped) { return [object[]]@() }

    $byId = [ordered]@{}
    $addHits = {
        param($Items)
        foreach ($item in (Expand-EntitlementList -Entitlements $Items)) {
            $id = [string](Get-ObjectPropertySafe -Object $item -Name 'id' -AlternateNames @('Id'))
            if ($id -and -not $byId.Contains($id)) {
                $byId[$id] = $item
            }
        }
    }

    # 1) Preferred: ISC Search (free-text across name/alias/email).
    try {
        $searchBody = [pscustomobject]@{
            indices = @('identities')
            query   = [pscustomobject]@{ query = $Query.Trim() }
            sort    = @('displayName')
        }
        $searchHits = @(Search-PostV1 -Search $searchBody -Limit 25 -ErrorAction Stop)
        & $addHits $searchHits
    }
    catch {
        # Fall through to filter-based lookups.
    }

    if ($byId.Count -gt 0) {
        return [object[]]@($byId.Values)
    }

    # 2) Public identities: alias/email/firstname/lastname with sw/eq (no "name" / no "co").
    $publicFilters = @(
        "alias sw `"$escaped`""
        "email sw `"$escaped`""
        "firstname sw `"$escaped`""
        "lastname sw `"$escaped`""
        "alias eq `"$escaped`""
        "email eq `"$escaped`""
    )
    foreach ($filters in $publicFilters) {
        try {
            if (Get-Command Get-PublicIdentitiesV1 -ErrorAction SilentlyContinue) {
                & $addHits (Get-PublicIdentitiesV1 -Filters $filters -Limit 25 -ErrorAction Stop)
            }
        }
        catch { }
    }

    if ($byId.Count -gt 0) {
        return [object[]]@($byId.Values)
    }

    # 3) Identities list: name/alias/email support eq/sw only (not co).
    $identityFilters = @(
        "name sw `"$escaped`""
        "alias sw `"$escaped`""
        "email sw `"$escaped`""
        "name eq `"$escaped`""
        "alias eq `"$escaped`""
        "email eq `"$escaped`""
    )
    foreach ($filters in $identityFilters) {
        $prevEap = $ErrorActionPreference
        $ErrorActionPreference = 'Continue'
        try {
            $err = $null
            $page = Get-IdentitiesV1 -Filters $filters -Limit 25 -ErrorAction SilentlyContinue -ErrorVariable err
            if ($err) { continue }
            & $addHits $page
        }
        catch {
            # Swallow 400/unsupported filter combinations.
        }
        finally {
            $ErrorActionPreference = $prevEap
        }
    }

    return [object[]]@($byId.Values)
}

function ConvertFrom-JwtPayload {
    param([AllowNull()][string]$Token)

    if ([string]::IsNullOrWhiteSpace($Token)) { return $null }
    $parts = $Token.Split('.')
    if ($parts.Count -lt 2) { return $null }
    $payload = $parts[1].Replace('-', '+').Replace('_', '/')
    switch ($payload.Length % 4) {
        1 { return $null }
        2 { $payload += '==' }
        3 { $payload += '=' }
    }
    try {
        $json = [Text.Encoding]::UTF8.GetString([Convert]::FromBase64String($payload))
        return $json | ConvertFrom-Json
    }
    catch {
        return $null
    }
}

function Get-CurrentIscIdentity {
    param(
        [string]$OwnerId,
        [scriptblock]$TokenFetcher,
        [scriptblock]$PersonalAccessTokenFetcher
    )

    if (-not [string]::IsNullOrWhiteSpace($OwnerId)) {
        return [pscustomobject]@{
            Id     = $OwnerId.Trim()
            Name   = $null
            Source = 'parameter'
        }
    }

    $token = $null
    if ($TokenFetcher) {
        $token = & $TokenFetcher
    }
    elseif (Get-Command Get-IDNAccessToken -ErrorAction SilentlyContinue) {
        try { $token = Get-IDNAccessToken } catch { }
    }

    $payload = ConvertFrom-JwtPayload -Token ([string]$token)
    if ($payload) {
        $id = [string](Get-ObjectPropertySafe -Object $payload -Name 'identity_id' -AlternateNames @('identityId', 'uid'))
        $name = [string](Get-ObjectPropertySafe -Object $payload -Name 'user_name' -AlternateNames @('userName', 'sub'))
        if (-not [string]::IsNullOrWhiteSpace($id) -and $id -ne 'null') {
            return [pscustomobject]@{
                Id     = $id
                Name   = $name
                Source = 'token'
            }
        }
    }

    $tokens = @()
    if ($PersonalAccessTokenFetcher) {
        $tokens = @(Expand-EntitlementList -Entitlements (& $PersonalAccessTokenFetcher))
    }
    elseif (Get-Command Get-PersonalAccessTokensV1 -ErrorAction SilentlyContinue) {
        try {
            $tokens = @(Get-PersonalAccessTokensV1 -OwnerId 'me' -ErrorAction Stop)
        }
        catch { }
    }

    foreach ($pat in $tokens) {
        $owner = Get-ObjectPropertySafe -Object $pat -Name 'owner' -AlternateNames @('Owner')
        $id = [string](Get-ObjectPropertySafe -Object $owner -Name 'id' -AlternateNames @('Id'))
        $name = [string](Get-ObjectPropertySafe -Object $owner -Name 'name' -AlternateNames @('Name'))
        if (-not [string]::IsNullOrWhiteSpace($id)) {
            return [pscustomobject]@{
                Id     = $id
                Name   = $name
                Source = 'personal-access-token'
            }
        }
    }

    throw 'Unable to resolve the current ISC identity as owner. Authenticate with a PAT issued to an identity, or pass -OwnerId.'
}

function Read-M365AccessProfileManifest {
    param([Parameter(Mandatory)][string]$Path)

    if (-not (Test-Path -LiteralPath $Path -PathType Leaf)) {
        throw "Manifest not found: $Path"
    }

    $extension = [IO.Path]::GetExtension($Path).ToLowerInvariant()
    if ($extension -eq '.json') {
        $manifest = Get-Content -LiteralPath $Path -Raw -Encoding UTF8 | ConvertFrom-Json
        $results = @(Get-ObjectPropertySafe -Object $manifest -Name 'Results' -AlternateNames @('results'))
        if ($results.Count -eq 0) {
            throw "JSON manifest contains no Results: $Path"
        }
        $application = Get-ObjectPropertySafe -Object $manifest -Name 'Application' -AlternateNames @('application')
        return [pscustomobject]@{
            Path            = (Resolve-Path -LiteralPath $Path).Path
            Format          = 'Json'
            Environment     = [string](Get-ObjectPropertySafe -Object $manifest -Name 'Environment')
            SourceId        = [string](Get-ObjectPropertySafe -Object $manifest -Name 'SourceId')
            SourceName      = [string](Get-ObjectPropertySafe -Object $manifest -Name 'SourceName')
            Prefix          = [string](Get-ObjectPropertySafe -Object $manifest -Name 'Prefix')
            ApplicationName = [string](Get-ObjectPropertySafe -Object $application -Name 'Name')
            Results         = @($results)
        }
    }

    if ($extension -eq '.csv') {
        $results = @(Import-Csv -LiteralPath $Path)
        if ($results.Count -eq 0) {
            throw "CSV manifest contains no rows: $Path"
        }
        foreach ($row in $results) {
            if ([string]::IsNullOrWhiteSpace([string]$row.AccessProfileName) -or
                ([string]::IsNullOrWhiteSpace([string]$row.InternalName) -and
                 [string]::IsNullOrWhiteSpace([string]$row.EntitlementId))) {
                throw "CSV manifest must contain AccessProfileName and InternalName or EntitlementId columns: $Path"
            }
        }
        return [pscustomobject]@{
            Path            = (Resolve-Path -LiteralPath $Path).Path
            Format          = 'Csv'
            Environment     = $null
            SourceId        = $null
            SourceName      = $null
            Prefix          = $null
            ApplicationName = $null
            Results         = @($results)
        }
    }

    throw "Manifest must be a .json or .csv file: $Path"
}

function Write-M365AccessProfileManifest {
    param(
        [Parameter(Mandatory)]$Manifest,
        [Parameter(Mandatory)][string]$OutputDirectory,
        [ValidateSet('Json', 'Csv', 'Both')]
        [string]$Format = 'Both'
    )

    if (-not (Test-Path -LiteralPath $OutputDirectory)) {
        New-Item -ItemType Directory -Path $OutputDirectory -Force | Out-Null
    }
    $stamp = Get-Date -Format 'yyyyMMdd-HHmmss'
    $jsonPath = $null
    $csvPath = $null
    if ($Format -in @('Json', 'Both')) {
        $jsonPath = Join-Path $OutputDirectory "m365-access-profiles-$stamp.json"
        $Manifest | ConvertTo-Json -Depth 8 | Set-Content -LiteralPath $jsonPath -Encoding UTF8
    }
    if ($Format -in @('Csv', 'Both') -and $Manifest.Results) {
        $csvPath = Join-Path $OutputDirectory "m365-access-profiles-$stamp.csv"
        $Manifest.Results | Export-Csv -LiteralPath $csvPath -NoTypeInformation -Encoding UTF8
    }
    return [pscustomobject]@{ JsonPath = $jsonPath; CsvPath = $csvPath }
}

Export-ModuleMember -Function @(
    'Test-IsEntraIscSource'
    'Get-ServicePlanEntitlementsForSource'
    'Find-EntraSourcesWithServicePlans'
    'ConvertFrom-MicrosoftLicensingCsv'
    'Get-MicrosoftServicePlanFriendlyNameMap'
    'Get-ServicePlanLookupKeys'
    'Get-ObjectPropertySafe'
    'Expand-EntitlementList'
    'Resolve-ServicePlanFriendlyName'
    'Build-ServicePlanSelectionRows'
    'New-AccessProfileName'
    'Get-M365ReplayAccessProfileName'
    'Get-M365AccessProfileLookupNames'
    'Find-AccessProfilesByName'
    'Get-AccessProfileEntitlementIds'
    'Build-AccessProfileCreatePayload'
    'Resolve-OrCreateAccessProfileForPlan'
    'Find-SourceAppByName'
    'Resolve-OrCreateSourceApp'
    'Build-SourceAppAccessProfilePatch'
    'Add-AccessProfilesToSourceApp'
    'Find-IdentitiesByQuery'
    'ConvertFrom-JwtPayload'
    'Get-CurrentIscIdentity'
    'Read-M365AccessProfileManifest'
    'Write-M365AccessProfileManifest'
)