src/Invoke-AZSCMultiTenantRun.ps1

#Requires -Version 7.0
Set-StrictMode -Version Latest
$ErrorActionPreference = 'Stop'

<#
.SYNOPSIS
    Orchestrates one Azure Scout invocation across several directly accessible tenants.
 
.DESCRIPTION
    AB#7105. Keeps the existing collection pipeline single-tenant and invokes it once per target
    tenant beneath one umbrella run folder. Each tenant has isolated cache, logs, evidence, and
    reports. A root self-contained overview records the requested scope, per-tenant outcome, and
    relative links to completed React reports. Azure Lighthouse is deliberately not involved.
 
.NOTES
    Author: Kristopher Turner
    Contact: kris@hybridsolutions.cloud
    Version: 1.0.0
#>


function New-AZSCRunResult {
    [CmdletBinding()]
    param(
        [string]$TenantId,
        [string]$OutputPath,
        [string]$ReactFile,
        [string]$EvidenceFile,
        [string]$JsonFile,
        [int]$SubscriptionCount,
        [int]$ResourceCount,
        [string]$Duration,
        [string]$Status = 'Completed'
    )

    return [pscustomobject]@{
        PSTypeName        = 'AzureScout.RunResult'
        Status            = $Status
        TenantId          = $TenantId
        OutputPath        = $OutputPath
        ReactFile         = $ReactFile
        EvidenceFile      = $EvidenceFile
        JsonFile          = $JsonFile
        SubscriptionCount = $SubscriptionCount
        ResourceCount     = $ResourceCount
        Duration          = $Duration
    }
}

function ConvertTo-AZSCTenantFolderName {
    [CmdletBinding()]
    [OutputType([string])]
    param(
        [string]$TenantName,
        [Parameter(Mandatory)]
        [string]$TenantId
    )

    $displayName = if ([string]::IsNullOrWhiteSpace($TenantName)) { 'tenant' } else { $TenantName }
    $safeName = ConvertTo-AZSCSafeRunName -Name $displayName
    $safeId = ConvertTo-AZSCSafeRunName -Name $TenantId
    $idToken = if ($safeId.Length -gt 8) { $safeId.Substring(0, 8) } else { $safeId }
    return '{0}_{1}' -f $safeName, $idToken
}

function Resolve-AZSCMultiTenantTarget {
    [CmdletBinding()]
    [OutputType([object[]])]
    param(
        [string[]]$RequestedTenantId,
        [switch]$AllAccessibleTenants
    )

    $accessibleTenants = @(Get-AZSCAccessibleTenant)
    $accessibleById = [System.Collections.Generic.Dictionary[string, object]]::new(
        [System.StringComparer]::OrdinalIgnoreCase
    )
    foreach ($tenant in $accessibleTenants) {
        $id = [string]$tenant.Id
        if (-not [string]::IsNullOrWhiteSpace($id) -and -not $accessibleById.ContainsKey($id)) {
            $accessibleById[$id] = $tenant
        }
    }

    $targetIds = if ($AllAccessibleTenants.IsPresent) {
        @($accessibleTenants | ForEach-Object { [string]$_.Id })
    }
    else {
        @($RequestedTenantId)
    }
    $targetIds = @($targetIds | Where-Object { -not [string]::IsNullOrWhiteSpace([string]$_) })
    if ($targetIds.Count -eq 0) {
        if ($AllAccessibleTenants.IsPresent) {
            throw 'No accessible tenants were discovered for the signed-in account.'
        }
        throw 'At least two tenant IDs or -AllAccessibleTenants is required for a multi-tenant run.'
    }

    $seen = [System.Collections.Generic.HashSet[string]]::new([System.StringComparer]::OrdinalIgnoreCase)
    $targets = foreach ($requestedId in $targetIds) {
        $id = ([string]$requestedId).Trim()
        if (-not $seen.Add($id)) { continue }

        $knownTenant = if ($accessibleById.ContainsKey($id)) { $accessibleById[$id] } else { $null }
        $name = if ($knownTenant -and -not [string]::IsNullOrWhiteSpace([string]$knownTenant.Name)) {
            [string]$knownTenant.Name
        }
        else {
            'Tenant {0}' -f $(if ($id.Length -gt 8) { $id.Substring(0, 8) } else { $id })
        }
        [pscustomobject]@{
            Id         = $id
            Name       = $name
            FolderName = ConvertTo-AZSCTenantFolderName -TenantName $name -TenantId $id
            Discovered = [bool]$knownTenant
        }
    }
    return @($targets)
}

