#requires -Version 7.2
[CmdletBinding()]
param(
    [Parameter(Mandatory)]
    [ValidatePattern('^[0-9a-fA-F-]{36}$')]
    [string]$SubscriptionId,

    [ValidateSet('westeurope')]
    [string]$Location = 'westeurope',

    [ValidatePattern('^rg-machine-configuration-deep-dive-we$')]
    [string]$ResourceGroupName = 'rg-machine-configuration-deep-dive-we',

    [Parameter(Mandatory)]
    [ValidateSet('Preflight', 'Foundation', 'Author', 'BrokenAccess', 'Audit', 'Drift', 'ExpandRing', 'Enforce', 'Validate')]
    [string]$Stage
)

Set-StrictMode -Version Latest
$ErrorActionPreference = 'Stop'
$ProgressPreference = 'SilentlyContinue'

$script:Root = Split-Path -Parent $PSScriptRoot
$script:PrivateRoot = Join-Path $script:Root '.private'
$script:ContextPath = Join-Path $script:PrivateRoot 'runtime-context.json'
$script:TranscriptPath = Join-Path $script:Root 'validation-transcript.md'
$script:VnetName = 'vnet-machine-config-we'
$script:WorkloadSubnetName = 'snet-machine-config-workload-we'
$script:PrivateEndpointSubnetName = 'snet-machine-config-private-endpoints-we'
$script:NsgName = 'nsg-machine-config-workload-we'
$script:NatName = 'nat-machine-config-we'
$script:NatPublicIpName = 'pip-nat-machine-config-we'
$script:IdentityName = 'id-machine-config-package-we'
$script:WorkspaceName = 'law-machine-config-deep-dive-we'
$script:DcrName = 'dcr-machine-config-events-we'
$script:StorageContainer = 'machine-configuration'
$script:Vm2022 = 'vm-mc-ws2022-we'
$script:Vm2025 = 'vm-mc-ws2025-we'
$script:GuestConfigRoleId = '088ab73d-1256-47ae-bea9-9de8e7131f31'
$script:BlobReaderRoleId = '2a2b9908-6ea1-4ae2-8e65-a410df84e7d1'
$script:BlobContributorRoleId = 'ba92f5b4-2d11-453d-a403-e96b0029c9fe'
$script:PolicyApi = '2025-11-01'
$script:GuestConfigApi = '2024-04-05'
$script:RemediationApi = '2024-10-01'
$script:DcrApi = '2023-03-11'
$script:Tags = @{
    Workshop = 'MachineConfiguration-DeepDive'
    ManagedBy = 'AzureCLI-PowerShell'
    Purpose = 'AzureWorkshopz'
}

function Protect-Text {
    param([AllowNull()][string]$Text)
    if ($null -eq $Text) { return '' }
    $safe = $Text -replace [regex]::Escape($SubscriptionId), '<subscription-redacted>'
    $safe = $safe -replace '(?i)/subscriptions/[0-9a-f-]{36}', '/subscriptions/<redacted>'
    $safe = $safe -replace '(?i)\b[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}\b', '<guid-redacted>'
    $safe = $safe -replace '(?i)https://[a-z0-9]{3,24}\.blob\.core\.windows\.net', 'https://<storage-redacted>.blob.core.windows.net'
    $safe = $safe -replace '(?i)\b\d{1,3}(?:\.\d{1,3}){3}\b', '<ip-redacted>'
    if (Test-Path -LiteralPath $script:ContextPath) {
        try {
            $context = Get-Content -Raw -LiteralPath $script:ContextPath | ConvertFrom-Json
            foreach ($property in @('StorageAccountName', 'AuthorObjectId')) {
                $value = [string]$context.$property
                if ($value) { $safe = $safe -replace [regex]::Escape($value), "<$property-redacted>" }
            }
        }
        catch { }
    }
    return $safe
}

function Write-Lab {
    param([string]$Message, [ValidateSet('INFO', 'PASS', 'WAIT', 'WARN')][string]$Level = 'INFO')
    $line = '[{0}] {1}' -f $Level, (Protect-Text $Message)
    Write-Host $line
    if (-not (Test-Path -LiteralPath $script:TranscriptPath)) { "# Sanitized validation transcript`n" | Out-File -LiteralPath $script:TranscriptPath -Encoding utf8 }
    "- ``$line``" | Out-File -LiteralPath $script:TranscriptPath -Append -Encoding utf8
}

function Invoke-Az {
    param(
        [Parameter(Mandatory)][string[]]$Arguments,
        [switch]$Json,
        [switch]$AllowFailure,
        [switch]$SensitiveArguments
    )
    $effectiveArguments = @($Arguments) + '--only-show-errors'
    $previousPythonWarnings = $env:PYTHONWARNINGS
    $env:PYTHONWARNINGS = 'ignore'
    try { $output = & az @effectiveArguments 2>&1 }
    finally { $env:PYTHONWARNINGS = $previousPythonWarnings }
    $code = $LASTEXITCODE
    if ($code -ne 0 -and -not $AllowFailure) {
        $message = if ($SensitiveArguments) { 'Azure CLI failed while processing protected arguments.' } else { Protect-Text ($output -join "`n") }
        throw $message
    }
    if ($AllowFailure) { return [pscustomobject]@{ ExitCode = $code; Output = Protect-Text ($output -join "`n") } }
    if ($Json) {
        $value = ($output -join "`n").Trim()
        if (-not $value) { return $null }
        try { return $value | ConvertFrom-Json -Depth 100 }
        catch {
            $firstObject = $value.IndexOf('{')
            $firstArray = $value.IndexOf('[')
            $starts = @(@($firstObject, $firstArray) | Where-Object { $_ -ge 0 })
            if ($starts.Count -eq 0) { throw }
            return $value.Substring(($starts | Measure-Object -Minimum).Minimum) | ConvertFrom-Json -Depth 100
        }
    }
    return ($output -join "`n").Trim()
}

