Private/Core.ps1

<#
.SYNOPSIS
    Socle technique commun pour les scripts de migration SharePoint.
 
.DESCRIPTION
    Regroupe les briques transverses qui manquaient aux scripts historiques :
 
      * Invoke-SPMWithRetry - résilience au throttling SharePoint Online
                                (HTTP 429 / 503 / 504, en-tête Retry-After,
                                 backoff exponentiel + jitter)
      * Connect-SPMSite - pool de connexions PnP réutilisables
                                (-ReturnConnection / -Connection) : plus aucune
                                déconnexion/reconnexion en cours de traitement
      * Get-SPMListItemByPath - recherche d'élément par requête CAML ciblée
                                au lieu d'un téléchargement complet de la liste
      * Write-SPMLog - journalisation structurée NDJSON + console
 
    Ce module est le socle de transition : les scripts existants l'importent,
    et il sert de base au futur module SPMigrator.
 
.NOTES
    Requiert PnP.PowerShell 3.x (lui-même dépendant de PowerShell 7.4.6+).
    Le User-Agent est déjà décoré par PnP.PowerShell (NONISV|SharePointPnP|PnPPS/x.y.z),
    aucune action n'est nécessaire de ce côté.
#>


Set-StrictMode -Version Latest

# ============================================================================
# Configuration du module
# ============================================================================

$script:SPMConfig = @{
    MaxRetries = 6
    BaseDelayMs = 1000
    MaxDelayMs = 120000
    # Plafond du délai DICTÉ PAR LE SERVEUR, distinct de MaxDelayMs qui borne le
    # backoff que nous calculons. Rejouer avant l'heure autorisée par un
    # Retry-After aggrave le throttling au lieu de l'absorber : le service
    # rallonge alors ses propres délais. Cette borne-ci n'est qu'un garde-fou
    # contre un en-tête aberrant, pas un arbitrage.
    MaxRetryAfterMs = 900000
    JitterRatio = 0.25
    LogPath = $null
    JsonLogPath = $null
    ConsoleOutput = $true
    MinLevel = 'INFO'
}

$script:SPMLevels = @{ DEBUG = 0; INFO = 1; SUCCESS = 1; WARNING = 2; ERROR = 3 }

# Pool de connexions : clé = URL normalisée, valeur = objet PnPConnection
$script:SPMConnections = [System.Collections.Generic.Dictionary[string, object]]::new()

# Compteurs d'observabilité (exposés par Get-SPMStatistics)
$script:SPMStats = [ordered]@{
    Calls = 0
    Retries = 0
    ThrottleEvents = 0
    ThrottleWaitMs = 0
    Failures = 0
}

# ============================================================================
# Configuration
# ============================================================================

function Set-SPMConfiguration {
    <#
    .SYNOPSIS
        Configure le comportement global du socle (retries, journalisation).
    #>

    [CmdletBinding()]
    param(
        [ValidateRange(0, 20)]    [int]$MaxRetries,
        [ValidateRange(100, 60000)][int]$BaseDelayMs,
        [ValidateRange(1000, 600000)][int]$MaxDelayMs,
        [ValidateRange(1000, 3600000)][int]$MaxRetryAfterMs,
        [ValidateRange(0.0, 1.0)] [double]$JitterRatio,
        [string]$LogPath,
        [string]$JsonLogPath,
        [ValidateSet('DEBUG', 'INFO', 'WARNING', 'ERROR')][string]$MinLevel,
        [bool]$ConsoleOutput
    )

    foreach ($key in $PSBoundParameters.Keys) {
        if ($script:SPMConfig.Contains($key)) {
            $script:SPMConfig[$key] = $PSBoundParameters[$key]
        }
    }

    foreach ($p in 'LogPath', 'JsonLogPath') {
        $value = $script:SPMConfig[$p]
        if ($value) {
            $dir = Split-Path -Parent $value
            if ($dir -and -not (Test-Path -LiteralPath $dir)) {
                New-Item -ItemType Directory -Path $dir -Force | Out-Null
            }
        }
    }
}

function Get-SPMConfiguration {
    [CmdletBinding()]
    param()
    [pscustomobject]$script:SPMConfig
}

function Get-SPMStatistics {
    <#
    .SYNOPSIS
        Retourne les compteurs d'appels, de retries et de throttling observés.
    #>

    [CmdletBinding()]
    param([switch]$Reset)

    $snapshot = [pscustomobject]$script:SPMStats
    if ($Reset) {
        foreach ($k in @($script:SPMStats.Keys)) { $script:SPMStats[$k] = 0 }
    }
    $snapshot
}

