Private/State.ps1

<#
    Magasin d'état de migration.
 
    Principe : l'état est la source de vérité, les journaux en sont dérivés.
    La version précédente faisait l'inverse — elle reconstruisait les compteurs
    en cherchant des sous-chaînes dans un fichier de log, ce qui interdisait
    toute reprise fiable.
 
    Format : NDJSON append-only (une ligne = un événement d'unité de travail).
    Choisi plutôt que SQLite pour rester sans dépendance binaire, lisible à
    l'œil, et réparable à la main en cas d'interruption brutale.
#>


$script:SPMStateFile = $null
$script:SPMStateIndex = $null
$script:SPMStateWriter = $null

function Initialize-SPMState {
    <#
    .SYNOPSIS
        Ouvre (ou crée) le magasin d'état d'une exécution de migration.
 
    .PARAMETER Path
        Fichier .ndjson d'état. S'il existe déjà, il est relu pour permettre la reprise.
    #>

    [CmdletBinding()]
    param(
        [Parameter(Mandatory)][string]$Path,
        [switch]$Fresh
    )

    $dir = Split-Path -Parent $Path
    if ($dir -and -not (Test-Path -LiteralPath $dir)) {
        New-Item -ItemType Directory -Path $dir -Force | Out-Null
    }

    if ($Fresh -and (Test-Path -LiteralPath $Path)) {
        Remove-Item -LiteralPath $Path -Force
    }

    $script:SPMStateFile = $Path
    $script:SPMStateIndex = [System.Collections.Generic.Dictionary[string, object]]::new()

    if (Test-Path -LiteralPath $Path) {
        $loaded = 0
        foreach ($line in [System.IO.File]::ReadLines($Path)) {
            if ([string]::IsNullOrWhiteSpace($line)) { continue }
            try {
                $record = $line | ConvertFrom-Json
                # Le dernier événement d'une clé fait foi (append-only + écrasement logique)
                $script:SPMStateIndex[$record.key] = $record
                $loaded++
            }
            catch {
                Write-SPMLog -Level WARNING -Operation 'State' -Message "Ligne d'état illisible ignorée : $($_.Exception.Message)"
            }
        }
        Write-SPMLog -Level INFO -Operation 'State' -Message "État rechargé : $loaded événement(s), $($script:SPMStateIndex.Count) unité(s) distincte(s)"
    }

    return [pscustomobject]@{
        Path = $Path
        Units = $script:SPMStateIndex.Count
        Completed = @($script:SPMStateIndex.Values | Where-Object { $_.status -eq 'Completed' }).Count
    }
}

function Get-SPMStateKey {
    <#
    .SYNOPSIS
        Construit la clé d'idempotence d'une unité de travail.
    #>

    [CmdletBinding()]
    [OutputType([string])]
    param(
        [Parameter(Mandatory)][string]$SourceUrl,
        [Parameter(Mandatory)][string]$TargetUrl
    )
    ($SourceUrl.TrimEnd('/') + '=>' + $TargetUrl.TrimEnd('/')).ToLowerInvariant()
}

function Test-SPMUnitCompleted {
    <#
    .SYNOPSIS
        Indique si une unité de travail est déjà terminée avec succès.
 
    .DESCRIPTION
        En mode incrémental, une unité terminée est retenue seulement si la
        signature de la source n'a pas changé (taille + date de modification).
        « Le dossier existe » ne suffit pas à conclure « il est à jour ».
    #>

    [CmdletBinding()]
    [OutputType([bool])]
    param(
        [Parameter(Mandatory)][string]$Key,
        [string]$SourceSignature
    )

    if (-not $script:SPMStateIndex -or -not $script:SPMStateIndex.ContainsKey($Key)) { return $false }

    $record = $script:SPMStateIndex[$Key]
    if ($record.status -ne 'Completed') { return $false }

    if ($SourceSignature) {
        $stored = $record.PSObject.Properties['sourceSignature']
        if (-not $stored -or $stored.Value -ne $SourceSignature) {
            return $false   # la source a changé depuis la dernière migration
        }
    }

    return $true
}

function Write-SPMStateRecord {
    <#
    .SYNOPSIS
        Enregistre l'état d'une unité de travail (append-only).
    #>

    [CmdletBinding()]
    param(
        [Parameter(Mandatory)][string]$Key,
        # « Submitted » : le service a ACCEPTÉ le travail de copie, il ne l'a pas
        # encore exécuté. Distinguer cet état de « Completed » est ce qui empêche
        # un rapport d'annoncer 100 % de réussite sur une destination vide.
        [Parameter(Mandatory)]
        [ValidateSet('Pending', 'Running', 'Submitted', 'Completed', 'Failed', 'Skipped')]
        [string]$Status,
        [string]$SourceUrl,
        [string]$TargetUrl,
        [string]$SourceSignature,
        [string]$JobId,
        [string]$ErrorMessage,
        [hashtable]$Data
    )

    $record = [ordered]@{
        ts = (Get-Date).ToString('o')
        key = $Key
        status = $Status
    }
    if ($SourceUrl) { $record.sourceUrl = $SourceUrl }
    if ($TargetUrl) { $record.targetUrl = $TargetUrl }
    if ($SourceSignature) { $record.sourceSignature = $SourceSignature }
    if ($JobId) { $record.jobId = $JobId }
    if ($ErrorMessage) { $record.error = $ErrorMessage }
    if ($Data) { foreach ($k in $Data.Keys) { $record[$k] = $Data[$k] } }

    $json = $record | ConvertTo-Json -Compress -Depth 6

    if ($script:SPMStateFile) {
        Add-Content -LiteralPath $script:SPMStateFile -Value $json -Encoding utf8
    }
    if ($script:SPMStateIndex) {
        $script:SPMStateIndex[$Key] = ($json | ConvertFrom-Json)
    }
}

function Get-SPMStateSummary {
    <#
    .SYNOPSIS
        Agrège l'état courant par statut.
    #>

    [CmdletBinding()]
    param()

    if (-not $script:SPMStateIndex) {
        return [pscustomobject]@{ Total = 0; Completed = 0; Failed = 0; Skipped = 0; Running = 0; Pending = 0 }
    }

    $byStatus = $script:SPMStateIndex.Values | Group-Object status -AsHashTable -AsString
    $count = { param($s) if ($byStatus -and $byStatus.ContainsKey($s)) { @($byStatus[$s]).Count } else { 0 } }

    [pscustomobject]@{
        Total = $script:SPMStateIndex.Count
        Completed = & $count 'Completed'
        Failed = & $count 'Failed'
        Skipped = & $count 'Skipped'
        Running = & $count 'Running'
        Pending = & $count 'Pending'
    }
}

function Get-SPMFailedUnit {
    <#
    .SYNOPSIS
        Retourne les unités en échec, pour relance ciblée.
    #>

    [CmdletBinding()]
    param()
    if (-not $script:SPMStateIndex) { return @() }
    @($script:SPMStateIndex.Values | Where-Object { $_.status -eq 'Failed' })
}