function Get-AZSCMultiTenantSelectionSummary {
    [CmdletBinding()]
    param(
        [Parameter(Mandatory)]
        [System.Collections.IDictionary]$InvocationParameters,
        [switch]$AllAccessibleTenants
    )

    $runType = if ($InvocationParameters.ContainsKey('InventoryAndAssessment') -and
        [bool]$InvocationParameters['InventoryAndAssessment']) { 'Inventory and assessment' }
    elseif ($InvocationParameters.ContainsKey('Assessment') -and @($InvocationParameters['Assessment']).Count -gt 0) { 'Assessment' }
    else { 'Inventory' }

    $enabled = foreach ($name in @(
        'SecurityCenter', 'IncludeCosts', 'QuotaUsage', 'CheckResourceProviders', 'IncludeDevOps',
        'IncludeOkta', 'IncludeOnPremisesIdentity', 'IncludeTags', 'Heavy'
    )) {
        if ($InvocationParameters.ContainsKey($name) -and [bool]$InvocationParameters[$name]) { $name }
    }
    $skipped = foreach ($name in @('SkipAdvisory', 'SkipPolicy', 'SkipAPIs', 'SkipVMDetails', 'SkipDiagram')) {
        if ($InvocationParameters.ContainsKey($name) -and [bool]$InvocationParameters[$name]) { $name }
    }

    return [ordered]@{
        TenantSelection = if ($AllAccessibleTenants.IsPresent) { 'Every accessible tenant' } else { 'Selected tenant IDs' }
        RunType          = $runType
        Scope            = if ($InvocationParameters.ContainsKey('Scope')) { [string]$InvocationParameters['Scope'] } else { 'ArmOnly' }
        Categories       = if ($InvocationParameters.ContainsKey('Category')) { @($InvocationParameters['Category']) } else { @('All') }
        Assessments      = if ($InvocationParameters.ContainsKey('Assessment')) { @($InvocationParameters['Assessment']) } else { @() }
        OutputFormats    = if ($InvocationParameters.ContainsKey('OutputFormat')) { @($InvocationParameters['OutputFormat']) } else { @('All') }
        SubscriptionIds  = if ($InvocationParameters.ContainsKey('SubscriptionID')) { @($InvocationParameters['SubscriptionID']) } else { @() }
        ManagementGroups = if ($InvocationParameters.ContainsKey('ManagementGroup')) { @($InvocationParameters['ManagementGroup']) } else { @() }
        EnabledOptions   = @($enabled)
        SkippedOptions   = @($skipped)
    }
}

function Update-AZSCMultiTenantSummary {
    [CmdletBinding()]
    param([Parameter(Mandatory)]$Summary)

    $Summary.Counts.Total = @($Summary.Tenants).Count
    foreach ($state in @('Pending', 'Running', 'Completed', 'Failed', 'Partial', 'Interrupted', 'Skipped')) {
        $Summary.Counts[$state] = @($Summary.Tenants | Where-Object { $_.Status -eq $state }).Count
    }
    $Summary.Counts.Resources = [int64](
        @($Summary.Tenants | Measure-Object -Property ResourceCount -Sum).Sum
    )
    $Summary.Counts.Subscriptions = [int64](
        @($Summary.Tenants | Measure-Object -Property SubscriptionCount -Sum).Sum
    )
}

