Public/Invoke-SPMigration.ps1

function Invoke-SPMigration {
    <#
    .SYNOPSIS
        Exécute un plan de migration.
 
    .DESCRIPTION
        Moteur de migration reposant sur les travaux de copie serveur-à-serveur
        de SharePoint Online (Copy-PnPFolder / Copy-PnPFile), et non plus sur un
        cycle download vers disque temporaire puis upload.
 
        Apports par rapport au moteur précédent :
          * historique des versions, dates et auteurs préservés par le service
          * le contenu ne transite plus par le poste d'exécution
          * état persistant : -Resume reprend là où l'exécution précédente s'est arrêtée
          * -Incremental ne re-migre que ce qui a changé (taille + date, pas seulement l'existence)
          * throttling 429/503 absorbé par le socle de résilience
          * -WhatIf simule intégralement sans aucune écriture
 
    .PARAMETER Plan
        Plan produit par New-SPMigrationPlan.
 
    .PARAMETER StatePath
        Fichier d'état NDJSON. Par défaut : .\Logs\<nom-du-plan>.state.ndjson
 
    .PARAMETER Resume
        Reprend une exécution : les unités déjà terminées sont ignorées.
 
    .PARAMETER Incremental
        Ne migre que les unités dont la signature source a changé.
        Surcharge options.incremental du plan.
 
    .PARAMETER SkipPreflight
        Saute la validation pré-vol. Déconseillé : à réserver aux relances.
 
    .EXAMPLE
        $plan = New-SPMigrationPlan -Path .\plan.json
        Invoke-SPMigration -Plan $plan -WhatIf
 
    .EXAMPLE
        Invoke-SPMigration -Plan $plan -Resume
 
    .OUTPUTS
        Objet de synthèse { Succeeded, Failed, Skipped, Duration, StatePath, Findings }.
    #>

    [CmdletBinding(SupportsShouldProcess, ConfirmImpact = 'High')]
    [OutputType([pscustomobject])]
    param(
        [Parameter(Mandatory, ValueFromPipeline)]
        [pscustomobject]$Plan,

        [string]$StatePath,

        [switch]$Resume,

        [switch]$Incremental,

        [switch]$SkipPreflight,

        # Table de correspondance d'identités, utilisée par le report des
        # permissions quand les principaux diffèrent entre les deux tenants.
        [string]$IdentityMapPath,

        # Fait mesurer au pré-vol ce que le tenant autorise — copie serveur
        # comprise — au lieu de le découvrir sur la première unité. Écrit un
        # dossier temporaire à destination, supprimé ensuite.
        [switch]$ProbeCapability
    )

    process {
        $started = Get-Date

        # ------------------------------------------------------- configuration
        Set-SPMConfiguration `
            -MaxRetries $Plan.Throttle.maxRetries `
            -BaseDelayMs $Plan.Throttle.baseDelayMs `
            -MaxDelayMs $Plan.Throttle.maxDelayMs

        if (-not $StatePath) {
            $safeName = ($Plan.Name -replace '[^\w\-]', '_')
            $StatePath = Join-Path (Join-Path (Get-Location) 'Logs') "$safeName.state.ndjson"
        }

        # ------------------------------------------------------- journalisation
        # Une migration qui ne laisse pas de trace sur disque n'est pas
        # auditable : quand le resultat surprend, il ne reste que la memoire de
        # l'operateur. Le fichier d'etat dit CE QUI a ete transfere ; il ne dit
        # rien de la metadonnee, des replis ni des champs qui ont resiste.
        # On ouvre donc un journal a cote de l'etat, sauf si l'appelant en a
        # deja configure un.
        $config = Get-SPMConfiguration
        if (-not $config.LogPath -and -not $config.JsonLogPath) {
            $logDir = Split-Path -Parent $StatePath
            if ($logDir -and -not (Test-Path -LiteralPath $logDir)) {
                New-Item -ItemType Directory -Path $logDir -Force | Out-Null
            }
            $base = [System.IO.Path]::GetFileNameWithoutExtension($StatePath) -replace '\.state$', ''
            $stamp = Get-Date -Format 'yyyyMMdd_HHmmss'
            Set-SPMConfiguration `
                -LogPath (Join-Path $logDir "$base`_$stamp.log") `
                -JsonLogPath (Join-Path $logDir "$base`_$stamp.ndjson")
        }

        $stateInfo = Initialize-SPMState -Path $StatePath -Fresh:(-not $Resume)
        $journal = (Get-SPMConfiguration).LogPath
        Write-SPMLog -Level INFO -Operation 'Migration' -Message (
            "Démarrage de '$($Plan.Name)' — état : $StatePath ($($stateInfo.Completed)/$($stateInfo.Units) déjà terminées)" +
            $(if ($journal) { " — journal : $journal" })
        )
        # Le chemin du journal s'affiche aussi à l'écran : quand un appelant en a
        # déjà configuré un, le nôtre n'est pas là où on le cherche.
        if ($journal) { Write-Host " Journal : $journal" -ForegroundColor DarkGray }

        $useIncremental = $Incremental -or [bool]$Plan.Options.incremental

        # Chemins écartés par le pré-vol et non transférés. Renseigné seulement
        # en mode quarantaine ; comparé en minuscules, comme les clés d'état.
        $quarantaine = [System.Collections.Generic.HashSet[string]]::new()

        # ------------------------------------------------------------ pré-vol
        $preflight = $null
        if (-not $SkipPreflight) {
            $preflight = Test-SPMigrationPlan -Plan $Plan -Online -ProbeCapability:$ProbeCapability

            if ($preflight.PSObject.Properties['Capability'] -and $preflight.Capability) {
                Write-SPMLog -Level INFO -Operation 'Migration' -Message (
                    "Capacité mesurée du tenant : transfert — $($preflight.Capability.RecommendedTransfer) ; " +
                    "schéma — $($preflight.Capability.RecommendedSchemaMethod)"
                )
            }
            if (-not $preflight.Ok) {
                # QUARANTAINE. Sur des dizaines de milliers de fichiers, il y
                # aura toujours un « ~$brouillon.docx ». Tout arrêter pour lui,
                # ou passer -SkipPreflight — donc désactiver AUSSI le contrôle
                # des 400 caractères —, sont deux mauvaises réponses.
                $bloquants = @($preflight.Findings | Where-Object Severity -eq 'Error')
                $surElement = @($bloquants | Where-Object { $_.PSObject.Properties['Path'] -and $_.Path })
                $surLePlan = @($bloquants | Where-Object { -not ($_.PSObject.Properties['Path'] -and $_.Path) })

                if ("$($Plan.Validation.onError)" -eq 'exclude' -and -not $surLePlan.Count) {
                    foreach ($f in $surElement) { [void]$quarantaine.Add("$($f.Path)".ToLowerInvariant()) }

                    Write-SPMLog -Level WARNING -Operation 'Migration' -Message (
                        "$($quarantaine.Count) élément(s) mis en QUARANTAINE (validation.onError = 'exclude') : " +
                        'ils sont écartés du lot, listés dans un fichier dédié, et la migration continue.'
                    )
                }
                else {
                    # Une erreur qui ne porte sur aucun élément — source vide,
                    # copie serveur refusée, étiquettes de rétention — ne
                    # s'écarte pas : elle condamne le lot entier. L'exclure
                    # reviendrait à migrer en ignorant le motif de l'arrêt.
                    $motif = if ($surLePlan.Count) {
                        "$($surLePlan.Count) erreur(s) portent sur le plan lui-même, pas sur un élément : la quarantaine ne s'y applique pas"
                    }
                    else { "$($preflight.ErrorCount) erreur(s) bloquante(s)" }

                    Write-SPMLog -Level ERROR -Operation 'Migration' -Message "Pré-vol en échec : $motif. Migration annulée."
                    return [pscustomobject]@{
                        Succeeded = 0; Failed = 0; Skipped = 0
                        Aborted = $true
                        Reason = "Pré-vol en échec — $motif"
                        Duration = (Get-Date) - $started
                        StatePath = $StatePath
                        Findings = $preflight.Findings
                    }
                }
            }
        }
        else {
            Write-SPMLog -Level WARNING -Operation 'Migration' -Message 'Pré-vol ignoré (-SkipPreflight) : des échecs en cours de route sont possibles.'
        }

        # ------------------------------------------------- construction du lot
        $units = Build-SPMWorkUnit -Plan $Plan
        Write-SPMLog -Level INFO -Operation 'Migration' -Message "$($units.Count) unité(s) racine"

        $succeeded = 0
        $submitted = 0
        $failed = 0
        $skipped = 0
        $simulated = 0
        $fallbackUsed = 0
        $reconnects = 0
        $metadataApplied = 0
        $metadataFailed = 0
        $labelsApplied = 0
        $recordsLost = 0
        $complianceProblems = 0

        # Deux connexions, deux rôles distincts :
        # * la SOURCE soumet le travail de copie — CreateCopyJobs s'invoque sur
        # le site qui détient le contenu à exporter ;
        # * la DESTINATION prépare la cible (création des dossiers).
        # N'ouvrir que la destination, comme le faisait la version précédente,
        # revenait à demander au site cible d'exporter un contenu qu'il n'a pas.
        $destConnection = $null
        $srcConnection = $null
        if ($PSCmdlet.ShouldProcess($Plan.Destination.Url, 'Se connecter aux deux sites')) {
            $destConnection = Get-SPMPlanConnection -Plan $Plan -Which Destination
            if ($Plan.Source.Type -eq 'sharepoint') {
                $srcConnection = Get-SPMPlanConnection -Plan $Plan -Which Source
            }
        }

        # Un téléversement depuis un partage réseau part forcément de la
        # destination : il n'y a pas de site source à interroger.
        $submitConnection = $srcConnection ?? $destConnection

        # ------------------------------------------------- éclatement en unités
        # Sans éclatement, tout le site tient dans UNE unité : une seule clé
        # d'état, donc aucune reprise partielle et aucun avancement visible.
        if ($srcConnection -and "$($Plan.Options.granularity)" -eq 'item') {
            $expanded = [System.Collections.Generic.List[object]]::new()
            foreach ($unit in $units) {
                foreach ($u in (Expand-SPMWorkUnit -Unit $unit -Connection $srcConnection)) {
                    $expanded.Add($u)
                }
            }
            if ($expanded.Count) { $units = $expanded.ToArray() }

            $echecs = @(Get-SPMEnumerationFailure)
            if ($echecs.Count) {
                Write-SPMLog -Level ERROR -Operation 'Migration' -Message (
                    "$($echecs.Count) dossier(s) illisible(s) : leur contenu ne sera PAS migré. " +
                    'Traitez-les avant de considérer la migration comme complète.'
                )
            }
        }

        # ------------------------------------------------------- quarantaine
        # L'exclusion a lieu APRÈS l'éclatement en unités : le pré-vol désigne
        # des éléments, pas des racines. Écarter avant reviendrait à perdre
        # tout le sous-arbre d'un dossier fautif.
        $ecartes = 0
        if ($quarantaine.Count) {
            $retenues = [System.Collections.Generic.List[object]]::new()
            $exclues = [System.Collections.Generic.List[object]]::new()

            foreach ($unit in $units) {
                if ($quarantaine.Contains("$($unit.Source)".ToLowerInvariant())) { $exclues.Add($unit) }
                else { $retenues.Add($unit) }
            }

            $units = $retenues.ToArray()
            $ecartes = $exclues.Count

            $fichierQuarantaine = [System.IO.Path]::ChangeExtension($StatePath, $null) + 'quarantine.ndjson'
            $dir = Split-Path -Parent $fichierQuarantaine
            if ($dir -and -not (Test-Path -LiteralPath $dir)) { New-Item -ItemType Directory -Path $dir -Force | Out-Null }

            # Un fichier, une ligne par élément écarté, avec le motif. Un
            # compteur seul ne permettrait pas de reprendre le travail.
            $lignes = foreach ($u in $exclues) {
                $motifs = @($preflight.Findings |
                        Where-Object { $_.Severity -eq 'Error' -and "$($_.Path)".ToLowerInvariant() -eq "$($u.Source)".ToLowerInvariant() } |
                        ForEach-Object { "$($_.Rule) : $($_.Message)" })
                $ligne = [ordered]@{
                    ts = (Get-Date).ToString('o')
                    source = $u.Source
                    target = $u.Target
                    kind = "$($u.Kind)"
                    reasons = $motifs
                }
                $ligne | ConvertTo-Json -Compress -Depth 4
            }
            Set-Content -LiteralPath $fichierQuarantaine -Value $lignes -Encoding utf8

            Write-SPMLog -Level WARNING -Operation 'Migration' -Message (
                "$ecartes unité(s) écartée(s) et NON transférée(s) : $fichierQuarantaine"
            )
        }

        Write-SPMLog -Level INFO -Operation 'Migration' -Message "$($units.Count) unité(s) de travail à traiter"

        # Ce qui a été transféré, pour la passe de métadonnée. On n'y remet que
        # les unités réellement traitées : réappliquer sur une unité ignorée
        # ferait mentir le bilan.
        $migrees = [System.Collections.Generic.List[object]]::new()

        # ------------------------------------------------------------ exécution
        # Une migration dure des heures. Sans avancement, l'opérateur ne peut
        # pas distinguer un travail lent d'un travail bloqué, et la seule
        # information dont il dispose arrive à la fin.
        $traitees = 0
        $debut = Get-Date

        foreach ($unit in $units) {
            $traitees++
            if ($units.Count -gt 1) {
                $ecoule = (Get-Date) - $debut
                $reste = if ($traitees -gt 1 -and $ecoule.TotalSeconds -gt 0) {
                    $parUnite = $ecoule.TotalSeconds / ($traitees - 1)
                    [timespan]::FromSeconds([math]::Round($parUnite * ($units.Count - $traitees)))
                }
                else { $null }

                Write-Progress -Id 1 -Activity "Migration « $($Plan.Name) »" `
                    -Status "$traitees / $($units.Count) — $($unit.Kind) $(Split-Path -Leaf $unit.Source)" `
                    -PercentComplete ([math]::Min(100, [int](100 * $traitees / $units.Count))) `
                    -SecondsRemaining $(if ($reste) { [int]$reste.TotalSeconds } else { -1 })
            }

            $key = Get-SPMStateKey -SourceUrl $unit.Source -TargetUrl $unit.Target
            $signature = if ($useIncremental) { $unit.Signature } else { $null }

            if (Test-SPMUnitCompleted -Key $key -SourceSignature $signature) {
                $skipped++
                Write-SPMLog -Level DEBUG -Operation 'Migration' -Message "Déjà à jour, ignorée : $($unit.Source)"
                continue
            }

            # ------------------------------------------------- idempotence
            # L'état persistant ne suffit pas : une exécution sans -Resume le
            # repart à neuf, et le contenu se retrouve copié une seconde fois.
            # Mesuré : 63 éléments à la source, 180 à destination après trois
            # exécutions. On interroge donc la DESTINATION, seule source de
            # vérité sur ce qui s'y trouve déjà.
            if ($destConnection -and -not $Plan.Options.overwrite) {
                $cible = if ($unit.Kind -eq 'File') {
                    "$($unit.Target)/$(Split-Path -Leaf $unit.Source)"
                }
                else { $unit.Target }

                if (Test-SPMPathExists -ServerRelativeUrl $cible -Connection $destConnection) {
                    $skipped++
                    $migrees.Add($unit)   # présent à destination : sa métadonnée reste à poser
                    Write-SPMStateRecord -Key $key -Status Skipped -SourceUrl $unit.Source -TargetUrl $unit.Target
                    Write-SPMLog -Level DEBUG -Operation 'Migration' -Message "Déjà présent à destination : $cible"
                    continue
                }
            }

            Write-SPMStateRecord -Key $key -Status Running -SourceUrl $unit.Source -TargetUrl $unit.Target

            # ---------------------------------------- dossier en granularité item
            # Un travail de copie sur un DOSSIER emporte tout son sous-arbre :
            # le service est récursif par nature. Comme la granularité « item »
            # émet aussi une unité par fichier, chaque fichier était copié deux
            # fois — d'où 180 éléments à destination pour 63 à la source.
            #
            # En granularité « item », un dossier se contente donc d'exister.
            # Ses fichiers sont copiés un à un, ce qui préserve la reprise
            # partielle et l'avancement, sans doubler quoi que ce soit.
            if ($unit.Kind -eq 'Folder' -and "$($Plan.Options.granularity)" -eq 'item' -and $destConnection) {
                try {
                    if ($PSCmdlet.ShouldProcess($unit.Target, 'Créer le dossier')) {
                        Resolve-SPMTargetFolder -ServerRelativeUrl $unit.Target -Connection $destConnection
                        $succeeded++
                        $migrees.Add($unit)
                        Write-SPMStateRecord -Key $key -Status Completed -SourceUrl $unit.Source `
                            -TargetUrl $unit.Target -SourceSignature $unit.Signature
                    }
                    else { $simulated++ }
                }
                catch {
                    $failed++
                    Write-SPMStateRecord -Key $key -Status Failed -SourceUrl $unit.Source `
                        -TargetUrl $unit.Target -Error ($_.Exception.Message -split [char]10)[0]
                }
                continue
            }

            $result = Invoke-SPMCopyJob `
                -SourceUrl $unit.SourceUrl `
                -LocalPath $unit.LocalPath `
                -TargetUrl $unit.Target `
                -Kind $unit.Kind `
                -PreserveVersions ([bool]$Plan.Options.preserveVersions) `
                -Overwrite:([bool]$Plan.Options.overwrite) `
                -Recurse:([bool]$Plan.Options.recurse) `
                -AllowSchemaMismatch:([bool]$Plan.Options.allowSchemaMismatch) `
                -NoWait:([bool]$Plan.Options.async) `
                -Connection $submitConnection `
                -DestinationConnection $destConnection

            if ($result.Simulated) {
                $simulated++
                continue
            }

            # ---------------------------------------------- repli niveau 2
            # La bascule est décidée PAR UNITÉ. Un drapeau global condamnerait
            # toute la suite de l'exécution sur un échec isolé — un fichier
            # verrouillé suffirait à faire perdre l'historique de tous les autres.
            if (-not $result.Success -and $Plan.Options.allowLocalFallback -and
                $unit.Kind -eq 'File' -and $srcConnection) {

                Write-SPMLog -Level WARNING -Operation 'Transfer' -Message (
                    "Copie serveur refusée sur $($unit.Source), repli local — " +
                    'HISTORIQUE DES VERSIONS PERDU pour cet élément.'
                )

                $repli = Invoke-SPMLocalTransfer -SourceUrl $unit.SourceUrl `
                    -TargetFolderServerRelative $unit.Target `
                    -SourceConnection $srcConnection -DestinationConnection $destConnection

                if ($repli.Success) {
                    $result = [pscustomobject]@{
                        Success = $true; Simulated = $false; JobId = $null
                        Duration = $result.Duration; Source = $result.Source
                        Target = $result.Target; Error = $null
                        Mechanism = 'local'; VersionsLost = $true
                    }
                    $fallbackUsed++
                }
            }

            # ------------------------------------------- jeton expiré, rejeu
            # Une migration dure des heures, un jeton interactif environ une.
            # Sans cette reprise, l'expiration faisait échouer DÉFINITIVEMENT
            # toutes les unités restantes : des milliers d'échecs pour une
            # connexion à rouvrir. Le plan porte les paramètres d'authen-
            # tification ; la connexion en pool, elle, ne les porte pas — c'est
            # donc ici, et pas dans Invoke-SPMWithRetry, que la réparation a lieu.
            if (-not $result.Success -and (Test-SPMAuthExpired -Message $result.Error)) {
                Write-SPMLog -Level WARNING -Operation 'Migration' -Message (
                    "Authentification expirée sur $($unit.Source) : réouverture des connexions puis rejeu."
                )
                try {
                    $destConnection = Get-SPMPlanConnection -Plan $Plan -Which Destination -Validate
                    if ($Plan.Source.Type -eq 'sharepoint') {
                        $srcConnection = Get-SPMPlanConnection -Plan $Plan -Which Source -Validate
                    }
                    $submitConnection = $srcConnection ?? $destConnection
                    $reconnects++

                    $result = Invoke-SPMCopyJob `
                        -SourceUrl $unit.SourceUrl `
                        -LocalPath $unit.LocalPath `
                        -TargetUrl $unit.Target `
                        -Kind $unit.Kind `
                        -PreserveVersions ([bool]$Plan.Options.preserveVersions) `
                        -Overwrite:([bool]$Plan.Options.overwrite) `
                        -Recurse:([bool]$Plan.Options.recurse) `
                        -AllowSchemaMismatch:([bool]$Plan.Options.allowSchemaMismatch) `
                        -NoWait:([bool]$Plan.Options.async) `
                        -Connection $submitConnection `
                        -DestinationConnection $destConnection
                }
                catch {
                    Write-SPMLog -Level ERROR -Operation 'Migration' -Message (
                        "Réouverture impossible : $(($_.Exception.Message -split [char]10)[0]). " +
                        'Les unités suivantes échoueront de la même façon.'
                    )
                }
            }

            if ($result.Success) {
                $migrees.Add($unit)
                # Une soumission asynchrone n'est pas une réussite : le service
                # a accepté le travail, il ne l'a pas encore exécuté. L'écrire
                # « Completed » produisait un rapport à 100 % de réussite sur une
                # destination restée vide.
                if ($Plan.Options.async) {
                    $submitted++
                    Write-SPMStateRecord -Key $key -Status Submitted -SourceUrl $unit.Source -TargetUrl $unit.Target `
                        -JobId $result.JobId `
                        -Data @{ durationMs = [int]$result.Duration.TotalMilliseconds }
                }
                else {
                    $succeeded++
                    Write-SPMStateRecord -Key $key -Status Completed -SourceUrl $unit.Source -TargetUrl $unit.Target `
                        -SourceSignature $unit.Signature -JobId $result.JobId `
                        -Data @{ durationMs = [int]$result.Duration.TotalMilliseconds }
                }
            }
            else {
                $failed++
                Write-SPMStateRecord -Key $key -Status Failed -SourceUrl $unit.Source -TargetUrl $unit.Target `
                    -Error $result.Error
            }
        }

        Write-Progress -Id 1 -Activity "Migration « $($Plan.Name) »" -Completed

        # ------------------------------------------- passe de métadonnée
        # EN ORDRE INVERSE, et c'est tout l'intérêt : déposer un fichier dans un
        # dossier met à jour le Modified et le « Modifié par » de ce dossier.
        # L'énumération rend les parents avant leurs enfants ; les parcourir à
        # l'envers garantit donc qu'un dossier est estampillé APRÈS son contenu.
        if ($Plan.Options.applyMetadata -and $srcConnection -and $destConnection -and $migrees.Count) {
            Write-SPMLog -Level INFO -Operation 'Metadata' -Message (
                "Application de la métadonnée sur $($migrees.Count) élément(s), du plus profond au plus haut"
            )
            Clear-SPMFieldCache

            $libraryTitle = Get-SPMListTitleForPath -ServerRelativeUrl $units[0].Target -Connection $destConnection

            if (-not $libraryTitle) {
                Write-SPMLog -Level WARNING -Operation 'Metadata' -Message (
                    "Bibliothèque de destination non identifiée pour $($units[0].Target) : métadonnée non appliquée."
                )
            }
            else {
                for ($i = $migrees.Count - 1; $i -ge 0; $i--) {
                    $unit = $migrees[$i]
                    Write-Progress -Id 2 -Activity 'Application de la métadonnée' `
                        -Status "$($migrees.Count - $i) / $($migrees.Count) — $($unit.Relative)" `
                        -PercentComplete ([math]::Min(100, [int](100 * ($migrees.Count - $i) / $migrees.Count)))
                    try {
                        $srcItem = Get-SPMItemForPath -ServerRelativeUrl $unit.Source -Kind $unit.Kind `
                            -Connection $srcConnection
                        $dstPath = if ($unit.Kind -eq 'File') {
                            "$($unit.Target)/$(Split-Path -Leaf $unit.Source)"
                        }
                        else { $unit.Target }
                        $dstItem = Get-SPMItemForPath -ServerRelativeUrl $dstPath -Kind $unit.Kind `
                            -Connection $destConnection

                        if (-not $srcItem -or -not $dstItem) {
                            $metadataFailed++
                            # Dire LEQUEL manque : « métadonnée non appliquée »
                            # sans le chemin ne se diagnostique pas.
                            $manquant = if (-not $srcItem) { "source $($unit.Source)" } else { "destination $dstPath" }
                            Write-SPMLog -Level WARNING -Operation 'Metadata' -Message (
                                "Element introuvable, métadonnée non appliquée : $manquant"
                            )
                            continue
                        }

                        $meta = Set-SPMItemMetadata -SourceItem $srcItem -TargetItem $dstItem `
                            -LibraryTitle $libraryTitle -Connection $destConnection

                        if ($meta.FailedFields.Count) { $metadataFailed++ } else { $metadataApplied++ }

                        # ----------------------------- rétention et modération
                        # Ces états ne voyagent pas avec le fichier : un document
                        # sous étiquette « Conserver 7 ans » arrivait sans
                        # étiquette, donc supprimable, et rien ne le disait.
                        $conf = Set-SPMItemCompliance -SourceItem $srcItem -TargetItem $dstItem `
                            -LibraryTitle $libraryTitle -Connection $destConnection

                        if ($conf.Label -like 'réappliquée*') { $labelsApplied++ }
                        if ($conf.Record -eq 'NON restauré') { $recordsLost++ }
                        foreach ($p in $conf.Problems) {
                            $complianceProblems++
                            Write-SPMLog -Level WARNING -Operation 'Compliance' -Message "$($unit.Relative) — $p"
                        }

                        Write-SPMLog -Level DEBUG -Operation 'Metadata' -Message (
                            "$($unit.Kind) $($unit.Relative) — colonnes $($meta.Fields), " +
                            "authorship $($meta.Authorship), type $($meta.ContentType)" +
                            $(if ($meta.FailedFields.Count) { " — resistent : $($meta.FailedFields -join ', ')" })
                        )
                    }
                    catch {
                        $metadataFailed++
                        Write-SPMLog -Level WARNING -Operation 'Metadata' -Message (
                            "Métadonnée non appliquée sur $($unit.Source) : $(($_.Exception.Message -split [char]10)[0])"
                        )
                    }
                }
            }
        }

        Write-Progress -Id 2 -Activity 'Application de la métadonnée' -Completed

        # ------------------------------------------------------ permissions
        # options.preservePermissions existait dans le plan, était documentée, et
        # n'était lue que pour émettre un avertissement : la commande n'était
        # appelée que depuis le menu. Une option qui ne fait rien est pire qu'une
        # option absente — elle fait croire au travail accompli.
        $permissions = $null
        if ($Plan.Options.preservePermissions -and $srcConnection -and $destConnection -and $migrees.Count) {
            $libSource = Get-SPMListTitleForPath -ServerRelativeUrl (Get-SPMServerRelativePath -Url $Plan.Source.Url) `
                -Connection $srcConnection
            $libCible = Get-SPMListTitleForPath -ServerRelativeUrl $units[0].Target -Connection $destConnection

            if (-not $libSource -or -not $libCible) {
                Write-SPMLog -Level WARNING -Operation 'Permissions' -Message (
                    'Bibliothèques non identifiées : permissions NON reportées. ' +
                    'Les éléments à héritage rompu restent sur les droits du parent à destination.'
                )
            }
            else {
                $permissions = Copy-SPMigrationPermission `
                    -SourceSiteUrl (Get-SPMSiteUrlFromResourceUrl -Url $Plan.Source.Url) `
                    -TargetSiteUrl (Get-SPMSiteUrlFromResourceUrl -Url $Plan.Destination.Url) `
                    -SourceList $libSource -TargetList $libCible `
                    -SourceConnection $srcConnection -DestinationConnection $destConnection `
                    -IdentityMapPath $IdentityMapPath -Confirm:$false -WhatIf:$WhatIfPreference

                if (-not $permissions.Ok) {
                    Write-SPMLog -Level ERROR -Operation 'Permissions' -Message (
                        "Report des permissions INCOMPLET : $($permissions.Failures.Count) échec(s), " +
                        "$($permissions.Surplus.Count) surplus de droit. La destination ne reflète pas la source."
                    )
                }
            }
        }

        # ----------------------------------------------- preuve de fidélité
        # Le bilan qui suit dérive de l'état : il dit ce que le moteur croit
        # avoir fait. Cette passe-ci va voir ce qui se trouve à destination.
        $fidelite = $null
        if ($Plan.Options.verify -and $srcConnection -and $destConnection -and -not $WhatIfPreference) {
            try {
                $fidelite = Test-SPMigrationFidelity -SourceUrl $Plan.Source.Url -TargetUrl $Plan.Destination.Url `
                    -SourceConnection $srcConnection -DestinationConnection $destConnection

                if (-not $fidelite.Ok) {
                    Write-SPMLog -Level ERROR -Operation 'Migration' -Message (
                        "Fidélité : $($fidelite.Errors) écart(s) mesuré(s) à destination sur " +
                        "$($fidelite.Compared) élément(s) comparé(s). Le transfert a réussi ; la copie n'est pas conforme."
                    )
                }
            }
            catch {
                Write-SPMLog -Level ERROR -Operation 'Migration' -Message (
                    "Vérification de fidélité impossible : $(($_.Exception.Message -split [char]10)[0]). " +
                    'Le résultat de la migration reste donc NON vérifié à destination.'
                )
            }
        }

        # ------------------------------------- ce qui n'a PAS été migré
        # Un rapport qui ne liste que les réussites laisse croire à une copie
        # exhaustive. Ces exclusions sont volontaires et documentées : elles
        # doivent figurer dans le bilan, pas dans la mémoire de celui qui a
        # écrit le moteur.
        $nonMigre = [System.Collections.Generic.List[string]]::new()
        $nonMigre.Add('Dossiers système (Forms, _vti_history, _catalogs) — recréés avec la bibliothèque')
        if (-not $Plan.Options.preservePermissions) {
            $nonMigre.Add('Permissions uniques — options.preservePermissions est à faux')
        }
        elseif ($permissions) {
            $nonMigre.Add(
                "Permissions : $($permissions.Applied) appliquée(s), $($permissions.Failures.Count) échec(s), " +
                "$($permissions.Surplus.Count) surplus de droit"
            )
        }
        $nonMigre.Add('Liens de partage et étiquettes de confidentialité')
        $nonMigre.Add('Pages, composants WebPart, affichages de liste personnalisés')
        $nonMigre.Add('Alertes, abonnements, corbeille')
        if ($metadataFailed) {
            $nonMigre.Add("Champs de métadonnée sur $metadataFailed élément(s) — voir le journal")
        }
        if ($ecartes) {
            $nonMigre.Add("$ecartes élément(s) EN QUARANTAINE — refusés par le pré-vol, voir le fichier .quarantine.ndjson")
        }
        if ($recordsLost) {
            # Un enregistrement se DÉCLARE, il ne s'écrit pas : le passer sous
            # silence ferait croire à une copie conforme d'un document qui a
            # perdu son statut — la perte qu'un audit révèle deux ans plus tard.
            $nonMigre.Add("Statut d'enregistrement de $recordsLost élément(s) — À REDÉCLARER à destination")
        }
        if ($complianceProblems -and -not $recordsLost) {
            $nonMigre.Add("Rétention ou modération sur $complianceProblems élément(s) — voir le journal")
        }
        if ($fallbackUsed) {
            $nonMigre.Add("Historique des versions de $fallbackUsed élément(s) passés par le repli local")
        }

        Write-SPMLog -Level INFO -Operation 'Migration' -Message (
            "Non migré, par décision : $($nonMigre -join ' | ')"
        )

        if ($Plan.Options.applyMetadata) {
            $niveau = if ($metadataFailed) { 'WARNING' } else { 'SUCCESS' }
            Write-SPMLog -Level $niveau -Operation 'Metadata' -Message (
                "Métadonnée : $metadataApplied élément(s) traité(s) sans réserve, $metadataFailed avec au moins un champ non appliqué"
            ) -Data @{ applied = $metadataApplied; failed = $metadataFailed }
        }

        if ($fallbackUsed) {
            Write-SPMLog -Level WARNING -Operation 'Migration' -Message (
                "$fallbackUsed élément(s) transféré(s) par repli local : leur HISTORIQUE DE VERSIONS " +
                "est perdu. Seule la version courante est arrivée."
            )
        }

        if ($labelsApplied -or $recordsLost) {
            $niveauConf = if ($recordsLost) { 'WARNING' } else { 'SUCCESS' }
            Write-SPMLog -Level $niveauConf -Operation 'Compliance' -Message (
                "Rétention : $labelsApplied étiquette(s) réappliquée(s)" +
                $(if ($recordsLost) {
                        ", $recordsLost enregistrement(s) NON restauré(s) — leur statut doit être redéclaré à destination"
                    })
            ) -Data @{ labels = $labelsApplied; records = $recordsLost; problems = $complianceProblems }
        }

        if ($reconnects) {
            Write-SPMLog -Level WARNING -Operation 'Migration' -Message (
                "$reconnects réouverture(s) de connexion après expiration du jeton. " +
                "Sur une fenêtre aussi longue, une authentification par certificat évite l'interruption."
            )
        }

        if ($submitted) {
            Write-SPMLog -Level WARNING -Operation 'Migration' -Message (
                "$submitted travail(aux) SOUMIS et non confirmés (options.async). " +
                "Leur exécution par le service n'est pas vérifiée : ne les comptez pas comme migrés " +
                "tant que le suivi de travail n'est pas branché. Posez options.async = false pour " +
                "obtenir un résultat confirmé."
            )
        }

        $duration = (Get-Date) - $started
        $spmStats = Get-SPMStatistics

        Write-SPMLog -Level $(if ($failed -eq 0 -and $submitted -eq 0) { 'SUCCESS' } else { 'WARNING' }) -Operation 'Migration' -Message (
            "Terminé en $($duration.ToString('hh\:mm\:ss')) — $succeeded confirmée(s), $failed en échec, $skipped ignorée(s)" +
            $(if ($submitted) { ", $submitted SOUMISE(S) NON CONFIRMÉE(S)" }) +
            $(if ($simulated) { ", $simulated simulée(s)" })
        ) -Data @{
            succeeded = $succeeded
            submitted = $submitted
            failed = $failed
            skipped = $skipped
            simulated = $simulated
            fallbackUsed = $fallbackUsed
            reconnects = $reconnects
            metadataApplied = $metadataApplied
            metadataFailed = $metadataFailed
            durationMs = [int]$duration.TotalMilliseconds
            retries = $spmStats.Retries
            throttleEvents = $spmStats.ThrottleEvents
        }

        if ($failed -gt 0) {
            Write-SPMLog -Level INFO -Operation 'Migration' -Message (
                "Relancez avec -Resume pour ne retraiter que les $failed unité(s) en échec."
            )
        }

        return [pscustomobject]@{
            Succeeded = $succeeded
            Submitted = $submitted
            Failed = $failed
            Skipped = $skipped
            Simulated = $simulated
            FallbackUsed = $fallbackUsed
            VersionsLost = $fallbackUsed
            Reconnects = $reconnects
            MetadataApplied = $metadataApplied
            MetadataFailed = $metadataFailed
            LabelsReapplied = $labelsApplied
            RecordsNotRestored = $recordsLost
            Permissions = $permissions
            Fidelity = $fidelite
            Quarantined = $ecartes
            NotMigrated = $nonMigre.ToArray()
            Aborted = $false
            Reason = $null
            Duration = $duration
            StatePath = $StatePath
            Resilience = $spmStats
            Findings = if ($preflight) { $preflight.Findings } else { @() }
        }
    }
}