function Assert-ExactSubscription {
    $active = Invoke-Az -Arguments @('account', 'show', '--query', '{id:id,state:state,name:name}', '-o', 'json') -Json
    if ($active.state -ne 'Enabled' -or $active.id -ne $SubscriptionId) { throw 'The active Azure CLI subscription is not the exact enabled subscription supplied to this script.' }
    Write-Lab "Exact active subscription confirmed: $($active.name) (identifier redacted)." PASS
}

function Get-Context {
    if (-not (Test-Path -LiteralPath $script:ContextPath)) { throw 'Runtime context is missing. Run Foundation first.' }
    return Get-Content -Raw -LiteralPath $script:ContextPath | ConvertFrom-Json
}

function Save-Context {
    param([Parameter(Mandatory)]$Context)
    New-Item -ItemType Directory -Path $script:PrivateRoot -Force | Out-Null
    $Context | ConvertTo-Json -Depth 20 | Set-Content -LiteralPath $script:ContextPath -Encoding utf8
}

function Get-TagArguments {
    param([hashtable]$Additional = @{})
    $all = [ordered]@{}
    foreach ($entry in $script:Tags.GetEnumerator()) { $all[$entry.Key] = $entry.Value }
    foreach ($entry in $Additional.GetEnumerator()) { $all[$entry.Key] = $entry.Value }
    return @($all.GetEnumerator() | ForEach-Object { "$($_.Key)=$($_.Value)" })
}

function Wait-Until {
    param(
        [Parameter(Mandatory)][scriptblock]$Condition,
        [Parameter(Mandatory)][string]$Description,
        [int]$TimeoutSeconds = 2700,
        [int]$IntervalSeconds = 20
    )
    $deadline = (Get-Date).AddSeconds($TimeoutSeconds)
    do {
        try {
            if (& $Condition) { Write-Lab "$Description is ready." PASS; return }
        }
        catch { }
        if ((Get-Date) -ge $deadline) { throw "Timed out waiting for $Description." }
        Write-Lab "$Description is still converging." WAIT
        Start-Sleep -Seconds $IntervalSeconds
    } while ($true)
}

function New-StrongPassword {
    $upper = 'ABCDEFGHJKLMNPQRSTUVWXYZ'
    $lower = 'abcdefghijkmnopqrstuvwxyz'
    $digits = '23456789'
    $special = '!@#%*-_+'
    $all = $upper + $lower + $digits + $special
    $chars = @(
        $upper[(Get-Random -Maximum $upper.Length)],
        $lower[(Get-Random -Maximum $lower.Length)],
        $digits[(Get-Random -Maximum $digits.Length)],
        $special[(Get-Random -Maximum $special.Length)]
    )
    1..28 | ForEach-Object { $chars += $all[(Get-Random -Maximum $all.Length)] }
    return -join ($chars | Sort-Object { Get-Random })
}

function New-StorageName {
    $bytes = [Text.Encoding]::UTF8.GetBytes("$SubscriptionId|$ResourceGroupName|machine-configuration")
    $hash = [Convert]::ToHexString([Security.Cryptography.SHA256]::HashData($bytes)).ToLowerInvariant()
    return "stmc$($hash.Substring(0, 16))"
}

function Invoke-GuestScript {
    param(
        [Parameter(Mandatory)][string]$VmName,
        [Parameter(Mandatory)][string]$ScriptText,
        [hashtable]$Parameters = @{}
    )
    New-Item -ItemType Directory -Path $script:PrivateRoot -Force | Out-Null
    $path = Join-Path $script:PrivateRoot ("run-command-{0}.ps1" -f [guid]::NewGuid().ToString('N'))
    try {
        Set-Content -LiteralPath $path -Value $ScriptText -Encoding utf8
        $arguments = @('vm', 'run-command', 'invoke', '-g', $ResourceGroupName, '-n', $VmName, '--command-id', 'RunPowerShellScript', '--scripts', "@$path")
        if ($Parameters.Count -gt 0) {
            $arguments += '--parameters'
            foreach ($entry in $Parameters.GetEnumerator()) { $arguments += "$($entry.Key)=$($entry.Value)" }
        }
        $arguments += @('--query', 'value[0].message', '-o', 'tsv')
        return Invoke-Az -Arguments $arguments
    }
    finally {
        if (Test-Path -LiteralPath $path) { Remove-Item -LiteralPath $path -Force }
    }
}

function Wait-GuestAgent {
    param([string]$VmName)
    Wait-Until -Description "$VmName guest agent" -TimeoutSeconds 1200 -IntervalSeconds 20 -Condition {
        $result = Invoke-Az -Arguments @('vm', 'get-instance-view', '-g', $ResourceGroupName, '-n', $VmName, '--query', 'instanceView.vmAgent.statuses[?code==`ProvisioningState/succeeded`] | length(@)', '-o', 'tsv') -AllowFailure
        $result.ExitCode -eq 0 -and $result.Output.Trim() -eq '1'
    }
}