# ============================================================================
# Journalisation structurée
# ============================================================================

function Write-SPMLog {
    <#
    .SYNOPSIS
        Journalise un événement en texte lisible et, si configuré, en NDJSON.
 
    .DESCRIPTION
        Le NDJSON est la source exploitable par les outils (une ligne = un objet
        JSON). Il remplace l'analyse de logs par correspondance de chaînes.
    #>

    [CmdletBinding()]
    param(
        [Parameter(Mandatory, Position = 0)][string]$Message,
        [ValidateSet('DEBUG', 'INFO', 'SUCCESS', 'WARNING', 'ERROR')][string]$Level = 'INFO',
        [string]$Operation,
        [hashtable]$Data
    )

    if ($script:SPMLevels[$Level] -lt $script:SPMLevels[$script:SPMConfig.MinLevel]) { return }

    $now = Get-Date
    $stamp = $now.ToString('yyyy-MM-dd HH:mm:ss')
    $line = if ($Operation) { "[$stamp] [$Level] [$Operation] $Message" } else { "[$stamp] [$Level] $Message" }

    if ($script:SPMConfig.ConsoleOutput) {
        $color = switch ($Level) {
            'SUCCESS' { 'Green' }
            'WARNING' { 'Yellow' }
            'ERROR' { 'Red' }
            'DEBUG' { 'DarkGray' }
            default { 'Gray' }
        }
        Write-Host $line -ForegroundColor $color
    }

    if ($script:SPMConfig.LogPath) {
        Add-Content -LiteralPath $script:SPMConfig.LogPath -Value $line -Encoding utf8
    }

    if ($script:SPMConfig.JsonLogPath) {
        $record = [ordered]@{
            ts = $now.ToString('o')
            level = $Level
            operation = $Operation
            message = $Message
        }
        if ($Data) { foreach ($k in $Data.Keys) { $record[$k] = $Data[$k] } }
        Add-Content -LiteralPath $script:SPMConfig.JsonLogPath `
            -Value ($record | ConvertTo-Json -Compress -Depth 6) -Encoding utf8
    }
}

# ============================================================================
# Résilience : détection du throttling et des erreurs transitoires
# ============================================================================

function Test-SPMAuthExpired {
    <#
    .SYNOPSIS
        Reconnaît un échec dû à un jeton expiré plutôt qu'à un refus de droit.
 
    .DESCRIPTION
        Une migration dure des heures ; un jeton interactif, non. Les deux se
        présentent en 401, mais l'un se répare en rouvrant la connexion et
        l'autre non. Ne pas les distinguer faisait échouer définitivement toutes
        les unités restantes d'une exécution de nuit — pour une raison qui se
        corrigeait en une seconde.
 
        La reconnaissance porte sur le message, parce que c'est tout ce dont
        dispose l'appelant en aval : un travail de copie rend une chaîne, pas
        une ErrorRecord.
    #>

    [CmdletBinding()]
    [OutputType([bool])]
    param([AllowNull()][string]$Message, [AllowNull()]$StatusCode)

    if ("$StatusCode" -eq '401') { return $true }
    if ([string]::IsNullOrWhiteSpace($Message)) { return $false }

    $lower = $Message.ToLowerInvariant()

    # « access denied » seul ne suffit pas : c'est aussi le refus de droit
    # légitime, que rouvrir la connexion ne changerait pas.
    $indices = @(
        'token has expired', 'token is expired', 'expired token', 'lifetime validation failed',
        'the token expiration', 'aadsts50173', 'aadsts700082', 'aadsts50076', 'aadsts50079',
        'reauthenticate', 're-authenticate', 'authentication expired', 'session has expired',
        '(401)', 'unauthorized'
    )
    foreach ($i in $indices) { if ($lower.Contains($i)) { return $true } }
    return $false
}

function Get-SPMRetryDecision {
    <#
    .SYNOPSIS
        Analyse une ErrorRecord et décide si l'opération doit être retentée.
 
    .DESCRIPTION
        Parcourt la chaîne d'exceptions internes à la recherche :
          - d'un code de statut HTTP (429, 502, 503, 504, 500 transitoire)
          - d'un en-tête Retry-After (délai ou date)
          - d'une erreur réseau / d'un dépassement de délai
        Retourne un objet { ShouldRetry, RetryAfterSeconds, StatusCode, Reason }.
 
    .NOTES
        Exposée publiquement car c'est la fonction la plus critique du socle :
        elle doit être testable unitairement sans appeler SharePoint.
    #>

    [CmdletBinding()]
    [OutputType([pscustomobject])]
    param(
        [Parameter(Mandatory)]
        [System.Management.Automation.ErrorRecord]$ErrorRecord
    )

    $statusCode = $null
    $retryAfter = $null
    $reason = $null

    $ex = $ErrorRecord.Exception
    $depth = 0

    while ($null -ne $ex -and $depth -lt 12) {
        $props = $ex.PSObject.Properties

        # --- 1. Statut HTTP porté par une propriété Response (HttpResponseMessage
        # pour HttpClient, HttpWebResponse pour WebException) ---
        if ($props['Response'] -and $null -ne $props['Response'].Value) {
            $response = $props['Response'].Value
            $rprops = $response.PSObject.Properties

            if ($rprops['StatusCode'] -and $null -ne $rprops['StatusCode'].Value) {
                try { $statusCode = [int]$rprops['StatusCode'].Value } catch { Write-Debug 'StatusCode non convertible en entier' }
            }

            if ($rprops['Headers'] -and $null -ne $rprops['Headers'].Value) {
                $retryAfter = Get-SPMRetryAfterSeconds -Headers $rprops['Headers'].Value
            }
        }

        # --- 2. Statut HTTP porté directement par l'exception ---
        if ($null -eq $statusCode -and $props['StatusCode'] -and $null -ne $props['StatusCode'].Value) {
            try { $statusCode = [int]$props['StatusCode'].Value } catch { Write-Debug 'StatusCode non convertible en entier' }
        }

        # --- 3. Erreurs réseau / délais dépassés (pas de statut HTTP) ---
        $typeName = $ex.GetType().FullName
        if ($typeName -in @(
                'System.Net.Sockets.SocketException',
                'System.IO.IOException',
                'System.Threading.Tasks.TaskCanceledException',
                'System.OperationCanceledException',
                'System.Net.Http.HttpRequestException')) {
            if (-not $reason) { $reason = "Erreur transitoire : $typeName" }
        }

        $ex = $ex.InnerException
        $depth++
    }

    # --- 4. Repli sur l'analyse du message quand rien n'a été trouvé ---
    $message = $ErrorRecord.Exception.Message
    if ($null -eq $statusCode -and $message) {
        $m = [regex]::Match($message, '\((?<code>4\d{2}|5\d{2})\)')
        if ($m.Success) { $statusCode = [int]$m.Groups['code'].Value }
    }

    $lower = if ($message) { $message.ToLowerInvariant() } else { '' }

    $throttleHints = @(
        'too many requests', 'throttl', '429',
        'temporarily unavailable', 'service unavailable',
        'server is busy', 'please retry'
    )
    $transientHints = @(
        'the operation has timed out', 'a task was canceled',
        'unable to read data from the transport connection',
        'the underlying connection was closed',
        'an existing connection was forcibly closed',
        'the remote name could not be resolved',
        'connection reset', 'timed out'
    )

    $isThrottle = $statusCode -eq 429 -or ($throttleHints | Where-Object { $lower.Contains($_) })
    $isTransient = $statusCode -in @(502, 503, 504) -or ($transientHints | Where-Object { $lower.Contains($_) })

    # 500 est ambigu chez SharePoint (souvent une vraie erreur métier) :
    # on ne retente que si le message évoque explicitement un problème transitoire.
    $is500Transient = $statusCode -eq 500 -and ($isThrottle -or $isTransient)

    $shouldRetry = [bool]($isThrottle -or $isTransient -or $is500Transient -or $reason)

    # Les erreurs définitives ne sont jamais retentées, quoi qu'en dise le message.
    if ($statusCode -in @(400, 401, 403, 404, 409, 413)) {
        # 403 accompagné d'un indice de throttling reste une limitation de débit.
        if (-not ($statusCode -eq 403 -and $isThrottle)) {
            $shouldRetry = $false
        }
    }

    if (-not $reason) {
        $reason = if ($isThrottle) { 'Throttling SharePoint Online' }
        elseif ($isTransient) { 'Erreur serveur transitoire' }
        elseif ($shouldRetry) { 'Erreur potentiellement transitoire' }
        else { 'Erreur définitive' }
    }

    [pscustomobject]@{
        ShouldRetry = $shouldRetry
        IsThrottle = [bool]$isThrottle
        # Un jeton expiré n'est pas retentable EN L'ÉTAT — rejouer le même appel
        # avec la même connexion morte échouerait pareil. Il est réparable :
        # c'est à l'appelant qui détient la connexion de la rouvrir puis de
        # rejouer. Le drapeau existe pour qu'il puisse le décider.
        IsAuthExpired = (Test-SPMAuthExpired -Message $message -StatusCode $statusCode)
        RetryAfterSeconds = $retryAfter
        StatusCode = $statusCode
        Reason = $reason
        Message = $message
    }
}

function Get-SPMRetryAfterSeconds {
    <#
    .SYNOPSIS
        Extrait la valeur Retry-After d'une collection d'en-têtes HTTP.
    #>

    [CmdletBinding()]
    [OutputType([int])]
    param([Parameter(Mandatory)]$Headers)

    try {
        $hprops = $Headers.PSObject.Properties

        # HttpResponseHeaders : propriété fortement typée RetryConditionHeaderValue
        if ($hprops['RetryAfter'] -and $null -ne $hprops['RetryAfter'].Value) {
            $ra = $hprops['RetryAfter'].Value
            $raProps = $ra.PSObject.Properties
            if ($raProps['Delta'] -and $null -ne $raProps['Delta'].Value) {
                return [int][math]::Ceiling(([timespan]$raProps['Delta'].Value).TotalSeconds)
            }
            if ($raProps['Date'] -and $null -ne $raProps['Date'].Value) {
                $delta = ([datetimeoffset]$raProps['Date'].Value) - [datetimeoffset]::UtcNow
                if ($delta.TotalSeconds -gt 0) { return [int][math]::Ceiling($delta.TotalSeconds) }
            }
        }

        # WebHeaderCollection / dictionnaire : accès par nom
        foreach ($name in 'Retry-After', 'retry-after') {
            $raw = $null
            try { $raw = $Headers[$name] } catch { Write-Debug "En-tete $name inaccessible" }
            if ($raw) {
                $value = if ($raw -is [array]) { $raw[0] } else { $raw }
                $parsed = 0
                if ([int]::TryParse([string]$value, [ref]$parsed) -and $parsed -gt 0) { return $parsed }
            }
        }
    }
    catch {
        # En-têtes illisibles ou d'un type inattendu : on retombe sur le backoff.
        Write-Debug "Retry-After illisible : $($_.Exception.Message)"
    }

    return 0
}

function Invoke-SPMWithRetry {
    <#
    .SYNOPSIS
        Exécute un bloc de script avec gestion du throttling SharePoint Online.
 
    .DESCRIPTION
        Retente automatiquement les erreurs 429 / 502 / 503 / 504 et les erreurs
        réseau transitoires. Respecte strictement l'en-tête Retry-After quand il
        est présent ; sinon applique un backoff exponentiel avec jitter afin
        d'éviter que plusieurs travailleurs ne retentent en même temps.
 
    .PARAMETER ScriptBlock
        Le bloc à exécuter. Sa sortie est retournée telle quelle.
 
    .PARAMETER Operation
        Libellé utilisé dans les journaux.
 
    .EXAMPLE
        Invoke-SPMWithRetry -Operation 'Get-PnPList' -ScriptBlock {
            Get-PnPList -Identity 'Documents' -Connection $conn
        }
    #>

    [CmdletBinding()]
    param(
        [Parameter(Mandatory, Position = 0)][scriptblock]$ScriptBlock,
        [string]$Operation = 'Appel PnP',
        [int]$MaxRetries = -1,
        [int]$BaseDelayMs = -1,
        [int]$MaxDelayMs = -1,
        [switch]$PassThruErrors
    )

    if ($MaxRetries -lt 0) { $MaxRetries = $script:SPMConfig.MaxRetries }
    if ($BaseDelayMs -lt 0) { $BaseDelayMs = $script:SPMConfig.BaseDelayMs }
    if ($MaxDelayMs -lt 0) { $MaxDelayMs = $script:SPMConfig.MaxDelayMs }

    $attempt = 0
    $script:SPMStats.Calls++

    while ($true) {
        $attempt++
        try {
            return & $ScriptBlock
        }
        catch {
            $decision = Get-SPMRetryDecision -ErrorRecord $_

            if (-not $decision.ShouldRetry -or $attempt -gt $MaxRetries) {
                $script:SPMStats.Failures++
                Write-SPMLog -Level ERROR -Operation $Operation -Message (
                    "Échec définitif après $attempt tentative(s) : $($decision.Reason) - $($decision.Message)"
                ) -Data @{
                    attempts = $attempt
                    statusCode = $decision.StatusCode
                    retryable = $decision.ShouldRetry
                }
                if ($PassThruErrors) { return $null }
                throw
            }

            # Délai : Retry-After prioritaire, sinon backoff exponentiel + jitter
            if ($decision.RetryAfterSeconds -and $decision.RetryAfterSeconds -gt 0) {
                # Le plafond MaxDelayMs borne CE QUE NOUS CALCULONS. L'appliquer
                # aussi à l'en-tête du serveur faisait rejouer avant l'heure
                # autorisée — c'est-à-dire aggraver un throttling sévère, où
                # SharePoint demande couramment plus de deux minutes.
                $dicte = [long]$decision.RetryAfterSeconds * 1000
                $delayMs = [int][math]::Min($dicte, $script:SPMConfig.MaxRetryAfterMs)
                $source = "Retry-After=$($decision.RetryAfterSeconds)s"

                if ($dicte -gt $script:SPMConfig.MaxRetryAfterMs) {
                    Write-SPMLog -Level WARNING -Operation $Operation -Message (
                        "Retry-After aberrant ($($decision.RetryAfterSeconds)s) : ramené à " +
                        "$([int]($script:SPMConfig.MaxRetryAfterMs / 1000))s."
                    )
                }
                elseif ($delayMs -gt $MaxDelayMs) {
                    Write-SPMLog -Level WARNING -Operation $Operation -Message (
                        "Le serveur impose $($decision.RetryAfterSeconds)s d'attente, au-delà du plafond " +
                        "de backoff ($([int]($MaxDelayMs / 1000))s). Le délai du serveur est respecté : " +
                        'rejouer plus tôt allongerait ses propres délais.'
                    )
                }
            }
            else {
                $exponential = $BaseDelayMs * [math]::Pow(2, $attempt - 1)
                $capped = [math]::Min($exponential, $MaxDelayMs)
                $jitter = $capped * $script:SPMConfig.JitterRatio
                $delayMs = [int]($capped - $jitter + (Get-Random -Minimum 0.0 -Maximum (2 * $jitter)))
                $delayMs = [math]::Max(100, [math]::Min($delayMs, $MaxDelayMs))
                $source = 'backoff exponentiel + jitter'
            }

            $script:SPMStats.Retries++
            if ($decision.IsThrottle) {
                $script:SPMStats.ThrottleEvents++
                $script:SPMStats.ThrottleWaitMs += $delayMs
            }

            Write-SPMLog -Level WARNING -Operation $Operation -Message (
                "Tentative $attempt/$MaxRetries échouée ($($decision.Reason)) - " +
                "nouvelle tentative dans $([math]::Round($delayMs / 1000, 1))s [$source]"
            ) -Data @{
                attempt = $attempt
                statusCode = $decision.StatusCode
                delayMs = $delayMs
                throttled = $decision.IsThrottle
            }

            Start-Sleep -Milliseconds $delayMs
        }
    }
}

# ============================================================================
# Pool de connexions
# ============================================================================

function ConvertTo-SPMConnectionKey {
    [CmdletBinding()]
    param([Parameter(Mandatory)][string]$Url)
    $Url.TrimEnd('/').ToLowerInvariant()
}

function Connect-SPMSite {
    <#
    .SYNOPSIS
        Ouvre (ou réutilise) une connexion PnP vers un site et la retourne.
 
    .DESCRIPTION
        Remplace le cycle Disconnect/Remove-Module/Import-Module/Connect qui
        provoquait une réauthentification MFA à chaque changement de site.
        La connexion retournée doit être passée aux cmdlets PnP via -Connection.
 
        Modes pris en charge :
          -Interactive login navigateur (MFA)
          -DeviceLogin login par code (poste sans navigateur)
          -Thumbprint application seule, certificat du magasin Windows
          -CertificatePath application seule, certificat sur disque (.pfx)
          -ManagedIdentity identité managée Azure (Automation, Functions)
 
    .EXAMPLE
        $src = Connect-SPMSite -Url $srcUrl -ClientId $id -Interactive
        $dst = Connect-SPMSite -Url $dstUrl -ClientId $id -Interactive
        Get-PnPList -Connection $src
    #>

    [CmdletBinding(DefaultParameterSetName = 'Interactive')]
    [OutputType([object])]
    param(
        [Parameter(Mandatory, Position = 0)][string]$Url,

        [Parameter(ParameterSetName = 'Interactive')]
        [Parameter(ParameterSetName = 'DeviceLogin')]
        [Parameter(ParameterSetName = 'Certificate', Mandatory)]
        [Parameter(ParameterSetName = 'CertificateFile', Mandatory)]
        [string]$ClientId,

        [Parameter(ParameterSetName = 'Certificate', Mandatory)]
        [Parameter(ParameterSetName = 'CertificateFile', Mandatory)]
        [string]$Tenant,

        [Parameter(ParameterSetName = 'Interactive')][switch]$Interactive,
        [Parameter(ParameterSetName = 'DeviceLogin', Mandatory)][switch]$DeviceLogin,
        [Parameter(ParameterSetName = 'Certificate', Mandatory)][string]$Thumbprint,
        [Parameter(ParameterSetName = 'CertificateFile', Mandatory)][string]$CertificatePath,
        [Parameter(ParameterSetName = 'CertificateFile')][securestring]$CertificatePassword,
        [Parameter(ParameterSetName = 'ManagedIdentity', Mandatory)][switch]$ManagedIdentity,

        [switch]$PersistLogin,
        [switch]$Force,
        [switch]$Validate
    )

    $key = ConvertTo-SPMConnectionKey -Url $Url

    if (-not $Force -and $script:SPMConnections.ContainsKey($key)) {
        $existing = $script:SPMConnections[$key]
        if (-not $Validate) {
            Write-SPMLog -Level DEBUG -Operation 'Connect' -Message "Connexion réutilisée : $Url"
            return $existing
        }
        try {
            Invoke-SPMWithRetry -Operation 'Connect/Validate' -MaxRetries 1 -ScriptBlock {
                Get-PnPWeb -Connection $existing -ErrorAction Stop
            } | Out-Null
            Write-SPMLog -Level DEBUG -Operation 'Connect' -Message "Connexion réutilisée et validée : $Url"
            return $existing
        }
        catch {
            Write-SPMLog -Level WARNING -Operation 'Connect' -Message "Connexion expirée pour $Url, réouverture"
            $script:SPMConnections.Remove($key) | Out-Null
        }
    }

    $params = @{ Url = $Url; ReturnConnection = $true; ErrorAction = 'Stop' }

    switch ($PSCmdlet.ParameterSetName) {
        'Interactive' {
            if (-not $ClientId) {
                throw "ClientId est obligatoire pour l'authentification interactive depuis PnP.PowerShell 2.x " +
                "(l'application multi-tenant par défaut a été retirée). " +
                "Enregistrez une application Entra ID et fournissez son ClientId."
            }
            $params.Interactive = $true
            $params.ClientId = $ClientId
            if ($Tenant) { $params.Tenant = $Tenant }
            if ($PersistLogin) { $params.PersistLogin = $true }
        }
        'DeviceLogin' {
            $params.DeviceLogin = $true
            if ($ClientId) { $params.ClientId = $ClientId }
            if ($Tenant) { $params.Tenant = $Tenant }
            if ($PersistLogin) { $params.PersistLogin = $true }
        }
        'Certificate' {
            $params.ClientId = $ClientId
            $params.Tenant = $Tenant
            $params.Thumbprint = $Thumbprint
        }
        'CertificateFile' {
            $params.ClientId = $ClientId
            $params.Tenant = $Tenant
            $params.CertificatePath = $CertificatePath
            if ($CertificatePassword) { $params.CertificatePassword = $CertificatePassword }
        }
        'ManagedIdentity' {
            $params.ManagedIdentity = $true
        }
    }

    Write-SPMLog -Level INFO -Operation 'Connect' -Message (
        "Ouverture d'une connexion ($($PSCmdlet.ParameterSetName)) vers $Url"
    )

    $connection = Invoke-SPMWithRetry -Operation 'Connect-PnPOnline' -ScriptBlock {
        Connect-PnPOnline @params
    }

    $script:SPMConnections[$key] = $connection
    Write-SPMLog -Level SUCCESS -Operation 'Connect' -Message "Connexion établie : $Url"

    return $connection
}

function Get-SPMSiteConnection {
    <#
    .SYNOPSIS
        Retourne la connexion en pool pour une URL, ou $null si absente.
    #>

    [CmdletBinding()]
    param([Parameter(Mandatory)][string]$Url)

    $key = ConvertTo-SPMConnectionKey -Url $Url
    if ($script:SPMConnections.ContainsKey($key)) { return $script:SPMConnections[$key] }
    return $null
}

function Disconnect-SPMAllSites {
    <#
    .SYNOPSIS
        Ferme et vide le pool de connexions. À appeler une seule fois, en fin de traitement.
    #>

    [CmdletBinding()]
    param([switch]$ClearPersistedLogin)

    $count = $script:SPMConnections.Count
    $script:SPMConnections.Clear()

    try {
        if ($ClearPersistedLogin) {
            Disconnect-PnPOnline -ClearPersistedLogin -ErrorAction SilentlyContinue
        }
        else {
            Disconnect-PnPOnline -ErrorAction SilentlyContinue
        }
    }
    catch {
        # Deconnexion best-effort : une session deja fermee n'est pas une erreur.
        Write-Debug "Disconnect-PnPOnline : $($_.Exception.Message)"
    }

    Write-SPMLog -Level INFO -Operation 'Disconnect' -Message "$count connexion(s) fermée(s)"
}

# ============================================================================
# Accès aux éléments : requêtes ciblées au lieu d'énumérations complètes
# ============================================================================

function Get-SPMListItemByPath {
    <#
    .SYNOPSIS
        Retrouve un élément de liste par son nom de fichier/dossier via CAML.
 
    .DESCRIPTION
        Remplace le motif catastrophique en O(n) :
 
            Get-PnPListItem -List $lib | Where-Object { $_.FieldValues['Title'] -eq $name }
 
        qui télécharge l'intégralité de la bibliothèque côté client. Ici, le
        filtrage est fait par le serveur sur les champs indexés FileLeafRef et
        FileDirRef : une seule ligne transite.
 
    .PARAMETER LeafName
        Nom du fichier ou du dossier (FileLeafRef), sans le chemin.
 
    .PARAMETER FolderServerRelativeUrl
        Chemin serveur-relatif du dossier parent (FileDirRef), par ex.
        /sites/MonSite/Documents/General. Optionnel mais fortement recommandé.
 
    .EXAMPLE
        Get-SPMListItemByPath -List 'Documents' -LeafName 'Innovation' `
            -FolderServerRelativeUrl '/sites/DPT/Documents/General' -Connection $conn
    #>

    [CmdletBinding()]
    [OutputType([object])]
    param(
        [Parameter(Mandatory)][string]$List,
        [Parameter(Mandatory)][string]$LeafName,
        [string]$FolderServerRelativeUrl,
        [string[]]$Fields,
        $Connection,
        [int]$RowLimit = 2,

        # Rend le PREMIER homonyme au lieu de refuser. C'était le comportement
        # par défaut, et il est dangereux là où le résultat sert à écrire :
        # sur dix « Rapport.docx », neuf fois sur dix ce n'était pas le bon.
        [switch]$AllowAmbiguous
    )

    $safeLeaf = [System.Security.SecurityElement]::Escape($LeafName)
    $where = "<Eq><FieldRef Name='FileLeafRef'/><Value Type='Text'>$safeLeaf</Value></Eq>"

    if ($FolderServerRelativeUrl) {
        $safeDir = [System.Security.SecurityElement]::Escape($FolderServerRelativeUrl.TrimEnd('/'))
        $where = "<And>$where<Eq><FieldRef Name='FileDirRef'/><Value Type='Text'>$safeDir</Value></Eq></And>"
    }

    $viewFields = ''
    if ($Fields) {
        $refs = ($Fields | ForEach-Object { "<FieldRef Name='$([System.Security.SecurityElement]::Escape($_))'/>" }) -join ''
        $viewFields = "<ViewFields>$refs</ViewFields>"
    }

    $caml = "<View Scope='RecursiveAll'>$viewFields<Query><Where>$where</Where></Query><RowLimit>$RowLimit</RowLimit></View>"

    $splat = @{ List = $List; Query = $caml; ErrorAction = 'Stop' }
    if ($Connection) { $splat.Connection = $Connection }

    $items = Invoke-SPMWithRetry -Operation "Get-SPMListItemByPath/$List" -ScriptBlock {
        Get-PnPListItem @splat
    }

    if (-not $items) { return $null }
    if ($items -is [array]) {
        if ($items.Count -gt 1) {
            # La requête est en Scope='RecursiveAll' : sans dossier parent, le
            # même nom de feuille se retrouve à tous les étages de la
            # bibliothèque. Retourner « le premier » revient à désigner un
            # élément au hasard — acceptable pour une lecture, jamais pour une
            # écriture de permissions.
            if (-not $AllowAmbiguous) {
                Write-SPMLog -Level ERROR -Operation 'Get-SPMListItemByPath' -Message (
                    "$($items.Count) éléments portent le nom '$LeafName' dans '$List' : refus de choisir. " +
                    'Précisez -FolderServerRelativeUrl.'
                )
                return $null
            }
            Write-SPMLog -Level WARNING -Operation 'Get-SPMListItemByPath' -Message (
                "$($items.Count) éléments portent le nom '$LeafName' dans '$List' — le premier est retourné (-AllowAmbiguous)"
            )
        }
        return $items[0]
    }
    return $items
}

function Test-SPMPathExists {
    <#
    .SYNOPSIS
        Teste l'existence d'un dossier serveur-relatif sans lever d'exception.
    #>

    [CmdletBinding()]
    [OutputType([bool])]
    param(
        [Parameter(Mandatory)][string]$ServerRelativeUrl,
        $Connection
    )

    $splat = @{ Url = $ServerRelativeUrl; ErrorAction = 'SilentlyContinue' }
    if ($Connection) { $splat.Connection = $Connection }

    try {
        $folder = Invoke-SPMWithRetry -Operation 'Test-SPMPathExists' -MaxRetries 2 -ScriptBlock {
            Get-PnPFolder @splat
        }
        return [bool]$folder
    }
    catch {
        return $false
    }
}

# ============================================================================
# Vérification d'environnement
# ============================================================================

function Test-SPMPrerequisite {
    <#
    .SYNOPSIS
        Vérifie que l'environnement satisfait les prérequis PnP.PowerShell 3.x.
 
    .DESCRIPTION
        PnP.PowerShell 3.x exige PowerShell 7.4.6+ (édition Core). Un lancement
        sous Windows PowerShell 5.1 échoue avec un message peu explicite : ce
        contrôle transforme cet échec en diagnostic clair.
    #>

    [CmdletBinding()]
    [OutputType([pscustomobject])]
    param([switch]$ThrowOnFailure)

    $problems = [System.Collections.Generic.List[string]]::new()
    $warnings = [System.Collections.Generic.List[string]]::new()

    if ($PSVersionTable.PSEdition -ne 'Core') {
        $problems.Add("Édition PowerShell '$($PSVersionTable.PSEdition)' : PnP.PowerShell 3.x exige l'édition Core (PowerShell 7).")
    }
    if ($PSVersionTable.PSVersion -lt [version]'7.4.6') {
        $problems.Add("PowerShell $($PSVersionTable.PSVersion) détecté : PnP.PowerShell 3.x exige 7.4.6 ou supérieur.")
    }

    $pnp = Get-Module PnP.PowerShell | Select-Object -First 1
    if (-not $pnp) {
        $pnp = Get-Module PnP.PowerShell -ListAvailable |
            Sort-Object Version -Descending | Select-Object -First 1
    }

    if (-not $pnp) {
        $problems.Add("Module PnP.PowerShell introuvable. Installez-le : Install-Module PnP.PowerShell -Scope CurrentUser")
    }
    elseif ($pnp.Version.Major -lt 2) {
        $problems.Add("PnP.PowerShell $($pnp.Version) est trop ancien. Version 3.x recommandée.")
    }

    $installed = @(Get-Module PnP.PowerShell -ListAvailable | Select-Object -ExpandProperty Version -Unique)
    if ($installed.Count -gt 1) {
        $warnings.Add("Plusieurs versions de PnP.PowerShell installées ($($installed -join ', ')). " +
            "Épinglez la version voulue : Import-Module PnP.PowerShell -RequiredVersion $(($installed | Sort-Object -Descending)[0])")
    }

    # Les scripts historiques positionnent des variables de télémétrie obsolètes.
    # PnP 3.x lit PNP_DISABLETELEMETRY ; PNPPOWERSHELL_TELEMETRY_DISABLED est ignorée.
    if ($env:PNPPOWERSHELL_TELEMETRY_DISABLED -and -not $env:PNP_DISABLETELEMETRY) {
        $warnings.Add("PNPPOWERSHELL_TELEMETRY_DISABLED n'est plus lue par PnP 3.x. Utilisez PNP_DISABLETELEMETRY.")
    }

    $result = [pscustomobject]@{
        Ok = ($problems.Count -eq 0)
        PSVersion = $PSVersionTable.PSVersion
        PSEdition = $PSVersionTable.PSEdition
        PnPVersion = if ($pnp) { $pnp.Version } else { $null }
        PnPAvailable = @($installed)
        Problems = @($problems)
        Warnings = @($warnings)
    }

    if ($ThrowOnFailure -and -not $result.Ok) {
        throw "Prérequis non satisfaits :`n - " + ($problems -join "`n - ")
    }

    $result
}