modules/shared/Checkpoint.ps1

#Requires -Version 7.4
<#
.SYNOPSIS
    Checkpoint helpers for tool execution.
.DESCRIPTION
    Saves, loads, and removes per-tool checkpoint files to support resume.
    Checkpoints are keyed by tool name and scope-specific identifiers.
#>

[CmdletBinding()]
param ()

Set-StrictMode -Version Latest

$sanitizePath = Join-Path $PSScriptRoot 'Sanitize.ps1'
if (Test-Path $sanitizePath) { . $sanitizePath }
if (-not (Get-Command Remove-Credentials -ErrorAction SilentlyContinue)) {
    function Remove-Credentials { param ([string] $Text) return $Text }
}

function ConvertTo-SafeCheckpointComponent {
    param (
        [AllowNull()]
        [string] $Value
    )

    if ($null -eq $Value) { return $null }
    return ($Value -replace '[/\\]', '_' -replace '\.\.', '_')
}

function Get-CheckpointKey {
    <#
    .SYNOPSIS
        Builds a scope-aware checkpoint key.
    .PARAMETER ScopeType
        The scope classification for the tool.
    .PARAMETER SubscriptionId
        Azure subscription ID for subscription-scoped tools.
    .PARAMETER ManagementGroupId
        Management group ID for MG-scoped tools.
    .PARAMETER TenantId
        Tenant ID for tenant-scoped tools.
    .PARAMETER RepoSlug
        Repository slug in the form owner-repo.
    .PARAMETER AdoOrg
        Azure DevOps organization name.
    .PARAMETER AdoProject
        Azure DevOps project name.
.PARAMETER CorrelationId
    Reserved for future correlation identifiers; identity checkpoints use
    the fixed key "identity-correlator" by default.
    #>

    [CmdletBinding()]
    param (
        [Parameter(Mandatory)]
        [ValidateSet('Subscription', 'ManagementGroup', 'Tenant', 'Repository', 'ADO', 'Identity')]
        [string] $ScopeType,

        [string] $SubscriptionId,
        [string] $ManagementGroupId,
        [string] $TenantId,
        [string] $RepoSlug,
        [string] $AdoOrg,
        [string] $AdoProject,
        [string] $CorrelationId = 'identity-correlator'
    )

    switch ($ScopeType) {
        'Subscription' {
            if (-not $SubscriptionId) { throw "SubscriptionId is required for subscription-scoped checkpoints." }
            return (ConvertTo-SafeCheckpointComponent -Value $SubscriptionId)
        }
        'ManagementGroup' {
            if (-not $ManagementGroupId) { throw "ManagementGroupId is required for management-group checkpoints." }
            return (ConvertTo-SafeCheckpointComponent -Value "mg-$ManagementGroupId")
        }
        'Tenant' {
            if (-not $TenantId) { throw "TenantId is required for tenant-scoped checkpoints." }
            return (ConvertTo-SafeCheckpointComponent -Value "tenant-$TenantId")
        }
        'Repository' {
            if (-not $RepoSlug) { throw "RepoSlug is required for repository-scoped checkpoints." }
            return (ConvertTo-SafeCheckpointComponent -Value "repo-$RepoSlug")
        }
        'ADO' {
            if (-not $AdoOrg -or -not $AdoProject) { throw "AdoOrg and AdoProject are required for ADO checkpoints." }
            return (ConvertTo-SafeCheckpointComponent -Value "ado-$AdoOrg-$AdoProject")
        }
        'Identity' {
            return (ConvertTo-SafeCheckpointComponent -Value ($CorrelationId ?? 'identity-correlator'))
        }
    }
}

function Get-CheckpointPath {
    <#
    .SYNOPSIS
        Resolves the checkpoint file path for a tool and scope key.
    .PARAMETER CheckpointDir
        Directory where checkpoint files are stored.
    .PARAMETER Tool
        Tool name.
    .PARAMETER ScopeKey
        Scope-specific checkpoint key.
    #>

    [CmdletBinding()]
    param (
        [Parameter(Mandatory)]
        [ValidateNotNullOrEmpty()]
        [string] $CheckpointDir,

        [Parameter(Mandatory)]
        [ValidateNotNullOrEmpty()]
        [string] $Tool,

        [Parameter(Mandatory)]
        [ValidateNotNullOrEmpty()]
        [string] $ScopeKey
    )

    $resolvedCheckpointDir = [System.IO.Path]::GetFullPath($CheckpointDir)
    $sanitizedTool = ConvertTo-SafeCheckpointComponent -Value $Tool
    $sanitizedScopeKey = ConvertTo-SafeCheckpointComponent -Value $ScopeKey
    $resolvedPath = [System.IO.Path]::GetFullPath((Join-Path $resolvedCheckpointDir "$sanitizedTool-$sanitizedScopeKey.json"))

    $sep = [System.IO.Path]::DirectorySeparatorChar
    $checkpointRoot = if ($resolvedCheckpointDir.EndsWith($sep)) { $resolvedCheckpointDir } else { "$resolvedCheckpointDir$sep" }
    if (-not $resolvedPath.StartsWith($checkpointRoot, [System.StringComparison]::OrdinalIgnoreCase)) {
        throw "Checkpoint path resolution escaped checkpoint directory: $resolvedPath"
    }

    return $resolvedPath
}