function Initialize-GuestBaseline {
    param([string]$VmName, [ValidateSet('Baseline', 'Application')][string]$Role)
    $iisScript = if ($Role -eq 'Application') {
        "Install-WindowsFeature Web-Server -IncludeManagementTools | Out-Null; New-Item -ItemType Directory -Path 'C:\inetpub\wwwroot' -Force | Out-Null; Set-Content -LiteralPath 'C:\inetpub\wwwroot\index.html' -Value '<html><body><h1>Azure Workshopz</h1><p>Machine Configuration application server proof.</p></body></html>' -Encoding ascii"
    }
    else { "Remove-WindowsFeature Web-Server -Restart:`$false | Out-Null" }
    $scriptText = @"
`$ErrorActionPreference = 'Stop'
New-Item -Path 'HKLM:\SOFTWARE\AzureWorkshopz\MachineConfiguration' -Force | Out-Null
Set-ItemProperty -Path 'HKLM:\SOFTWARE\AzureWorkshopz\MachineConfiguration' -Name Environment -Value Workshop -Type String
Set-ItemProperty -Path 'HKLM:\SOFTWARE\AzureWorkshopz\MachineConfiguration' -Name BaselineVersion -Value '1.1.0' -Type String
Set-NetFirewallProfile -Profile Domain,Private,Public -Enabled True
Disable-WindowsOptionalFeature -Online -FeatureName SMB1Protocol -NoRestart -ErrorAction SilentlyContinue | Out-Null
Set-Service -Name EventLog -StartupType Automatic
Start-Service -Name EventLog
New-Item -ItemType Directory -Path 'C:\AzureWorkshopz\MachineConfiguration' -Force | Out-Null
`$content = "Azure Workshopz Machine Configuration`r`nBaselineVersion=1.1.0`r`nDesiredState=Compliant`r`n"
Set-Content -LiteralPath 'C:\AzureWorkshopz\MachineConfiguration\desired-state.txt' -Value `$content -Encoding ascii -NoNewline
$iisScript
if (-not [System.Diagnostics.EventLog]::SourceExists('AzureWorkshopzMachineConfiguration')) { New-EventLog -LogName Application -Source AzureWorkshopzMachineConfiguration }
Write-EventLog -LogName Application -Source AzureWorkshopzMachineConfiguration -EntryType Information -EventId 4100 -Message 'Workshop desired state initialized.'
[pscustomobject]@{ Hostname=`$env:COMPUTERNAME; Role='$Role'; FirewallEnabled=(@(Get-NetFirewallProfile | Where-Object Enabled).Count -eq 3); Smb1Enabled=(Get-WindowsOptionalFeature -Online -FeatureName SMB1Protocol).State; EventLog=(Get-CimInstance Win32_Service -Filter "Name='EventLog'").StartMode; IisInstalled=[bool](Get-WindowsFeature Web-Server).Installed }
"@
    $result = Invoke-GuestScript -VmName $VmName -ScriptText $scriptText
    Write-Lab "$VmName initialized for the $Role role: $result" PASS
}

function Install-GuestConfigurationExtension {
    param([string]$VmName)
    Invoke-Az -Arguments @('vm', 'extension', 'set', '-g', $ResourceGroupName, '--vm-name', $VmName, '--publisher', 'Microsoft.GuestConfiguration', '--name', 'ConfigurationforWindows', '--enable-auto-upgrade', 'true', '--no-wait') | Out-Null
    Wait-Until -Description "$VmName Machine Configuration extension" -TimeoutSeconds 1200 -IntervalSeconds 20 -Condition {
        $state = Invoke-Az -Arguments @('vm', 'extension', 'show', '-g', $ResourceGroupName, '--vm-name', $VmName, '-n', 'ConfigurationforWindows', '--query', 'provisioningState', '-o', 'tsv') -AllowFailure
        $state.ExitCode -eq 0 -and $state.Output.Trim() -eq 'Succeeded'
    }
}