function Write-AZSCAtomicTextFile {
    [CmdletBinding()]
    param(
        [Parameter(Mandatory)][string]$Path,
        [Parameter(Mandatory)][AllowEmptyString()][string]$Content
    )

    $parent = Split-Path -Path $Path -Parent
    if (-not (Test-Path -LiteralPath $parent -PathType Container)) {
        $null = New-Item -ItemType Directory -Path $parent -Force -ErrorAction Stop
    }
    $temporaryPath = Join-Path $parent ('.{0}.{1}.tmp' -f (Split-Path $Path -Leaf), [guid]::NewGuid().ToString('N'))
    try {
        [System.IO.File]::WriteAllText($temporaryPath, $Content, [System.Text.UTF8Encoding]::new($false))
        Move-Item -LiteralPath $temporaryPath -Destination $Path -Force -ErrorAction Stop
    }
    finally {
        if (Test-Path -LiteralPath $temporaryPath) {
            Remove-Item -LiteralPath $temporaryPath -Force -ErrorAction SilentlyContinue
        }
    }
}

function Export-AZSCMultiTenantOverview {
    [CmdletBinding()]
    [OutputType([string])]
    param(
        [Parameter(Mandatory)]$Summary,
        [Parameter(Mandatory)][string]$OutputPath
    )

    Update-AZSCMultiTenantSummary -Summary $Summary
    $json = $Summary | ConvertTo-Json -Depth 20
    Write-AZSCAtomicTextFile -Path (Join-Path $OutputPath 'run-summary.json') -Content $json

    $safeJson = $json -replace '</', '<\/'
    $templatePath = Join-Path $PSScriptRoot 'report/templates/report-multi-tenant.html.template'
    $template = Get-Content -LiteralPath $templatePath -Raw -ErrorAction Stop
    $html = $template.Replace('/*__SCOUT_MULTI_TENANT_DATA__*/', $safeJson)
    $reportPath = Join-Path $OutputPath 'report-react.html'
    Write-AZSCAtomicTextFile -Path $reportPath -Content $html
    Write-AZSCAtomicTextFile -Path (Join-Path $OutputPath 'index.html') -Content $html
    return $reportPath
}

function Get-AZSCCheckpointParameterName {
    @('AzureEnvironment','ReportName','SubscriptionID','ManagementGroup','ResourceGroup','TagKey','TagValue',
      'SecurityCenter','Heavy','SkipAdvisory','SkipPolicy','SkipAPIs','IncludeTags','SkipVMDetails',
      'IncludeCosts','QuotaUsage','SkipDiagram','Lite','DeviceLogin','DiagramFullEnvironment','Scope',
      'SkipPermissionCheck','CheckResourceProviders','IncludeEntraPermissions','OutputFormat','Assessment',
      'InventoryAndAssessment','CollectOnly','Category','IncludeDevOps','DevOpsOrganization','IncludeOkta',
      'OktaOrganizationUrl','IncludeOnPremisesIdentity','ReportIdentity','DefaultReportMode','AppId','CertificatePath')
}

function Get-AZSCCheckpointParameters {
    param([System.Collections.IDictionary]$Parameters)
    $saved = [ordered]@{}
    foreach ($key in (Get-AZSCCheckpointParameterName)) {
        if ($Parameters.ContainsKey($key)) {
            $value = $Parameters[$key]
            $saved[$key] = if ($value -is [switch]) { [bool]$value } else { $value }
        }
    }
    return $saved
}

