Public/Repair-FileShareIntegrity.ps1

function Repair-FileShareIntegrity {
    <#
    .SYNOPSIS
        Remediates file integrity issues between two Azure file shares by re-copying files.
    .DESCRIPTION
        Accepts a list of relative file paths and runs a three-phase remediation:
          Phase 1 — Pre-check: verifies each file still has an issue (skippable).
          Phase 2 — Copy: re-copies confirmed-bad files (SMB or azcopy).
          Phase 3 — Post-check: re-validates every copied file.
 
        A remediation report CSV is produced with per-file results.
    .PARAMETER FilePaths
        Array of relative file paths to remediate. Accepts pipeline input.
    .PARAMETER SourceAccountName
        Name of the source (authoritative) storage account.
    .PARAMETER DestinationAccountName
        Name of the destination (target) storage account.
    .PARAMETER ShareName
        Name of the file share on both accounts.
    .PARAMETER SourceDriveLetter
        Drive letter for source mount. Default: X.
    .PARAMETER DestinationDriveLetter
        Drive letter for destination mount. Default: Y.
    .PARAMETER AzCopy
        Use azcopy instead of SMB file copy for Phase 2.
    .PARAMETER SasExpiryHours
        SAS token validity in hours (azcopy mode). Default: 24.
    .PARAMETER SkipPreCheck
        Skip Phase 1 pre-check.
    .PARAMETER LogDirectory
        Base directory for reports. Default: current directory.
    .EXAMPLE
        $files = Select-IntegrityReportFile -ReportPath '.\report.csv' -Status ByteMismatch
        Repair-FileShareIntegrity -FilePaths $files -SourceAccountName 'sourceaccount' -DestinationAccountName 'destaccount' -ShareName 'data'
    .EXAMPLE
        Select-IntegrityReportFile -ReportPath '.\report.csv' -Status ByteMismatch |
            Repair-FileShareIntegrity -SourceAccountName 'sourceaccount' -DestinationAccountName 'destaccount' -ShareName 'data' -SkipPreCheck
    #>

    [CmdletBinding(SupportsShouldProcess)]
    [Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSAvoidUsingPositionalParameters', '', Justification = 'Join-Path positional parameters are idiomatic and readable')]
    param(
        [Parameter(Mandatory, ValueFromPipeline)]
        [AllowEmptyCollection()]
        [string[]]$FilePaths,

        [Parameter(Mandatory)]
        [string]$SourceAccountName,

        [Parameter(Mandatory)]
        [string]$DestinationAccountName,

        [Parameter(Mandatory)]
        [string]$ShareName,

        [Parameter()]
        [ValidatePattern('^[A-Za-z]$')]
        [string]$SourceDriveLetter = 'X',

        [Parameter()]
        [ValidatePattern('^[A-Za-z]$')]
        [string]$DestinationDriveLetter = 'Y',

        [Parameter()]
        [switch]$AzCopy,

        [Parameter()]
        [int]$SasExpiryHours = 24,

        [Parameter()]
        [switch]$SkipPreCheck,

        [Parameter()]
        [string]$LogDirectory = (Get-Location).Path
    )

    begin {
        Set-StrictMode -Version Latest
        $ErrorActionPreference = 'Stop'
        $collectedPaths = [System.Collections.Generic.List[string]]::new()
    }

    process {
        foreach ($p in $FilePaths) {
            if ($p) { $collectedPaths.Add($p) }
        }
    }

    end {
        if ($collectedPaths.Count -eq 0) {
            Write-Host 'No file paths provided. Nothing to do.' -ForegroundColor Yellow
            return
        }

        # Deduplicate
        $seen = [System.Collections.Generic.HashSet[string]]::new([System.StringComparer]::OrdinalIgnoreCase)
        $allPaths = [System.Collections.Generic.List[string]]::new()
        foreach ($p in $collectedPaths) { if ($seen.Add($p)) { $allPaths.Add($p) } }

        if ($SourceDriveLetter -eq $DestinationDriveLetter) {
            throw 'Source and destination drive letters must be different.'
        }

        $copyMethod = if ($AzCopy) { 'azcopy' } else { 'SMB' }
        Write-Host "[$(Get-Timestamp)] $($allPaths.Count) file path(s) to remediate (copy method: $copyMethod)." -ForegroundColor Cyan

        # ── Pre-flight ───────────────────────────────────────────────────
        if ($AzCopy) { Assert-AzCopyInstalled }
        Assert-AzCliLogin | Out-Null

        $srcDrive = "${SourceDriveLetter}:"
        $dstDrive = "${DestinationDriveLetter}:"
        foreach ($drive in @($srcDrive, $dstDrive)) {
            if (Test-Path "${drive}\") {
                throw "Drive '$drive' is already mounted."
            }
        }

        $sourceKey = Get-StorageAccountKey -AccountName $SourceAccountName
        $destKey   = Get-StorageAccountKey -AccountName $DestinationAccountName

        # SAS tokens for azcopy mode
        $sourceSas = $null
        $destSas   = $null
        if ($AzCopy) {
            $sourceSas = New-SasToken -AccountName $SourceAccountName  -Permissions 'rl'    -ExpiryHours $SasExpiryHours
            $destSas   = New-SasToken -AccountName $DestinationAccountName -Permissions 'rwdlc' -ExpiryHours $SasExpiryHours
        }

        # ── Report setup ─────────────────────────────────────────────────
        $reportDir = Join-Path $LogDirectory 'logs' "repair-integrity_$(Get-Date -Format 'yyyyMMdd_HHmmss')"
        New-Item -ItemType Directory -Path $reportDir -Force -Confirm:$false -WhatIf:$false | Out-Null

        $csvPath   = Join-Path $reportDir "${ShareName}_remediation-report.csv"
        $csvWriter = [System.IO.StreamWriter]::new($csvPath, $false, [System.Text.Encoding]::UTF8)
        $csvWriter.WriteLine('"RelativePath","PreCheckResult","Action","PostCheckResult","Error"')

        function Write-ReportRow {
            param([System.IO.StreamWriter]$Writer, [string]$RelativePath, [string]$PreCheckResult, [string]$Action, [string]$PostCheckResult, [string]$ErrorMessage)
            $escaped    = $RelativePath.Replace('"', '""')
            $errEscaped = $ErrorMessage.Replace('"', '""')
            $Writer.WriteLine("`"$escaped`",`"$PreCheckResult`",`"$Action`",`"$PostCheckResult`",`"$errEscaped`"")
        }

        $mountedSource = $false
        $mountedDest   = $false
        $srcRoot = "${srcDrive}\"
        $dstRoot = "${dstDrive}\"

        $totalSelected = $allPaths.Count
        $skippedCount  = 0
        $preErrorCount = 0
        $toRemediate   = [System.Collections.Generic.List[hashtable]]::new()

        try {
            # ── Phase 1: Pre-check ───────────────────────────────────────
            if ($SkipPreCheck) {
                Write-Host "`n[$(Get-Timestamp)] Phase 1: Skipped (-SkipPreCheck)." -ForegroundColor Yellow
                foreach ($rel in $allPaths) {
                    $toRemediate.Add(@{ RelativePath = $rel; PreCheckResult = 'Skipped' })
                }
            }
            else {
                Mount-SmbDrive -AccountName $SourceAccountName -AccountKey $sourceKey -ShareName $ShareName -DriveLetter $SourceDriveLetter
                $mountedSource = $true
                Mount-SmbDrive -AccountName $DestinationAccountName -AccountKey $destKey -ShareName $ShareName -DriveLetter $DestinationDriveLetter
                $mountedDest = $true

                Write-Host "`n[$(Get-Timestamp)] Phase 1: Pre-checking $totalSelected file(s)..." -ForegroundColor Cyan

                $fileNum = 0
                foreach ($rel in $allPaths) {
                    $fileNum++
                    $srcPath = Join-Path $srcRoot $rel
                    $dstPath = Join-Path $dstRoot $rel

                    if ($fileNum % 500 -eq 0 -or $fileNum -eq $totalSelected) {
                        Write-Host " Pre-check: $fileNum / $totalSelected ($([math]::Round(($fileNum / $totalSelected) * 100, 1))%)" -ForegroundColor Gray
                    }

                    try {
                        $preCheck = Test-FileByteIntegrity -SourcePath $srcPath -DestinationPath $dstPath
                    }
                    catch {
                        $preErrorCount++
                        Write-ReportRow -Writer $csvWriter -RelativePath $rel -PreCheckResult 'Error' -Action 'None' -PostCheckResult '' -ErrorMessage $_.Exception.Message
                        continue
                    }

                    if ($preCheck -eq 'Match') {
                        $skippedCount++
                        Write-ReportRow -Writer $csvWriter -RelativePath $rel -PreCheckResult 'Match' -Action 'Skipped' -PostCheckResult 'Match' -ErrorMessage ''
                        continue
                    }

                    if ($preCheck -eq 'MissingOnSource') {
                        $skippedCount++
                        Write-ReportRow -Writer $csvWriter -RelativePath $rel -PreCheckResult 'MissingOnSource' -Action 'Skipped' -PostCheckResult '' -ErrorMessage 'Source file does not exist'
                        continue
                    }

                    $toRemediate.Add(@{ RelativePath = $rel; PreCheckResult = $preCheck })
                }

                Write-Host " Already matching : $skippedCount" -ForegroundColor Gray
                Write-Host " Pre-check errors : $preErrorCount" -ForegroundColor $(if ($preErrorCount -gt 0) { 'Red' } else { 'Green' })
                Write-Host " Need remediation : $($toRemediate.Count)" -ForegroundColor $(if ($toRemediate.Count -gt 0) { 'Yellow' } else { 'Green' })
            }

            if ($toRemediate.Count -eq 0) {
                Write-Host "`n[$(Get-Timestamp)] No files need remediation. Done." -ForegroundColor Green
            }
            # ── Phase 2: Copy ────────────────────────────────────────────
            elseif ($PSCmdlet.ShouldProcess("$($toRemediate.Count) file(s) on '$ShareName'", "$copyMethod copy")) {

                $copyFailCount = 0

                if ($AzCopy) {
                    $listFilePath = Join-Path $reportDir 'azcopy-file-list.txt'
                    $listWriter   = [System.IO.StreamWriter]::new($listFilePath, $false, [System.Text.Encoding]::UTF8)
                    foreach ($item in $toRemediate) { $listWriter.WriteLine($item.RelativePath.Replace('\', '/')) }
                    $listWriter.Flush(); $listWriter.Dispose()

                    Write-Host "`n[$(Get-Timestamp)] Phase 2: Copying $($toRemediate.Count) file(s) with azcopy..." -ForegroundColor Cyan

                    $sourceUrl = "https://${SourceAccountName}.file.core.windows.net/${ShareName}/?${sourceSas}"
                    $destUrl   = "https://${DestinationAccountName}.file.core.windows.net/${ShareName}/?${destSas}"

                    $azcopyArgs = @('copy', $sourceUrl, $destUrl, "--list-of-files=$listFilePath", '--preserve-smb-permissions=false', '--preserve-smb-info=true', '--overwrite=true', '--log-level=INFO')
                    $env:AZCOPY_LOG_LOCATION = $reportDir
                    $azcopyOutput = & azcopy @azcopyArgs 2>&1
                    $azcopyExitCode = $LASTEXITCODE

                    $azcopyOutput | Out-File -FilePath (Join-Path $reportDir 'azcopy-output.log') -Encoding utf8

                    if ($azcopyExitCode -ne 0) {
                        Write-Warning "azcopy exited with code $azcopyExitCode."
                    }
                    else {
                        Write-Host ' ✓ azcopy completed successfully.' -ForegroundColor Green
                    }
                }
                else {
                    # SMB copy
                    if (-not $mountedSource) {
                        Mount-SmbDrive -AccountName $SourceAccountName -AccountKey $sourceKey -ShareName $ShareName -DriveLetter $SourceDriveLetter
                        $mountedSource = $true
                    }
                    if (-not $mountedDest) {
                        Mount-SmbDrive -AccountName $DestinationAccountName -AccountKey $destKey -ShareName $ShareName -DriveLetter $DestinationDriveLetter
                        $mountedDest = $true
                    }

                    Write-Host "`n[$(Get-Timestamp)] Phase 2: Copying $($toRemediate.Count) file(s) via SMB..." -ForegroundColor Cyan

                    $copyNum = 0
                    foreach ($item in $toRemediate) {
                        $copyNum++
                        $rel     = $item.RelativePath
                        $srcPath = Join-Path $srcRoot $rel
                        $dstPath = Join-Path $dstRoot $rel

                        if ($copyNum % 500 -eq 0 -or $copyNum -eq $toRemediate.Count) {
                            Write-Host " Copy: $copyNum / $($toRemediate.Count) ($([math]::Round(($copyNum / $toRemediate.Count) * 100, 1))%)" -ForegroundColor Gray
                        }

                        try {
                            $dstDir = [System.IO.Path]::GetDirectoryName($dstPath)
                            if (-not [System.IO.Directory]::Exists($dstDir)) {
                                [System.IO.Directory]::CreateDirectory($dstDir) | Out-Null
                            }
                            [System.IO.File]::Copy($srcPath, $dstPath, $true)
                        }
                        catch {
                            $copyFailCount++
                            Write-ReportRow -Writer $csvWriter -RelativePath $rel -PreCheckResult $item.PreCheckResult -Action 'CopyFailed' -PostCheckResult '' -ErrorMessage $_.Exception.Message
                            $item.PreCheckResult = 'CopyFailed'
                        }
                    }

                    if ($copyFailCount -gt 0) {
                        Write-Warning "$copyFailCount file(s) failed to copy."
                        $toRemediate = [System.Collections.Generic.List[hashtable]]@(
                            $toRemediate | Where-Object { $_.PreCheckResult -ne 'CopyFailed' }
                        )
                    }
                    else {
                        Write-Host ' ✓ All files copied.' -ForegroundColor Green
                    }
                }

                # ── Phase 3: Post-copy verification ──────────────────────
                if ($toRemediate.Count -gt 0) {
                    if (-not $mountedSource) {
                        Mount-SmbDrive -AccountName $SourceAccountName -AccountKey $sourceKey -ShareName $ShareName -DriveLetter $SourceDriveLetter
                        $mountedSource = $true
                    }
                    if (-not $mountedDest) {
                        Mount-SmbDrive -AccountName $DestinationAccountName -AccountKey $destKey -ShareName $ShareName -DriveLetter $DestinationDriveLetter
                        $mountedDest = $true
                    }

                    Write-Host "`n[$(Get-Timestamp)] Phase 3: Verifying $($toRemediate.Count) remediated file(s)..." -ForegroundColor Cyan

                    $verifiedCount  = 0
                    $postFailCount  = 0
                    $postErrorCount = 0
                    $checkNum       = 0

                    foreach ($item in $toRemediate) {
                        $checkNum++
                        $rel     = $item.RelativePath
                        $srcPath = Join-Path $srcRoot $rel
                        $dstPath = Join-Path $dstRoot $rel

                        if ($checkNum % 500 -eq 0 -or $checkNum -eq $toRemediate.Count) {
                            Write-Host " Post-check: $checkNum / $($toRemediate.Count)" -ForegroundColor Gray
                        }

                        try {
                            $postCheck = Test-FileByteIntegrity -SourcePath $srcPath -DestinationPath $dstPath
                            if ($postCheck -eq 'Match') { $verifiedCount++ } else { $postFailCount++ }
                            Write-ReportRow -Writer $csvWriter -RelativePath $rel -PreCheckResult $item.PreCheckResult -Action 'Copied' -PostCheckResult $postCheck -ErrorMessage ''
                        }
                        catch {
                            $postErrorCount++
                            Write-ReportRow -Writer $csvWriter -RelativePath $rel -PreCheckResult $item.PreCheckResult -Action 'Copied' -PostCheckResult 'Error' -ErrorMessage $_.Exception.Message
                        }
                    }
                }
            }
            else {
                foreach ($item in $toRemediate) {
                    Write-ReportRow -Writer $csvWriter -RelativePath $item.RelativePath -PreCheckResult $item.PreCheckResult -Action 'WhatIf' -PostCheckResult '' -ErrorMessage ''
                }
            }
        }
        finally {
            if ($csvWriter) { try { $csvWriter.Flush(); $csvWriter.Dispose() } catch { Write-Debug "CSV writer dispose failed: $_" } }
            if ($mountedDest)   { Dismount-SmbDrive -DriveLetter $DestinationDriveLetter }
            if ($mountedSource) { Dismount-SmbDrive -DriveLetter $SourceDriveLetter }
        }

        # ── Summary ──────────────────────────────────────────────────────
        $modeLabel = if ($WhatIfPreference) { 'DRY-RUN' } else { 'REMEDIATION' }
        if (-not (Get-Variable verifiedCount  -ErrorAction SilentlyContinue)) { $verifiedCount  = 0 }
        if (-not (Get-Variable postFailCount  -ErrorAction SilentlyContinue)) { $postFailCount  = 0 }
        if (-not (Get-Variable postErrorCount -ErrorAction SilentlyContinue)) { $postErrorCount = 0 }
        if (-not (Get-Variable copyFailCount  -ErrorAction SilentlyContinue)) { $copyFailCount  = 0 }

        Write-Host "`n════════════════════════════════════════════════════════════" -ForegroundColor Cyan
        Write-Host " $modeLabel — '$ShareName' ($copyMethod)" -ForegroundColor Cyan
        Write-Host '════════════════════════════════════════════════════════════' -ForegroundColor Cyan
        Write-Host " Files input : $totalSelected" -ForegroundColor White
        Write-Host " Copied : $($toRemediate.Count)" -ForegroundColor Cyan
        if ($copyFailCount -gt 0) { Write-Host " Copy failures : $copyFailCount" -ForegroundColor Red }
        Write-Host " Post-copy verified : $verifiedCount" -ForegroundColor $(if ($verifiedCount -eq $toRemediate.Count -and $toRemediate.Count -gt 0) { 'Green' } else { 'White' })
        Write-Host " Post-copy FAILED : $postFailCount" -ForegroundColor $(if ($postFailCount -gt 0) { 'Red' } else { 'Green' })
        Write-Host " Report : $csvPath" -ForegroundColor Cyan
        Write-Host ''

        $totalIssues = $preErrorCount + $postFailCount + $postErrorCount + $copyFailCount
        if ($totalIssues -gt 0) {
            Write-Warning "$totalIssues file(s) could not be fully remediated. Review the report."
        }
        elseif (-not $WhatIfPreference -and $toRemediate.Count -gt 0) {
            Write-Host " ✓ All $verifiedCount copied file(s) verified successfully." -ForegroundColor Green
        }
    }
}