function New-Foundation {
    Assert-ExactSubscription
    foreach ($provider in @('Microsoft.Compute', 'Microsoft.Network', 'Microsoft.Storage', 'Microsoft.GuestConfiguration', 'Microsoft.Authorization', 'Microsoft.PolicyInsights', 'Microsoft.Insights', 'Microsoft.OperationalInsights', 'Microsoft.ManagedIdentity')) {
        $providerState = Invoke-Az -Arguments @('provider', 'show', '--namespace', $provider, '--query', 'registrationState', '-o', 'tsv') -AllowFailure
        if ($providerState.ExitCode -ne 0 -or $providerState.Output.Trim() -ne 'Registered') {
            Invoke-Az -Arguments @('provider', 'register', '--namespace', $provider, '--wait') | Out-Null
        }
    }

    if ((Invoke-Az -Arguments @('group', 'exists', '-n', $ResourceGroupName, '-o', 'tsv')).Trim() -eq 'true') { throw 'The exact workshop resource group already exists.' }
    $prefixes = Invoke-Az -Arguments @('network', 'vnet', 'list', '--query', '[].addressSpace.addressPrefixes[]', '-o', 'tsv')
    if ($prefixes -match '10\.160\.') { throw 'The proposed 10.160.0.0/16 range overlaps an existing VNet.' }

    $tags = Get-TagArguments
    Invoke-Az -Arguments (@('group', 'create', '-n', $ResourceGroupName, '-l', $Location, '--tags') + $tags) | Out-Null
    Invoke-Az -Arguments (@('network', 'public-ip', 'create', '-g', $ResourceGroupName, '-n', $script:NatPublicIpName, '-l', $Location, '--sku', 'Standard', '--allocation-method', 'Static', '--tags') + $tags) | Out-Null
    Invoke-Az -Arguments (@('network', 'nat', 'gateway', 'create', '-g', $ResourceGroupName, '-n', $script:NatName, '-l', $Location, '--public-ip-addresses', $script:NatPublicIpName, '--idle-timeout', '10', '--tags') + $tags) | Out-Null
    Invoke-Az -Arguments (@('network', 'nsg', 'create', '-g', $ResourceGroupName, '-n', $script:NsgName, '-l', $Location, '--tags') + $tags) | Out-Null
    Invoke-Az -Arguments (@('network', 'vnet', 'create', '-g', $ResourceGroupName, '-n', $script:VnetName, '-l', $Location, '--address-prefixes', '10.160.0.0/16', '--subnet-name', $script:WorkloadSubnetName, '--subnet-prefixes', '10.160.1.0/24', '--tags') + $tags) | Out-Null
    Invoke-Az -Arguments @('network', 'vnet', 'subnet', 'update', '-g', $ResourceGroupName, '--vnet-name', $script:VnetName, '-n', $script:WorkloadSubnetName, '--network-security-group', $script:NsgName, '--nat-gateway', $script:NatName) | Out-Null
    Invoke-Az -Arguments @('network', 'vnet', 'subnet', 'create', '-g', $ResourceGroupName, '--vnet-name', $script:VnetName, '-n', $script:PrivateEndpointSubnetName, '--address-prefixes', '10.160.2.0/24', '--private-endpoint-network-policies', 'Disabled') | Out-Null

    $identity = Invoke-Az -Arguments (@('identity', 'create', '-g', $ResourceGroupName, '-n', $script:IdentityName, '-l', $Location, '--tags') + $tags + @('-o', 'json')) -Json
    $storageName = New-StorageName
    $available = Invoke-Az -Arguments @('storage', 'account', 'check-name', '-n', $storageName, '--query', 'nameAvailable', '-o', 'tsv')
    if ($available.Trim() -ne 'true') { throw 'The deterministic Storage account name is unavailable.' }
    Invoke-Az -Arguments (@('storage', 'account', 'create', '-g', $ResourceGroupName, '-n', $storageName, '-l', $Location, '--sku', 'Standard_LRS', '--kind', 'StorageV2', '--https-only', 'true', '--min-tls-version', 'TLS1_2', '--allow-blob-public-access', 'false', '--allow-shared-key-access', 'false', '--public-network-access', 'Enabled', '--default-action', 'Deny', '--tags') + $tags) | Out-Null
    $storageId = Invoke-Az -Arguments @('storage', 'account', 'show', '-g', $ResourceGroupName, '-n', $storageName, '--query', 'id', '-o', 'tsv')

    Invoke-Az -Arguments (@('network', 'private-dns', 'zone', 'create', '-g', $ResourceGroupName, '-n', 'privatelink.blob.core.windows.net', '--tags') + $tags) | Out-Null
    $vnetId = Invoke-Az -Arguments @('network', 'vnet', 'show', '-g', $ResourceGroupName, '-n', $script:VnetName, '--query', 'id', '-o', 'tsv')
    Invoke-Az -Arguments @('network', 'private-dns', 'link', 'vnet', 'create', '-g', $ResourceGroupName, '-z', 'privatelink.blob.core.windows.net', '-n', 'link-machine-config-vnet-we', '-v', $vnetId, '-e', 'false') | Out-Null
    Invoke-Az -Arguments @('network', 'private-endpoint', 'create', '-g', $ResourceGroupName, '-n', 'pe-machine-config-blob-we', '-l', $Location, '--vnet-name', $script:VnetName, '--subnet', $script:PrivateEndpointSubnetName, '--private-connection-resource-id', $storageId, '--group-id', 'blob', '--connection-name', 'psc-machine-config-blob-we') | Out-Null
    $zoneId = Invoke-Az -Arguments @('network', 'private-dns', 'zone', 'show', '-g', $ResourceGroupName, '-n', 'privatelink.blob.core.windows.net', '--query', 'id', '-o', 'tsv')
    Invoke-Az -Arguments @('network', 'private-endpoint', 'dns-zone-group', 'create', '-g', $ResourceGroupName, '--endpoint-name', 'pe-machine-config-blob-we', '-n', 'default', '--private-dns-zone', $zoneId, '--zone-name', 'blob') | Out-Null

    $workspace = Invoke-Az -Arguments (@('monitor', 'log-analytics', 'workspace', 'create', '-g', $ResourceGroupName, '-n', $script:WorkspaceName, '-l', $Location, '--retention-time', '30', '--tags') + $tags + @('-o', 'json')) -Json

    $adminPassword = New-StrongPassword
    try {
        foreach ($vm in @(
            @{ Name = $script:Vm2022; Ip = '10.160.1.4'; Image = 'MicrosoftWindowsServer:WindowsServer:2022-datacenter-g2:latest'; Ring = 'Pilot'; Role = 'Baseline' },
            @{ Name = $script:Vm2025; Ip = '10.160.1.5'; Image = 'MicrosoftWindowsServer:WindowsServer:2025-datacenter-g2:latest'; Ring = 'Pliot'; Role = 'Application' }
        )) {
            $nicName = "nic-$($vm.Name)"
            Invoke-Az -Arguments (@('network', 'nic', 'create', '-g', $ResourceGroupName, '-n', $nicName, '-l', $Location, '--vnet-name', $script:VnetName, '--subnet', $script:WorkloadSubnetName, '--private-ip-address', $vm.Ip, '--tags') + $tags) | Out-Null
            $vmTags = Get-TagArguments @{ ConfigurationRing = $vm.Ring; ServerRole = $vm.Role }
            Invoke-Az -Arguments (@('vm', 'create', '-g', $ResourceGroupName, '-n', $vm.Name, '-l', $Location, '--nics', $nicName, '--image', $vm.Image, '--size', 'Standard_B2s', '--admin-username', 'workshopadmin', '--admin-password', $adminPassword, '--authentication-type', 'password', '--security-type', 'Standard', '--storage-sku', 'StandardSSD_LRS', '--enable-agent', 'true', '--enable-auto-update', 'false', '--patch-mode', 'Manual', '--tags') + $vmTags) -SensitiveArguments | Out-Null
            Invoke-Az -Arguments @('vm', 'identity', 'assign', '-g', $ResourceGroupName, '-n', $vm.Name, '--identities', '[system]', $identity.id) | Out-Null
        }
    }
    finally { $adminPassword = $null; [GC]::Collect() }

    $context = [ordered]@{
        SubscriptionId = $SubscriptionId
        ResourceGroupName = $ResourceGroupName
        StorageAccountName = $storageName
        StorageAccountId = $storageId
        ManagedIdentityResourceId = $identity.id
        ManagedIdentityPrincipalId = $identity.principalId
        WorkspaceResourceId = $workspace.id
        AuditInitiativeId = $null
        EnforceInitiativeId = $null
        AuditAssignmentId = $null
        EnforceAssignmentId = $null
        AuthorObjectId = $null
        UploadRoleAssignmentId = $null
    }
    Save-Context $context

    foreach ($vmName in @($script:Vm2022, $script:Vm2025)) { Wait-GuestAgent $vmName }
    Initialize-GuestBaseline -VmName $script:Vm2022 -Role Baseline
    Initialize-GuestBaseline -VmName $script:Vm2025 -Role Application
    Install-GuestConfigurationExtension -VmName $script:Vm2022

    foreach ($vmName in @($script:Vm2022, $script:Vm2025)) {
        Invoke-Az -Arguments @('vm', 'extension', 'set', '-g', $ResourceGroupName, '--vm-name', $vmName, '--publisher', 'Microsoft.Azure.Monitor', '--name', 'AzureMonitorWindowsAgent', '--enable-auto-upgrade', 'true', '--no-wait') | Out-Null
    }

    $dcrBodyPath = Join-Path $script:PrivateRoot 'dcr.json'
    $dcrBody = [ordered]@{
        location = $Location
        properties = [ordered]@{
            dataSources = [ordered]@{ windowsEventLogs = @([ordered]@{ name = 'WorkshopEvents'; streams = @('Microsoft-Event'); xPathQueries = @("Application!*[System[Provider[@Name='AzureWorkshopzMachineConfiguration']]]", 'System!*[System[(Level=1 or Level=2 or Level=3)]]') }) }
            destinations = [ordered]@{ logAnalytics = @([ordered]@{ workspaceResourceId = $workspace.id; name = 'workshopWorkspace' }) }
            dataFlows = @([ordered]@{ streams = @('Microsoft-Event'); destinations = @('workshopWorkspace') })
        }
    }
    $dcrBody | ConvertTo-Json -Depth 20 | Set-Content -LiteralPath $dcrBodyPath -Encoding utf8
    $dcrId = "/subscriptions/$SubscriptionId/resourceGroups/$ResourceGroupName/providers/Microsoft.Insights/dataCollectionRules/$($script:DcrName)"
    Invoke-Az -Arguments @('rest', '--method', 'put', '--url', "https://management.azure.com$dcrId`?api-version=$($script:DcrApi)", '--body', "@$dcrBodyPath") | Out-Null
    foreach ($vmName in @($script:Vm2022, $script:Vm2025)) {
        $vmId = Invoke-Az -Arguments @('vm', 'show', '-g', $ResourceGroupName, '-n', $vmName, '--query', 'id', '-o', 'tsv')
        $associationBody = Join-Path $script:PrivateRoot "dcra-$vmName.json"
        @{ properties = @{ dataCollectionRuleId = $dcrId } } | ConvertTo-Json -Depth 5 | Set-Content -LiteralPath $associationBody -Encoding utf8
        Invoke-Az -Arguments @('rest', '--method', 'put', '--url', "https://management.azure.com$vmId/providers/Microsoft.Insights/dataCollectionRuleAssociations/dcra-machine-config?api-version=$($script:DcrApi)", '--body', "@$associationBody") | Out-Null
    }
    Write-Lab 'Foundation deployed: two private Windows VMs, NAT, private Storage path, managed identity, Log Analytics, AMA, and DCR.' PASS
}