function Invoke-AZSCMultiTenantRun {
    [CmdletBinding()]
    param(
        [Parameter(Mandatory)]
        [System.Collections.IDictionary]$InvocationParameters,
        [string[]]$RequestedTenantId,
        [switch]$AllAccessibleTenants,
        [string]$ResumeRun,
        [switch]$RetryFailed,
        [string[]]$RetryTenant,
        [Parameter(DontShow)]
        [scriptblock]$TenantRunner
    )

    if ($InvocationParameters.ContainsKey('FromCollect') -and $InvocationParameters['FromCollect']) {
        throw '-FromCollect is an offline single-run operation and cannot be combined with a multi-tenant scan.'
    }
    if ($InvocationParameters.ContainsKey('PermissionAudit') -and [bool]$InvocationParameters['PermissionAudit']) {
        throw '-PermissionAudit does not produce tenant React reports and cannot be combined with a multi-tenant scan.'
    }
    if ($InvocationParameters.ContainsKey('Automation') -and [bool]$InvocationParameters['Automation']) {
        throw 'Multi-tenant scanning currently supports directly signed-in user accounts, not Automation managed identities.'
    }
    if ($InvocationParameters.ContainsKey('StorageAccount') -and $InvocationParameters['StorageAccount']) {
        throw 'Multi-tenant storage upload is not yet supported because tenant artifacts require isolated blob prefixes.'
    }
    if ($InvocationParameters.ContainsKey('Force') -and [bool]$InvocationParameters['Force']) {
        throw 'Use -RunName for a named multi-tenant umbrella folder; -Force is not supported because it removes run isolation.'
    }
    if ($AllAccessibleTenants.IsPresent -and $InvocationParameters.ContainsKey('AppId') -and $InvocationParameters['AppId']) {
        throw '-AllAccessibleTenants requires a signed-in user account. For a multi-tenant app registration, pass the consented tenant IDs explicitly.'
    }

    $initialContext = Get-AzContext -ErrorAction SilentlyContinue
    $moduleVersion = [string](Import-PowerShellDataFile (Join-Path (Split-Path $PSScriptRoot -Parent) 'AzureScout.psd1')).ModuleVersion
    $runLock = $null
    $summary = $null
    try {
        if ($ResumeRun) {
            $rootPath = (Resolve-Path -LiteralPath $ResumeRun -ErrorAction Stop).ProviderPath
            $runLock = [IO.File]::Open((Join-Path $rootPath '.run.lock'), 'OpenOrCreate', 'ReadWrite', 'None')
            $summary = Get-Content -LiteralPath (Join-Path $rootPath 'run-summary.json') -Raw | ConvertFrom-Json -Depth 30
            if ($summary.Schema -ne 'azure-scout/multi-tenant-run/v2') { throw 'Unsupported checkpoint schema. Resume requires a v2 checkpoint; start a separate single-tenant scan for older runs.' }
            if ($summary.ModuleVersion -ne $moduleVersion) { throw 'Checkpoint module version differs. Resume with the original AzureScout version or start a separate run.' }
            $savedParameters = @{}
            foreach ($property in $summary.Parameters.PSObject.Properties) {
                if ($property.Name -notin (Get-AZSCCheckpointParameterName)) { throw "Unsupported checkpoint parameter: $($property.Name)" }
                $savedParameters[$property.Name] = $property.Value
            }
            if ($savedParameters.ContainsKey('ReportIdentity')) {
                $identity = @{}; foreach ($p in $savedParameters.ReportIdentity.PSObject.Properties) { $identity[$p.Name] = $p.Value }; $savedParameters.ReportIdentity = $identity
            }
            foreach ($key in $InvocationParameters.Keys) {
                if ($key -in @('ResumeRun','RetryFailed','RetryTenant','NoWizard','PassThru','NoProgress','Verbose','Debug')) { continue }
                if ($key -notin @('Secret','CertificatePassword','DevOpsPat','OktaApiToken')) { throw "Resume uses saved settings; do not override '$key'." }
                $savedParameters[$key] = $InvocationParameters[$key]
            }
            $InvocationParameters = $savedParameters
            $summary.RootPath = $rootPath
            $summary.Counts = [ordered]@{}
            $tenantEntries = @($summary.Tenants)
            $seen = [Collections.Generic.HashSet[string]]::new([StringComparer]::OrdinalIgnoreCase)
            foreach ($entry in $tenantEntries) {
                if (-not $entry.TenantId -or -not $seen.Add($entry.TenantId)) { throw 'Invalid checkpoint: missing or duplicate tenant.' }
                if ($entry.Folder -ne (Split-Path $entry.Folder -Leaf) -or $entry.Folder -in @('.','..') -or [IO.Path]::IsPathRooted($entry.Folder)) { throw 'Invalid checkpoint tenant folder.' }
                if ($entry.Status -eq 'Running') {
                    $entry.Status = 'Interrupted'
                    if (@($entry.Attempts).Count -gt 0) { $entry.Attempts[-1].Status = 'Interrupted' }
                }
            }
            if ($RetryFailed -and $RetryTenant) { throw 'Choose -RetryFailed or -RetryTenant, not both.' }
            foreach ($id in @($RetryTenant)) { if ($id -and -not $seen.Contains($id)) { throw "Tenant '$id' is not in this checkpoint." } }
            $selectedEntries = @($tenantEntries | Where-Object {
                if ($RetryTenant) { $_.TenantId -in $RetryTenant }
                elseif ($RetryFailed) { $_.Status -in @('Failed','Partial','Interrupted') }
                else { $_.Status -in @('Pending','Running','Failed','Partial','Interrupted') }
            })
            if ($selectedEntries.Count -eq 0) { throw 'No tenants match the requested recovery selection.' }
            $targets = @($selectedEntries | ForEach-Object { [pscustomobject]@{ Id=$_.TenantId; Name=$_.TenantName; FolderName=$_.Folder } })
        }
        $loginParameters = @{
            AzureEnvironment = if ($InvocationParameters.ContainsKey('AzureEnvironment')) { $InvocationParameters['AzureEnvironment'] } else { 'AzureCloud' }
        }
        foreach ($name in @('DeviceLogin', 'AppId', 'Secret', 'CertificatePath', 'CertificatePassword')) {
            if ($InvocationParameters.ContainsKey($name)) { $loginParameters[$name] = $InvocationParameters[$name] }
        }
        if ($loginParameters.ContainsKey('AppId') -and -not $AllAccessibleTenants.IsPresent -and $RequestedTenantId -and @($RequestedTenantId).Count -gt 0) {
            $loginParameters.TenantID = [string]$RequestedTenantId[0]
        }
        elseif ($ResumeRun -and $loginParameters.ContainsKey('AppId')) { $loginParameters.TenantID = [string]$targets[0].Id }
        $null = Connect-AZSCLoginSession @loginParameters

        if ($ResumeRun) {
            $signedIn = Get-AzContext -ErrorAction Stop
            if (-not $signedIn -or $signedIn.Account.Id -ne $summary.Account) { throw 'Resume requires the original signed-in account.' }
            $summary.Status = 'Running'
            $summary.CompletedAt = $null
        }
        else {
        $targets = @(Resolve-AZSCMultiTenantTarget -RequestedTenantId $RequestedTenantId `
            -AllAccessibleTenants:$AllAccessibleTenants)
        $reportDir = if ($InvocationParameters.ContainsKey('ReportDir')) { [string]$InvocationParameters['ReportDir'] } else { $null }
        $runName = if ($InvocationParameters.ContainsKey('RunName')) { [string]$InvocationParameters['RunName'] } else { $null }
        $layout = Set-AZSCReportPath -ReportDir $reportDir -RunName $runName -ScopeId 'multi-tenant'
        $rootPath = [string]$layout.DefaultPath
        $runLock = [IO.File]::Open((Join-Path $rootPath '.run.lock'), 'OpenOrCreate', 'ReadWrite', 'None')

        $context = Get-AzContext -ErrorAction SilentlyContinue
        $account = if ($context -and $context.Account) { [string]$context.Account.Id } else { 'unknown account' }
        $tenantEntries = [System.Collections.Generic.List[object]]::new()
        foreach ($target in $targets) {
            $tenantEntries.Add([pscustomobject]@{
                TenantId          = $target.Id
                TenantName        = $target.Name
                Folder            = $target.FolderName
                Status            = 'Pending'
                StartedAt         = $null
                CompletedAt       = $null
                Duration          = $null
                SubscriptionCount = 0
                ResourceCount     = 0
                ReactReport       = $null
                Error             = $null
                Attempts          = @()
            })
        }
        $summary = [pscustomobject]@{
            Schema      = 'azure-scout/multi-tenant-run/v2'
            ModuleVersion = $moduleVersion
            Parameters  = Get-AZSCCheckpointParameters -Parameters $InvocationParameters
            RunId       = Split-Path $rootPath -Leaf
            RootPath    = $rootPath
            Account     = $account
            StartedAt   = (Get-Date).ToString('o')
            CompletedAt = $null
            Status      = 'Running'
            Selection   = Get-AZSCMultiTenantSelectionSummary -InvocationParameters $InvocationParameters `
                -AllAccessibleTenants:$AllAccessibleTenants
            Counts      = [ordered]@{
                Total = $targets.Count; Pending = $targets.Count; Running = 0; Completed = 0
                Failed = 0; Subscriptions = 0; Resources = 0
            }
            Tenants     = $tenantEntries
        }
        $selectedEntries = @($tenantEntries)
        }
        $overviewPath = Export-AZSCMultiTenantOverview -Summary $summary -OutputPath $rootPath

        if (-not $TenantRunner) {
            $TenantRunner = { param($Arguments) Invoke-AzureScout @Arguments }
        }

        for ($index = 0; $index -lt $targets.Count; $index++) {
            $target = $targets[$index]
            $entry = $selectedEntries[$index]
            $entry.Status = 'Running'
            $entry.Error = $null
            $entry.ReactReport = $null
            $entry.ResourceCount = 0
            $entry.SubscriptionCount = 0
            $entry.StartedAt = (Get-Date).ToString('o')
            $attemptNumber = @($entry.Attempts).Count + 1
            $attemptFolder = 'attempt-{0}-{1}' -f $attemptNumber, [guid]::NewGuid().ToString('N').Substring(0,8)
            $attempt = [pscustomobject]@{ Status='Running'; StartedAt=$entry.StartedAt; CompletedAt=$null; Duration=$null; ReactReport=$null; Error=$null; Folder=('{0}/{1}' -f $target.FolderName,$attemptFolder) }
            $entry.Attempts = @($entry.Attempts) + @($attempt)
            $null = Export-AZSCMultiTenantOverview -Summary $summary -OutputPath $rootPath

            $tenantStopwatch = [System.Diagnostics.Stopwatch]::StartNew()
            try {
                $tenantParent = Join-Path $rootPath $target.FolderName
                $null = New-Item -ItemType Directory -Path $tenantParent -Force -ErrorAction Stop
                if ((Get-Item -LiteralPath $tenantParent).Attributes -band [IO.FileAttributes]::ReparsePoint) { throw 'Tenant folder must not be a link or junction.' }
                $tenantPath = Join-Path $tenantParent $attemptFolder
                $null = New-Item -ItemType Directory -Path $tenantPath -ErrorAction Stop
                $childParameters = @{}
                foreach ($key in $InvocationParameters.Keys) { $childParameters[$key] = $InvocationParameters[$key] }
                foreach ($key in @('AllAccessibleTenants', 'RunName', 'ReportDir', 'Force', 'ResumeRun', 'RetryFailed', 'RetryTenant')) { $childParameters.Remove($key) }
                $childParameters.TenantID = @([string]$target.Id)
                $childParameters.ReportDir = $tenantPath
                $childParameters.Force = $true
                $childParameters.NoWizard = $true
                $childParameters.PassThru = $true
                $childParameters.NonInteractiveAuth = $true

                Write-Host ''
                Write-Host ('Tenant {0}/{1}: {2} ({3})' -f ($index + 1), $targets.Count, $target.Name, $target.Id) -ForegroundColor Cyan
                $childOutput = @(& $TenantRunner $childParameters)
                $runResult = @($childOutput | Where-Object { $_ -and $_.PSTypeNames -contains 'AzureScout.RunResult' }) |
                    Select-Object -Last 1

                if (-not $runResult -or $runResult.TenantId -ne $target.Id) { throw 'Child did not return a typed result for the requested tenant.' }
                if ($runResult.Status -notin @('Completed','Partial','Skipped')) { throw "Child reported status '$($runResult.Status)'." }
                $reactFile = [string]$runResult.ReactFile
                $artifacts = @($runResult.ReactFile, $runResult.EvidenceFile, $runResult.JsonFile | Where-Object { $_ })
                if ($runResult.Status -ne 'Skipped' -and $artifacts.Count -eq 0) { throw 'Child returned no report or evidence artifacts.' }
                foreach ($artifact in $artifacts) {
                    $relative = [IO.Path]::GetRelativePath($tenantPath, [IO.Path]::GetFullPath($artifact))
                    if ([IO.Path]::IsPathRooted($relative) -or $relative -match '^\.\.([\\/]|$)' -or -not (Test-Path -LiteralPath $artifact -PathType Leaf)) { throw 'Child artifact is missing or outside its attempt directory.' }
                }
                $entry.SubscriptionCount = if ($runResult) { [int]$runResult.SubscriptionCount } else { 0 }
                $entry.ResourceCount = if ($runResult) { [int]$runResult.ResourceCount } else { 0 }
                if ($reactFile) {
                    $entry.ReactReport = ([System.IO.Path]::GetRelativePath($rootPath, $reactFile) -replace '\\', '/')
                }
                $entry.Status = $runResult.Status
            }
            catch {
                $entry.Status = 'Failed'
                $entry.Error = $_.Exception.Message
                Write-Warning ("Tenant '{0}' failed and the remaining tenants will continue: {1}" -f $target.Name, $_.Exception.Message)
            }
            finally {
                $tenantStopwatch.Stop()
                $entry.Duration = $tenantStopwatch.Elapsed.ToString('dd\:hh\:mm\:ss\.fff')
                $entry.CompletedAt = (Get-Date).ToString('o')
                foreach ($field in @('Status','CompletedAt','Duration','ReactReport','Error')) { $attempt.$field = $entry.$field }
                $null = Export-AZSCMultiTenantOverview -Summary $summary -OutputPath $rootPath
            }
        }

        $summary.CompletedAt = (Get-Date).ToString('o')
        $summary.Status = if (@($tenantEntries | Where-Object { $_.Status -ne 'Completed' }).Count -gt 0) {
            'CompletedWithErrors'
        }
        else { 'Completed' }
        $overviewPath = Export-AZSCMultiTenantOverview -Summary $summary -OutputPath $rootPath

        Write-Host ''
        Write-Host 'Multi-tenant run complete.' -ForegroundColor Green
        Write-Host ' Overview : ' -NoNewline -ForegroundColor DarkGray
        Write-Host $overviewPath -ForegroundColor Cyan

        return [pscustomobject]@{
            PSTypeName = 'AzureScout.MultiTenantRunResult'
            Status     = $summary.Status
            OutputPath = $rootPath
            Overview   = $overviewPath
            Summary    = Join-Path $rootPath 'run-summary.json'
            Tenants    = @($tenantEntries)
        }
    }
    finally {
        if ($runLock) { $runLock.Dispose() }
        if ($initialContext) {
            try { $null = Set-AzContext -Context $initialContext -ErrorAction Stop }
            catch { Write-Warning "The multi-tenant run finished, but the original Azure context could not be restored: $($_.Exception.Message)" }
        }
    }
}