function Save-Checkpoint {
    <#
    .SYNOPSIS
        Writes tool results to a checkpoint file.
    .PARAMETER CheckpointDir
        Directory where checkpoint files are stored.
    .PARAMETER Tool
        Tool name.
    .PARAMETER ScopeType
        Scope classification for the tool.
    .PARAMETER Result
        Tool result to serialize.
    #>

    [CmdletBinding()]
    param (
        [Parameter(Mandatory)]
        [ValidateNotNullOrEmpty()]
        [string] $CheckpointDir,

        [Parameter(Mandatory)]
        [ValidateNotNullOrEmpty()]
        [string] $Tool,

        [Parameter(Mandatory)]
        [ValidateSet('Subscription', 'ManagementGroup', 'Tenant', 'Repository', 'ADO', 'Identity')]
        [string] $ScopeType,

        [Parameter(Mandatory)]
        [ValidateNotNull()]
        [PSCustomObject] $Result,

        [string] $SubscriptionId,
        [string] $ManagementGroupId,
        [string] $TenantId,
        [string] $RepoSlug,
        [string] $AdoOrg,
        [string] $AdoProject,
        [string] $CorrelationId = 'identity-correlator'
    )

    if (-not (Test-Path $CheckpointDir)) {
        $null = New-Item -ItemType Directory -Path $CheckpointDir -Force
    }

    $scopeKey = Get-CheckpointKey -ScopeType $ScopeType -SubscriptionId $SubscriptionId `
        -ManagementGroupId $ManagementGroupId -TenantId $TenantId -RepoSlug $RepoSlug `
        -AdoOrg $AdoOrg -AdoProject $AdoProject -CorrelationId $CorrelationId

    $path = Get-CheckpointPath -CheckpointDir $CheckpointDir -Tool $Tool -ScopeKey $scopeKey
    $json = $Result | ConvertTo-Json -Depth 50
    if (Get-Command Remove-Credentials -ErrorAction SilentlyContinue) {
        $json = Remove-Credentials $json
    }
    $tempPath = "$path.tmp-$([Guid]::NewGuid().ToString('N'))"
    Set-Content -Path $tempPath -Value (Remove-Credentials $json) -Encoding utf8
    Move-Item -Path $tempPath -Destination $path -Force
    return $path
}

function Get-Checkpoint {
    <#
    .SYNOPSIS
        Loads a checkpoint file if present.
    .PARAMETER CheckpointDir
        Directory where checkpoint files are stored.
    .PARAMETER Tool
        Tool name.
    .PARAMETER ScopeType
        Scope classification for the tool.
    #>

    [CmdletBinding()]
    param (
        [Parameter(Mandatory)]
        [ValidateNotNullOrEmpty()]
        [string] $CheckpointDir,

        [Parameter(Mandatory)]
        [ValidateNotNullOrEmpty()]
        [string] $Tool,

        [Parameter(Mandatory)]
        [ValidateSet('Subscription', 'ManagementGroup', 'Tenant', 'Repository', 'ADO', 'Identity')]
        [string] $ScopeType,

        [string] $SubscriptionId,
        [string] $ManagementGroupId,
        [string] $TenantId,
        [string] $RepoSlug,
        [string] $AdoOrg,
        [string] $AdoProject,
        [string] $CorrelationId = 'identity-correlator'
    )

    $scopeKey = Get-CheckpointKey -ScopeType $ScopeType -SubscriptionId $SubscriptionId `
        -ManagementGroupId $ManagementGroupId -TenantId $TenantId -RepoSlug $RepoSlug `
        -AdoOrg $AdoOrg -AdoProject $AdoProject -CorrelationId $CorrelationId

    $path = Get-CheckpointPath -CheckpointDir $CheckpointDir -Tool $Tool -ScopeKey $scopeKey
    if (-not (Test-Path $path)) {
        return $null
    }

    try {
        return (Get-Content -Raw $path | ConvertFrom-Json -ErrorAction Stop)
    } catch {
        Write-Warning "Checkpoint file '$path' is corrupt or unreadable. Treating as cache miss. $_"
        return $null
    }
}

function Remove-Checkpoint {
    <#
    .SYNOPSIS
        Deletes a checkpoint file after successful completion.
    .PARAMETER CheckpointDir
        Directory where checkpoint files are stored.
    .PARAMETER Tool
        Tool name.
    .PARAMETER ScopeType
        Scope classification for the tool.
    #>

    [CmdletBinding()]
    param (
        [Parameter(Mandatory)]
        [ValidateNotNullOrEmpty()]
        [string] $CheckpointDir,

        [Parameter(Mandatory)]
        [ValidateNotNullOrEmpty()]
        [string] $Tool,

        [Parameter(Mandatory)]
        [ValidateSet('Subscription', 'ManagementGroup', 'Tenant', 'Repository', 'ADO', 'Identity')]
        [string] $ScopeType,

        [string] $SubscriptionId,
        [string] $ManagementGroupId,
        [string] $TenantId,
        [string] $RepoSlug,
        [string] $AdoOrg,
        [string] $AdoProject,
        [string] $CorrelationId = 'identity-correlator'
    )

    $scopeKey = Get-CheckpointKey -ScopeType $ScopeType -SubscriptionId $SubscriptionId `
        -ManagementGroupId $ManagementGroupId -TenantId $TenantId -RepoSlug $RepoSlug `
        -AdoOrg $AdoOrg -AdoProject $AdoProject -CorrelationId $CorrelationId

    $path = Get-CheckpointPath -CheckpointDir $CheckpointDir -Tool $Tool -ScopeKey $scopeKey
    if (Test-Path $path) {
        Remove-Item -Path $path -Force
        return $true
    }

    return $false
}