function Get-AuthorObjectId {
    $objectId = Invoke-Az -Arguments @('ad', 'signed-in-user', 'show', '--query', 'id', '-o', 'tsv') -AllowFailure
    if ($objectId.ExitCode -eq 0 -and $objectId.Output -match '^[0-9a-f-]{36}$') { return $objectId.Output.Trim() }
    $tokenResult = Invoke-Az -Arguments @('account', 'get-access-token', '--resource', 'https://management.azure.com/', '-o', 'json') -Json
    try {
        $payload = [string]$tokenResult.accessToken.Split('.')[1]
        $payload = $payload.Replace('-', '+').Replace('_', '/')
        while ($payload.Length % 4) { $payload += '=' }
        $claims = [Text.Encoding]::UTF8.GetString([Convert]::FromBase64String($payload)) | ConvertFrom-Json
        $oid = [string]$claims.oid
        if ($oid -notmatch '^[0-9a-f-]{36}$') { throw 'The active Azure token does not contain an object ID claim.' }
        return $oid
    }
    finally {
        $tokenResult = $null
        $payload = $null
        $claims = $null
        [GC]::Collect()
    }
}

function Build-AndUploadPackages {
    Assert-ExactSubscription
    $context = Get-Context
    $authorObjectId = Get-AuthorObjectId
    $context.AuthorObjectId = $authorObjectId
    Save-Context $context
    $clientIp = (Invoke-RestMethod -Uri 'https://api.ipify.org' -TimeoutSec 20).Trim()
    Invoke-Az -Arguments @('storage', 'account', 'update', '-g', $ResourceGroupName, '-n', $context.StorageAccountName, '--public-network-access', 'Enabled') | Out-Null
    Invoke-Az -Arguments @('storage', 'account', 'network-rule', 'add', '-g', $ResourceGroupName, '--account-name', $context.StorageAccountName, '--ip-address', $clientIp) | Out-Null
    $uploadRoleId = [guid]::NewGuid().Guid
    Invoke-Az -Arguments @('role', 'assignment', 'create', '--name', $uploadRoleId, '--assignee-object-id', $authorObjectId, '--assignee-principal-type', 'User', '--role', $script:BlobContributorRoleId, '--scope', $context.StorageAccountId) | Out-Null
    $dataPlaneReady = $false
    for ($attempt = 1; $attempt -le 20; $attempt++) {
        $probe = Invoke-Az -Arguments @('storage', 'container', 'exists', '--account-name', $context.StorageAccountName, '--name', $script:StorageContainer, '--auth-mode', 'login', '-o', 'json') -AllowFailure
        if ($probe.ExitCode -eq 0) {
            $dataPlaneReady = $true
            break
        }
        Write-Lab "Waiting for temporary Blob Data Contributor propagation ($attempt/20)." WAIT
        Start-Sleep -Seconds 15
    }
    if (-not $dataPlaneReady) { throw 'Temporary authoring RBAC did not reach the Storage data plane within five minutes.' }
    try {
        Invoke-Az -Arguments @('storage', 'container', 'create', '--account-name', $context.StorageAccountName, '--name', $script:StorageContainer, '--auth-mode', 'login', '--public-access', 'off') | Out-Null
        $auditUri = "https://$($context.StorageAccountName).blob.core.windows.net/$($script:StorageContainer)/WorkshopWindowsBaseline-1.0.0-Audit.zip"
        $enforceUri = "https://$($context.StorageAccountName).blob.core.windows.net/$($script:StorageContainer)/WorkshopWindowsBaseline-1.1.0-AuditAndSet.zip"
        $authoringScript = Join-Path $PSScriptRoot 'New-WorkshopMachineConfigurationPackage.ps1'
        & pwsh -NoLogo -NoProfile -File $authoringScript -SubscriptionId $SubscriptionId -ManagedIdentityResourceId $context.ManagedIdentityResourceId -AuditContentUri $auditUri -EnforceContentUri $enforceUri -SkipLocalAudit | Out-Host
        if ($LASTEXITCODE -ne 0) { throw 'The isolated package authoring process failed.' }
        Invoke-Az -Arguments @('storage', 'blob', 'upload', '--account-name', $context.StorageAccountName, '--container-name', $script:StorageContainer, '--name', 'WorkshopWindowsBaseline-1.0.0-Audit.zip', '--file', (Join-Path $script:Root 'packages\WorkshopWindowsBaseline-1.0.0-Audit.zip'), '--auth-mode', 'login', '--overwrite', 'true') | Out-Null
        Invoke-Az -Arguments @('storage', 'blob', 'upload', '--account-name', $context.StorageAccountName, '--container-name', $script:StorageContainer, '--name', 'WorkshopWindowsBaseline-1.1.0-AuditAndSet.zip', '--file', (Join-Path $script:Root 'packages\WorkshopWindowsBaseline-1.1.0-AuditAndSet.zip'), '--auth-mode', 'login', '--overwrite', 'true') | Out-Null
    }
    finally {
        $uploadAssignmentResourceId = "$($context.StorageAccountId)/providers/Microsoft.Authorization/roleAssignments/$uploadRoleId"
        Invoke-Az -Arguments @('role', 'assignment', 'delete', '--ids', $uploadAssignmentResourceId) -AllowFailure | Out-Null
        Invoke-Az -Arguments @('storage', 'account', 'network-rule', 'remove', '-g', $ResourceGroupName, '--account-name', $context.StorageAccountName, '--ip-address', $clientIp) -AllowFailure | Out-Null
        Invoke-Az -Arguments @('storage', 'account', 'update', '-g', $ResourceGroupName, '-n', $context.StorageAccountName, '--public-network-access', 'Disabled') -AllowFailure | Out-Null
        $clientIp = $null
    }
    $context.AuthorObjectId = $authorObjectId
    $context.UploadRoleAssignmentId = $null
    Save-Context $context
    Write-Lab 'Packages were uploaded with Entra authentication; temporary upload access was removed and public Storage access was disabled.' PASS
}

function Publish-PolicyDefinition {
    param([Parameter(Mandatory)]$Definition, [switch]$BadHash)
    $source = Get-Content -Raw -LiteralPath $Definition.Path | ConvertFrom-Json -Depth 100
    if ($BadHash -and $source.properties.metadata.guestConfiguration.contentHash) {
        $hash = [string]$source.properties.metadata.guestConfiguration.contentHash
        $source.properties.metadata.guestConfiguration.contentHash = ('0' + $hash.Substring(1))
    }
    $bodyPath = Join-Path $script:PrivateRoot "policy-$($Definition.PolicyId).json"
    @{ properties = $source.properties } | ConvertTo-Json -Depth 100 | Set-Content -LiteralPath $bodyPath -Encoding utf8
    Invoke-Az -Arguments @('rest', '--method', 'put', '--url', "https://management.azure.com/subscriptions/$SubscriptionId/providers/Microsoft.Authorization/policyDefinitions/$($Definition.PolicyId)?api-version=$($script:PolicyApi)", '--body', "@$bodyPath") | Out-Null
}

function Publish-PolicySet {
    param([ValidateSet('Audit', 'Enforce')][string]$Mode, [switch]$BadHash)
    $context = Get-Context
    $manifest = Get-Content -Raw -LiteralPath (Join-Path $script:PrivateRoot 'policy-manifest.json') | ConvertFrom-Json
    $definitions = @($manifest.$Mode)
    foreach ($definition in $definitions) { Publish-PolicyDefinition -Definition $definition -BadHash:($BadHash -and $Mode -eq 'Audit' -and $definition.Role -eq 'Baseline') }

    $initiativeId = [guid]::NewGuid().Guid
    $members = @($definitions | ForEach-Object { [ordered]@{ policyDefinitionReferenceId = $_.ReferenceId; policyDefinitionId = "/subscriptions/$SubscriptionId/providers/Microsoft.Authorization/policyDefinitions/$($_.PolicyId)" } })
    $initiativeBody = [ordered]@{ properties = [ordered]@{ displayName = "Azure Workshopz Machine Configuration $Mode initiative"; description = "Role-aware Windows DSC baseline for the Pilot configuration ring."; metadata = @{ category = 'Guest Configuration'; version = if ($Mode -eq 'Audit') { '1.0.0' } else { '1.1.0' } }; policyDefinitions = $members } }
    $initiativePath = Join-Path $script:PrivateRoot "initiative-$Mode.json"
    $initiativeBody | ConvertTo-Json -Depth 30 | Set-Content -LiteralPath $initiativePath -Encoding utf8
    Invoke-Az -Arguments @('rest', '--method', 'put', '--url', "https://management.azure.com/subscriptions/$SubscriptionId/providers/Microsoft.Authorization/policySetDefinitions/$initiativeId`?api-version=$($script:PolicyApi)", '--body', "@$initiativePath") | Out-Null

    $assignmentName = if ($Mode -eq 'Audit') { 'mc-workshop-audit' } else { 'mc-workshop-enforce' }
    $scope = "/subscriptions/$SubscriptionId/resourceGroups/$ResourceGroupName"
    $assignmentId = "$scope/providers/Microsoft.Authorization/policyAssignments/$assignmentName"
    $assignmentBody = [ordered]@{
        location = $Location
        properties = [ordered]@{
            displayName = "Azure Workshopz Machine Configuration $Mode"
            description = 'Applies the role-aware Windows configuration baseline to the Pilot ring.'
            policyDefinitionId = "/subscriptions/$SubscriptionId/providers/Microsoft.Authorization/policySetDefinitions/$initiativeId"
            scope = $scope
            enforcementMode = 'Default'
            nonComplianceMessages = @(@{ message = 'The Windows guest configuration does not match the Azure Workshopz desired state.' })
        }
    }
    if ($Mode -eq 'Enforce') { $assignmentBody.identity = @{ type = 'SystemAssigned' } }
    $assignmentPath = Join-Path $script:PrivateRoot "assignment-$Mode.json"
    $assignmentBody | ConvertTo-Json -Depth 30 | Set-Content -LiteralPath $assignmentPath -Encoding utf8
    $assignmentResult = Invoke-Az -Arguments @('rest', '--method', 'put', '--url', "https://management.azure.com$assignmentId`?api-version=$($script:PolicyApi)", '--body', "@$assignmentPath", '-o', 'json') -Json

    if ($Mode -eq 'Audit') {
        $context.AuditInitiativeId = $initiativeId
        $context.AuditAssignmentId = $assignmentId
    }
    else {
        $context.EnforceInitiativeId = $initiativeId
        $context.EnforceAssignmentId = $assignmentId
        $principalId = [string]$assignmentResult.identity.principalId
        Wait-Until -Description 'enforcement policy managed identity' -TimeoutSeconds 600 -IntervalSeconds 15 -Condition { $principalId -match '^[0-9a-f-]{36}$' }
        Invoke-Az -Arguments @('role', 'assignment', 'create', '--assignee-object-id', $principalId, '--assignee-principal-type', 'ServicePrincipal', '--role', $script:GuestConfigRoleId, '--scope', $scope) | Out-Null
        $context.EnforceAssignmentPrincipalId = $principalId
    }
    Save-Context $context
    Write-Lab "$Mode policy definitions, initiative, and resource-group assignment were created." PASS
}

function Trigger-PolicyScan {
    Invoke-Az -Arguments @('policy', 'state', 'trigger-scan', '--resource-group', $ResourceGroupName, '--no-wait') | Out-Null
    Write-Lab 'Azure Policy compliance scan requested.' PASS
}

function Grant-PackageReader {
    $context = Get-Context
    $containerScope = "$($context.StorageAccountId)/blobServices/default/containers/$($script:StorageContainer)"
    $existing = Invoke-Az -Arguments @('role', 'assignment', 'list', '--assignee-object-id', $context.ManagedIdentityPrincipalId, '--scope', $containerScope, '--query', "[?roleDefinitionId=='/subscriptions/$SubscriptionId/providers/Microsoft.Authorization/roleDefinitions/$($script:BlobReaderRoleId)'] | length(@)", '-o', 'tsv')
    if ($existing.Trim() -eq '0') {
        Invoke-Az -Arguments @('role', 'assignment', 'create', '--assignee-object-id', $context.ManagedIdentityPrincipalId, '--assignee-principal-type', 'ServicePrincipal', '--role', $script:BlobReaderRoleId, '--scope', $containerScope) | Out-Null
    }
    Write-Lab 'The package identity now has Storage Blob Data Reader only at the private container scope.' PASS
}

function Introduce-Drift {
    $scriptText = Get-Content -Raw -LiteralPath (Join-Path $PSScriptRoot 'Set-WorkshopConfigurationDrift.ps1')
    foreach ($target in @(
        @{ Name = $script:Vm2022; Scenario = 'Baseline' },
        @{ Name = $script:Vm2025; Scenario = 'Application' }
    )) {
        try {
            $result = Invoke-GuestScript -VmName $target.Name -ScriptText $scriptText -Parameters @{ Scenario = $target.Scenario; Action = 'Introduce' }
            Write-Lab "Safe drift introduced on $($target.Name): $result" WARN
        }
        catch {
            Write-Lab "Drift could not be introduced on $($target.Name) during this pass: $($_.Exception.Message)" WARN
        }
    }
    Trigger-PolicyScan
}

function Expand-PilotRing {
    Invoke-Az -Arguments @('vm', 'update', '-g', $ResourceGroupName, '-n', $script:Vm2025, '--set', 'tags.ConfigurationRing=Pilot') | Out-Null
    Trigger-PolicyScan
    Write-Lab 'The Pliot tag typo was corrected; both Windows VMs are now in the Pilot ring.' PASS
    Install-GuestConfigurationExtension -VmName $script:Vm2025
}

function Enable-Enforcement {
    $context = Get-Context
    if ($context.AuditAssignmentId) { Invoke-Az -Arguments @('rest', '--method', 'delete', '--url', "https://management.azure.com$($context.AuditAssignmentId)?api-version=$($script:PolicyApi)") -AllowFailure | Out-Null }
    Publish-PolicySet -Mode Enforce
    Trigger-PolicyScan
    Start-Sleep -Seconds 30
    $context = Get-Context
    $manifest = Get-Content -Raw -LiteralPath (Join-Path $script:PrivateRoot 'policy-manifest.json') | ConvertFrom-Json
    foreach ($definition in @($manifest.Enforce)) {
        $name = "mc-remediate-$($definition.Role.ToLowerInvariant())-$([guid]::NewGuid().ToString('N').Substring(0,8))"
        $bodyPath = Join-Path $script:PrivateRoot "$name.json"
        @{ properties = @{ policyAssignmentId = $context.EnforceAssignmentId; policyDefinitionReferenceId = $definition.ReferenceId; resourceDiscoveryMode = 'ReEvaluateCompliance' } } | ConvertTo-Json -Depth 10 | Set-Content -LiteralPath $bodyPath -Encoding utf8
        Invoke-Az -Arguments @('rest', '--method', 'put', '--url', "https://management.azure.com/subscriptions/$SubscriptionId/resourceGroups/$ResourceGroupName/providers/Microsoft.PolicyInsights/remediations/$name`?api-version=$($script:RemediationApi)", '--body', "@$bodyPath") | Out-Null
    }
    Write-Lab 'ApplyAndAutoCorrect assignment and two role-aware remediation tasks were started.' PASS
}

function Show-Validation {
    $context = Get-Context
    $nsgRules = Invoke-Az -Arguments @('network', 'nsg', 'rule', 'list', '-g', $ResourceGroupName, '--nsg-name', $script:NsgName, '--query', 'length(@)', '-o', 'tsv')
    $publicIps = Invoke-Az -Arguments @('vm', 'list-ip-addresses', '-g', $ResourceGroupName, '--query', "[?virtualMachine.name=='$($script:Vm2022)' || virtualMachine.name=='$($script:Vm2025)'].virtualMachine.network.publicIpAddresses[] | length(@)", '-o', 'tsv') -AllowFailure
    $storagePublic = Invoke-Az -Arguments @('storage', 'account', 'show', '-g', $ResourceGroupName, '-n', $context.StorageAccountName, '--query', 'publicNetworkAccess', '-o', 'tsv')
    $tags = Invoke-Az -Arguments @('vm', 'list', '-g', $ResourceGroupName, '--query', '[].{Name:name,Ring:tags.ConfigurationRing,Role:tags.ServerRole}', '-o', 'json') -Json
    $guest = Invoke-Az -Arguments @('graph', 'query', '-q', "guestconfigurationresources | where id contains '$ResourceGroupName' | project name, complianceStatus=tostring(properties.complianceStatus), assignmentType=tostring(properties.assignmentType)", '--query', 'data', '-o', 'json') -AllowFailure
    Write-Lab "Validation: custom inbound NSG rules=$nsgRules; VM public IP count=$($publicIps.Output); Storage public access=$storagePublic." PASS
    foreach ($tag in $tags) { Write-Lab "Tag state: $($tag.Name), ring=$($tag.Ring), role=$($tag.Role)." PASS }
    Write-Lab "Sanitized Machine Configuration Resource Graph result: $($guest.Output)" INFO
}

switch ($Stage) {
    'Preflight' {
        Assert-ExactSubscription
        $cli = Invoke-Az -Arguments @('version', '--query', '"azure-cli"', '-o', 'tsv') -AllowFailure
        $pwsh = $PSVersionTable.PSVersion.ToString()
        $modules = @('GuestConfiguration:4.12.0', 'PSDesiredStateConfiguration:2.0.7', 'PSDscResources:2.12.0.0')
        foreach ($item in $modules) {
            $parts = $item.Split(':')
            if (-not (Get-Module -ListAvailable $parts[0] | Where-Object Version -eq $parts[1])) { throw "Required module $item is missing." }
        }
        $usage = Invoke-Az -Arguments @('vm', 'list-usage', '--location', $Location, '--query', "[?name.value=='standardBSFamily'].{current:currentValue,limit:limit}[0]", '-o', 'json') -Json
        if (($usage.limit - $usage.current) -lt 4) { throw 'At least four free B-series vCPUs are required.' }
        Write-Lab "Preflight passed with PowerShell $pwsh, Azure CLI available, all pinned modules, and sufficient B-series quota." PASS
    }
    'Foundation' { New-Foundation }
    'Author' { Build-AndUploadPackages }
    'BrokenAccess' { Publish-PolicySet -Mode Audit -BadHash; Trigger-PolicyScan; Write-Lab 'The audit assignment intentionally has a bad baseline checksum and the package identity still lacks blob access.' WARN }
    'Audit' {
        $manifest = Get-Content -Raw -LiteralPath (Join-Path $script:PrivateRoot 'policy-manifest.json') | ConvertFrom-Json
        foreach ($definition in @($manifest.Audit)) { Publish-PolicyDefinition -Definition $definition }
        Grant-PackageReader
        Trigger-PolicyScan
    }
    'Drift' { Introduce-Drift }
    'ExpandRing' { Expand-PilotRing }
    'Enforce' { Enable-Enforcement }
    'Validate' { Show-Validation }
}
