scripts/seed-data.ps1

#Requires -Version 7.0
<#
.SYNOPSIS
    Azure Demo Environment (ADE) — Seed Script

.DESCRIPTION
    Seeds dummy data into deployed ADE resources so they are usable for
    demo and benchmark testing immediately after deployment:
      - Azure Blob Storage: uploads sample files (JSON, CSV) to data/logs/public containers
      - Storage Queue: creates demo-tasks queue and enqueues sample task messages
      - Storage Table: creates demotable and inserts sample device/config entities
      - Storage File Share: uploads welcome.txt to the provisioned file share
      - Cosmos DB: inserts sample order documents
      - Azure SQL Database: runs data/sql/seed.sql against AdventureWorksLT
      - PostgreSQL: creates demo_products + demo_orders tables and inserts sample rows
      - MySQL: creates demo_events + demo_devices tables and inserts sample rows
      - Redis Cache: sets demo keys via TLS RESP connection
      - Key Vault: adds demo secrets, an RSA 2048 encryption key, and a self-signed certificate
      - Service Bus: sends test messages to the orders queue
      - Event Hub: sends telemetry events via REST to the telemetry hub
      - Event Grid: publishes demo events to the custom topic

    NOTE: SQL, PostgreSQL, and MySQL passwords are fetched automatically from
    the environment Key Vault (secrets sql-admin-password / postgres-admin-password /
    mysql-admin-password, written by deploy.ps1). Pass -DatabaseAdminPassword only
    to override — e.g. when the environment was deployed without a Key Vault.
    In hardened mode all databases are behind private endpoints — run this
    script from within the VNet (e.g. via Bastion or a jump VM).
    Key Vault key and certificate creation require Key Vault Administrator
    (or Crypto Officer + Certificates Officer) on the vault.

.PARAMETER Prefix
    The prefix used during deployment. Default: ade

.PARAMETER SubscriptionId
    Target subscription ID. If omitted, uses the current az account.

.PARAMETER Modules
    Which resource types to seed. Defaults to all available.
    Accepted values: storage, cosmosdb, sql, postgresql, mysql, redis,
                     keyvault, servicebus, eventhub, eventgrid, all

.PARAMETER AdminUsername
    Admin username used during ADE deployment. Must match the -AdminUsername value
    passed to deploy.ps1 (default: 'adeadmin'). Used as the login for SQL, PostgreSQL,
    and MySQL seeding.

.PARAMETER DatabaseAdminPassword
    Optional override of the admin password for Azure SQL, PostgreSQL, and MySQL
    seeding (one value for all three). When omitted, each service's password is
    read from the environment Key Vault (written there by deploy.ps1); a service
    is skipped when neither source is available.
    Use: -DatabaseAdminPassword 'YourPassword'

.PARAMETER Force
    Skip confirmation prompts.

.EXAMPLE
    # Passwords fetched from the environment Key Vault automatically
    ./seed-data.ps1 -Prefix ade

.EXAMPLE
    ./seed-data.ps1 -Prefix ade -DatabaseAdminPassword 'YourPassword'

.EXAMPLE
    ./seed-data.ps1 -Prefix ade -Modules storage,cosmosdb,redis -Force
#>

[CmdletBinding()]
param(
    [Parameter(Mandatory = $false)]
    [ValidatePattern('(?-i)^[a-z0-9]{2,8}$')]
    [string]$Prefix = 'ade',

    [Parameter(Mandatory = $false)]
    [string]$SubscriptionId = '',

    [Parameter(Mandatory = $false)]
    [ValidateSet('storage', 'cosmosdb', 'sql', 'postgresql', 'mysql', 'redis',
                 'keyvault', 'servicebus', 'eventhub', 'eventgrid', 'all')]
    [string[]]$Modules = @('all'),

    [Parameter(Mandatory = $false)]
    [string]$AdminUsername = 'adeadmin',

    [Parameter(Mandatory = $false)]
    [string]$DatabaseAdminPassword,

    [Parameter(Mandatory = $false)]
    [switch]$Force
)

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

$scriptRoot = $PSScriptRoot
. "$scriptRoot\helpers\common.ps1"

Write-AdeSection "Azure Demo Environment — Data Seeding"

if ($SubscriptionId) {
    az account set --subscription $SubscriptionId --output none
    if ($LASTEXITCODE -ne 0) { throw "Could not set subscription: $SubscriptionId" }
}

if (-not $Force) {
    Write-Host ""
    Write-Host " This will write data to deployed ADE resources under prefix: '$Prefix'" -ForegroundColor Yellow
    Write-Host " Modules: $($Modules -join ', ')" -ForegroundColor Yellow
    Write-Host ""
    $confirm = Read-Host "Proceed with data seeding? [y/N]"
    if ($confirm -notmatch '^[Yy]$') {
        Write-AdeLog "Data seeding cancelled." -Level Warning
        exit 0
    }
}

$seedAll = $Modules -contains 'all'
$script:_seedFailed = $false

# ─── Discover resource names ──────────────────────────────────────────────────

function Get-AdeResource {
    param([string]$ResourceGroup, [string]$ResourceType, [string]$Query = 'name')
    $result = az resource list `
        --resource-group $ResourceGroup `
        --resource-type $ResourceType `
        --query "[0].$Query" `
        -o tsv 2>$null
    return $result
}

# ─── Database password resolution ─────────────────────────────────────────────
# Precedence:
# 1. -DatabaseAdminPassword (legacy: one value for all services; zero az calls)
# 2. per-service secret from the environment Key Vault, written by deploy.ps1
# (sql-admin-password / postgres-admin-password / mysql-admin-password)
# Returns $null when neither is available — callers skip that service.
$script:_adeSeedKvName    = $null
$script:_adeSeedKvChecked = $false
function Get-AdeSeedDbPassword {
    param([Parameter(Mandatory)][ValidateSet('sql', 'postgres', 'mysql')][string]$Service)

    if ($DatabaseAdminPassword) { return $DatabaseAdminPassword }

    if (-not $script:_adeSeedKvChecked) {
        $script:_adeSeedKvChecked = $true
        $script:_adeSeedKvName = Get-AdeResource -ResourceGroup "$Prefix-security-rg" -ResourceType 'Microsoft.KeyVault/vaults'
        if (-not $script:_adeSeedKvName) {
            Write-AdeLog "No Key Vault found in '$Prefix-security-rg' — database passwords cannot be auto-resolved." -Level Info
        }
    }
    if (-not $script:_adeSeedKvName) { return $null }

    try {
        $sec = Get-AdeKeyVaultSecret -VaultName $script:_adeSeedKvName -SecretName "$Service-admin-password" -MaxAttempts 2
        if ($sec) { return [System.Net.NetworkCredential]::new('', $sec).Password }
        Write-AdeLog "Secret '$Service-admin-password' not found in Key Vault '$($script:_adeSeedKvName)'." -Level Info
    } catch {
        Write-AdeLog "Could not read '$Service-admin-password' from Key Vault '$($script:_adeSeedKvName)': $($_.Exception.Message)" -Level Warning
    }
    return $null
}

# Opens a TcpClient to a Redis host. Extracted so unit tests can mock it.
function Get-RedisTcpClient {
    [OutputType([System.Net.Sockets.TcpClient])]
    param([string]$Host, [int]$Port)
    return [System.Net.Sockets.TcpClient]::new($Host, $Port)
}

# Sends a single RESP command over an already-open SslStream and returns the raw reply.
function Invoke-RedisCommand {
    param([System.Net.Security.SslStream]$Stream, [string[]]$Parts)
    $resp = "*$($Parts.Count)`r`n"
    foreach ($p in $Parts) {
        $len = [System.Text.Encoding]::UTF8.GetByteCount($p)
        $resp += "`$$len`r`n$p`r`n"
    }
    $bytes = [System.Text.Encoding]::UTF8.GetBytes($resp)
    $Stream.Write($bytes, 0, $bytes.Length)
    $Stream.Flush()
    $buf = New-Object byte[] 1024
    $n   = $Stream.Read($buf, 0, $buf.Length)
    return [System.Text.Encoding]::UTF8.GetString($buf, 0, $n).Trim()
}

# Generates a SharedAccessSignature token for Azure Service Bus / Event Hubs REST API.
function New-EhSasToken {
    param(
        [string]$ResourceUri,
        [string]$KeyName,
        [string]$Key,
        [int]$ExpirySeconds = 3600
    )
    $expiry       = [DateTimeOffset]::UtcNow.ToUnixTimeSeconds() + $ExpirySeconds
    $stringToSign = [Uri]::EscapeDataString($ResourceUri) + "`n" + $expiry
    $hmac         = [System.Security.Cryptography.HMACSHA256]::new(
                        [System.Text.Encoding]::UTF8.GetBytes($Key))
    $sig          = [Convert]::ToBase64String(
                        $hmac.ComputeHash([System.Text.Encoding]::UTF8.GetBytes($stringToSign)))
    return "SharedAccessSignature sr=$([Uri]::EscapeDataString($ResourceUri))&sig=$([Uri]::EscapeDataString($sig))&se=$expiry&skn=$KeyName"
}

# ─── Blob Storage ─────────────────────────────────────────────────────────────

if ($seedAll -or $Modules -contains 'storage') {
    Write-AdeSection "Seeding: Blob Storage"

    $storageRg   = "$Prefix-storage-rg"
    # isHnsEnabled is NULL (not false) on GP v2 accounts when HNS was never enabled.
    # JMESPath `==\`false\`` does not match null — use `!=\`true\`` to catch both null and false.
    $rawAcct     = az storage account list `
        --resource-group $storageRg `
        --query '[?isHnsEnabled!=`true`].name | [0]' `
        -o tsv 2>$null
    $accountName = if ($rawAcct) { $rawAcct.Trim() } else { '' }

    if (-not $accountName) {
        Write-AdeLog "No storage account found in '$storageRg'. Skipping." -Level Warning
    } else {
        Write-AdeLog "Storage account: $accountName" -Level Info

        $key = az storage account keys list `
            --account-name $accountName `
            --resource-group $storageRg `
            --query '[0].value' -o tsv 2>$null

        # Hardened mode: allowSharedKeyAccess = false — keys CLI returns empty.
        # Fall back to Entra ID identity-based auth (requires caller to have
        # Storage Blob Data Contributor role on the account).
        $authArgs = if ($key) {
            @('--account-key', $key)
        } else {
            Write-AdeLog "Shared keys disabled — using --auth-mode login (ensure Storage Blob Data Contributor role)." -Level Warning
            @('--auth-mode', 'login')
        }
        # Upload sample blobs to 'data' container
        $sampleFiles = @(
            @{ Name = 'customers.json';   Content = '[{"id":1,"name":"Contoso Ltd","tier":"gold"},{"id":2,"name":"Fabrikam Inc","tier":"silver"}]' }
            @{ Name = 'products.csv';     Content = "id,name,price,category`n1,Widget A,19.99,hardware`n2,Widget B,49.99,hardware`n3,Service Pack,99.00,software" }
            @{ Name = 'telemetry.json';   Content = '[{"ts":"2026-01-01T00:00:00Z","device":"dev-001","temp":22.5,"humidity":60},{"ts":"2026-01-01T00:01:00Z","device":"dev-001","temp":22.7,"humidity":59}]' }
        )

        foreach ($file in $sampleFiles) {
            $tmpPath = Join-Path ([System.IO.Path]::GetTempPath()) $file.Name
            $file.Content | Set-Content -Path $tmpPath -Encoding UTF8
            az storage blob upload `
                --account-name $accountName `
                @authArgs `
                --container-name 'data' `
                --name $file.Name `
                --file $tmpPath `
                --overwrite `
                --output none 2>$null
            if ($LASTEXITCODE -eq 0) {
                Write-AdeLog "Uploaded: data/$($file.Name)" -Level Success
            } else {
                Write-AdeLog "Failed to upload data/$($file.Name) — 'data' container missing or access denied." -Level Warning
            }
            Remove-Item $tmpPath -Force
        }

        # Upload a README to 'public' container only if it exists (absent in hardened mode)
        $publicExists = (az storage container exists `
            --account-name $accountName `
            @authArgs `
            --name 'public' `
            --query exists `
            --output tsv 2>$null) -eq 'true'
        if ($publicExists) {
            $readmePath = Join-Path ([System.IO.Path]::GetTempPath()) 'README.md'
            @"
# Azure Demo Environment — Sample Data

This container contains publicly accessible sample files.
Uploaded by seed-data.ps1 on $(Get-Date -Format 'yyyy-MM-dd').
"@
 | Set-Content -Path $readmePath -Encoding UTF8
            az storage blob upload `
                --account-name $accountName `
                @authArgs `
                --container-name 'public' `
                --name 'README.md' `
                --file $readmePath `
                --overwrite `
                --output none
            Write-AdeLog "Uploaded: public/README.md" -Level Success
            Remove-Item $readmePath -Force
        } else {
            Write-AdeLog "'public' container not found (hardened mode — expected). Skipping README upload." -Level Warning
        }

        # ─── Logs container: seed a diagnostic run record ─────────────────────────
        $logFile = Join-Path ([System.IO.Path]::GetTempPath()) 'seed-diagnostics.json'
        @{ seedRun = (Get-Date -Format 'o'); host = [System.Net.Dns]::GetHostName(); status = 'ok' } |
            ConvertTo-Json -Compress | Set-Content -Path $logFile -Encoding UTF8
        az storage blob upload `
            --account-name $accountName `
            @authArgs `
            --container-name 'logs' `
            --name 'seed/seed-diagnostics.json' `
            --file $logFile `
            --overwrite `
            --output none 2>$null
        if ($LASTEXITCODE -eq 0) {
            Write-AdeLog "Uploaded: logs/seed/seed-diagnostics.json" -Level Success
        } else {
            Write-AdeLog "Failed to upload diagnostic log to 'logs' container." -Level Warning
        }
        Remove-Item $logFile -Force

        # ─── Storage Queue: demo task queue ───────────────────────────────────────
        az storage queue create `
            --account-name $accountName `
            @authArgs `
            --name 'demo-tasks' `
            --output none 2>$null
        Write-AdeLog "Queue created: demo-tasks" -Level Info

        $demoQueueMessages = @(
            '{"taskId":"task-001","type":"SendEmail","priority":"high","createdAt":"2026-01-01T00:00:00Z"}'
            '{"taskId":"task-002","type":"ProcessOrder","priority":"normal","createdAt":"2026-01-01T00:01:00Z"}'
            '{"taskId":"task-003","type":"GenerateReport","priority":"low","createdAt":"2026-01-01T00:02:00Z"}'
        )
        foreach ($msg in $demoQueueMessages) {
            $msgB64 = [Convert]::ToBase64String([System.Text.Encoding]::UTF8.GetBytes($msg))
            az storage message put `
                --account-name $accountName `
                @authArgs `
                --queue-name 'demo-tasks' `
                --content $msgB64 `
                --output none 2>$null
        }
        Write-AdeLog "Enqueued $($demoQueueMessages.Count) demo tasks to 'demo-tasks' queue." -Level Success

        # ─── Storage Table: demo entity store ─────────────────────────────────────
        az storage table create `
            --account-name $accountName `
            @authArgs `
            --name 'demotable' `
            --output none 2>$null
        Write-AdeLog "Table created: demotable" -Level Info

        $demoEntities = @(
            [ordered]@{ PartitionKey = 'devices'; RowKey = 'dev-001'; Model = 'SensorXv2'; Location = 'BuildingA'; Active = 'true' }
            [ordered]@{ PartitionKey = 'devices'; RowKey = 'dev-002'; Model = 'SensorXv2'; Location = 'BuildingB'; Active = 'true' }
            [ordered]@{ PartitionKey = 'config';  RowKey = 'global';  Theme = 'dark'; Version = '1.0'; Features = 'newUI' }
        )
        foreach ($entity in $demoEntities) {
            $entityProps = $entity.GetEnumerator() | ForEach-Object { "$($_.Key)=$($_.Value)" }
            az storage entity insert `
                --account-name $accountName `
                @authArgs `
                --table-name 'demotable' `
                --entity @entityProps `
                --if-exists replace `
                --output none 2>$null
            Write-AdeLog "Entity inserted: $($entity.PartitionKey)/$($entity.RowKey)" -Level Success
        }

        # ─── File Share: seed a welcome file ──────────────────────────────────────
        $welcomePath = Join-Path ([System.IO.Path]::GetTempPath()) 'welcome.txt'
        "Azure Demo Environment`nPrefix : $Prefix`nDeployed: $(Get-Date -Format 'yyyy-MM-dd')`nContents: Demo files for storage benchmark testing." |
            Set-Content -Path $welcomePath -Encoding UTF8
        az storage file upload `
            --account-name $accountName `
            @authArgs `
            --share-name "$Prefix-fileshare" `
            --source $welcomePath `
            --output none 2>$null
        Write-AdeLog "Uploaded: $Prefix-fileshare/welcome.txt" -Level Success
        Remove-Item $welcomePath -Force
    }
}

# ─── Cosmos DB ────────────────────────────────────────────────────────────────

if ($seedAll -or $Modules -contains 'cosmosdb') {
    Write-AdeSection "Seeding: Cosmos DB"

    $dbRg        = "$Prefix-databases-rg"
    $accountName = Get-AdeResource -ResourceGroup $dbRg -ResourceType 'Microsoft.DocumentDB/databaseAccounts'

    if (-not $accountName) {
        Write-AdeLog "No Cosmos DB account found in '$dbRg'. Skipping." -Level Warning
    } else {
        Write-AdeLog "Cosmos DB account: $accountName" -Level Info

        # In hardened mode, disableLocalAuth=true blocks key-based data plane access.
        # az cosmosdb sql document create uses keys internally; it has no --auth-mode login flag.
        $localAuthDisabled = (az cosmosdb show `
            --name $accountName `
            --resource-group $dbRg `
            --query 'properties.disableLocalAuth' `
            --output tsv 2>$null) -eq 'true'

        if ($localAuthDisabled) {
            Write-AdeLog "Cosmos DB local auth is disabled (hardened mode). Skipping document seeding via CLI." -Level Warning
            Write-AdeLog "To seed manually: assign 'Cosmos DB Built-in Data Contributor' to your identity, then use the Azure Portal Data Explorer or the Cosmos REST API with an Entra Bearer token." -Level Info
        } else {
        # az cosmosdb sql document does not exist in az CLI core; use the Cosmos DB
        # Data Plane REST API with master-key HMAC-SHA256 authentication instead.
        $cosmosKey = az cosmosdb keys list `
            --name $accountName `
            --resource-group $dbRg `
            --query primaryMasterKey `
            --output tsv 2>$null

        $dbName        = "${Prefix}-db"
        $containerName = 'items'
        $resourceLink  = "dbs/$dbName/colls/$containerName"

        $sampleDocs = @(
            @{ id = 'order-001'; customerId = 1; total = 149.99; status = 'completed'; items = @(@{ sku = 'WIDGET-A'; qty = 2 }) }
            @{ id = 'order-002'; customerId = 2; total = 299.50; status = 'pending';   items = @(@{ sku = 'WIDGET-B'; qty = 5 }) }
            @{ id = 'order-003'; customerId = 1; total = 99.00;  status = 'shipped';   items = @(@{ sku = 'SVC-PACK'; qty = 1 }) }
        )

        foreach ($doc in $sampleDocs) {
            $docJson  = $doc | ConvertTo-Json -Depth 5 -Compress
            $dateUtc  = [datetime]::UtcNow.ToString('R')

            # Build Cosmos DB master-key authorization token (HMAC-SHA256)
            $stringToSign = "post`ndocs`n$resourceLink`n$($dateUtc.ToLower())`n`n"
            $keyBytes  = [System.Convert]::FromBase64String($cosmosKey)
            $hmac      = [System.Security.Cryptography.HMACSHA256]::new($keyBytes)
            $sig       = [System.Convert]::ToBase64String(
                             $hmac.ComputeHash([System.Text.Encoding]::UTF8.GetBytes($stringToSign)))
            $authToken = [uri]::EscapeDataString("type=master&ver=1.0&sig=$sig")

            try {
                $null = Invoke-RestMethod `
                    -Uri     "https://$accountName.documents.azure.com/$resourceLink/docs" `
                    -Method  POST `
                    -Headers @{
                        'Authorization'                    = $authToken
                        'x-ms-date'                        = $dateUtc
                        'x-ms-version'                     = '2018-12-31'
                        'x-ms-documentdb-partitionkey'     = "[`"$($doc.id)`"]"
                        'x-ms-documentdb-is-upsert'        = 'true'
                    } `
                    -ContentType 'application/json' `
                    -Body        $docJson `
                    -ErrorAction Stop
                Write-AdeLog "Inserted Cosmos document: $($doc.id)" -Level Success
            } catch {
                Write-AdeLog "Failed to insert Cosmos document: $($doc.id) — $($_.Exception.Message)" -Level Warning
            }
        }
        } # end else (local auth enabled)
    }
}

# ─── Azure SQL ───────────────────────────────────────────────────────────────

if ($seedAll -or $Modules -contains 'sql') {
    Write-AdeSection "Seeding: Azure SQL Database"

    $dbRg      = "$Prefix-databases-rg"
    $sqlServer = Get-AdeResource -ResourceGroup $dbRg -ResourceType 'Microsoft.Sql/servers'

    $dbAdminPwd = $null
    if (-not $sqlServer) {
        Write-AdeLog "No SQL Server found in '$dbRg'. Skipping." -Level Warning
    } elseif (-not ($dbAdminPwd = Get-AdeSeedDbPassword -Service 'sql')) {
        Write-AdeLog "SQL seeding skipped — no password available. deploy.ps1 stores 'sql-admin-password' in the environment Key Vault automatically; pass -DatabaseAdminPassword to override." -Level Warning
    } else {
        $dbName     = "$Prefix-sqldb"
        Write-AdeLog "SQL Server: $sqlServer DB: $dbName" -Level Info

        # az sql db query was removed from Azure CLI — use System.Data.SqlClient directly
        $seedFile = Join-Path $PSScriptRoot '..\data\sql\seed.sql'
        try {
            $connStr = "Server=tcp:${sqlServer}.database.windows.net,1433;Initial Catalog=$dbName;User ID=$AdminUsername;Password=$dbAdminPwd;Encrypt=True;TrustServerCertificate=False;Connection Timeout=30"
            $conn    = New-Object System.Data.SqlClient.SqlConnection($connStr)
            $conn.Open()
            $sqlRaw  = (Get-Content $seedFile -Raw) -replace '(?im)^\s*USE\s+\[?\w+\]?\s*;?\s*(\r?\n)', ''
            $batches = $sqlRaw -split '(?m)^\s*GO\s*$'
            foreach ($batch in ($batches | Where-Object { $_.Trim() })) {
                $cmd             = $conn.CreateCommand()
                $cmd.CommandText = $batch
                $cmd.CommandTimeout = 60
                $cmd.ExecuteNonQuery() | Out-Null
            }
            $conn.Close()
            Write-AdeLog "SQL seed applied: $dbName" -Level Success
        } catch {
            Write-AdeLog "SQL seed failed: $_" -Level Warning
            $script:_seedFailed = $true
        }
    }
}

# ─── PostgreSQL ───────────────────────────────────────────────────────────────

if ($seedAll -or $Modules -contains 'postgresql') {
    Write-AdeSection "Seeding: PostgreSQL Flexible Server"

    $dbRg     = "$Prefix-databases-rg"
    $pgServer = Get-AdeResource -ResourceGroup $dbRg -ResourceType 'Microsoft.DBforPostgreSQL/flexibleServers'

    $dbAdminPwd = $null
    if (-not $pgServer) {
        Write-AdeLog "No PostgreSQL server found in '$dbRg'. Skipping." -Level Warning
    } elseif (-not (Get-Command 'psql' -ErrorAction SilentlyContinue)) {
        # Client check BEFORE password resolution — a tool-missing skip must not cost Key Vault calls.
        Write-AdeLog "PostgreSQL seeding skipped — 'psql' client not found. See docs/operations.md § Seed data for options." -Level Info
    } elseif (-not ($dbAdminPwd = Get-AdeSeedDbPassword -Service 'postgres')) {
        Write-AdeLog "PostgreSQL seeding skipped — no password available. deploy.ps1 stores 'postgres-admin-password' in the environment Key Vault automatically; pass -DatabaseAdminPassword to override." -Level Warning
    } else {
        $pgDbName   = "${Prefix}db"
        $pgSeedFile = Join-Path $PSScriptRoot '..\data\postgres\seed.sql'
        Write-AdeLog "PostgreSQL server: $pgServer DB: $pgDbName" -Level Info
        try {
            $env:PGPASSWORD = $dbAdminPwd
            psql --host="$pgServer.postgres.database.azure.com" --port=5432 `
                 --username="$AdminUsername" --dbname="$pgDbName" `
                 --file="$pgSeedFile" --no-password 2>&1 | ForEach-Object { Write-AdeLog $_ -Level Info }
            if ($LASTEXITCODE -ne 0) { throw "psql exited with code $LASTEXITCODE" }
            Write-AdeLog "PostgreSQL seed applied: $pgDbName" -Level Success
        } catch {
            Write-AdeLog "PostgreSQL seed failed: $_" -Level Warning
            $script:_seedFailed = $true
        } finally {
            Remove-Item Env:\PGPASSWORD -ErrorAction SilentlyContinue
        }
    }
}

# ─── MySQL ────────────────────────────────────────────────────────────────────

if ($seedAll -or $Modules -contains 'mysql') {
    Write-AdeSection "Seeding: MySQL Flexible Server"

    $dbRg        = "$Prefix-databases-rg"
    $mysqlServer = Get-AdeResource -ResourceGroup $dbRg -ResourceType 'Microsoft.DBforMySQL/flexibleServers'

    $dbAdminPwd = $null
    if (-not $mysqlServer) {
        Write-AdeLog "No MySQL server found in '$dbRg'. Skipping." -Level Warning
    } elseif (-not (Get-Command 'mysql' -ErrorAction SilentlyContinue)) {
        # Client check BEFORE password resolution — a tool-missing skip must not cost Key Vault calls.
        Write-AdeLog "MySQL seeding skipped — 'mysql' client not found. See docs/operations.md § Seed data for options." -Level Info
    } elseif (-not ($dbAdminPwd = Get-AdeSeedDbPassword -Service 'mysql')) {
        Write-AdeLog "MySQL seeding skipped — no password available. deploy.ps1 stores 'mysql-admin-password' in the environment Key Vault automatically; pass -DatabaseAdminPassword to override." -Level Warning
    } else {
        $mysqlDbName = "${Prefix}db"
        $mysqlSeedFile = Join-Path $PSScriptRoot '..\data\mysql\seed.sql'
        Write-AdeLog "MySQL server: $mysqlServer DB: $mysqlDbName" -Level Info
        try {
            mysql --host="$mysqlServer.mysql.database.azure.com" --port=3306 `
                  --user="$AdminUsername" --password="$dbAdminPwd" `
                  --ssl-mode=REQUIRED "$mysqlDbName" `
                  --execute="source $mysqlSeedFile" 2>&1 | ForEach-Object { Write-AdeLog $_ -Level Info }
            if ($LASTEXITCODE -ne 0) { throw "mysql exited with code $LASTEXITCODE" }
            Write-AdeLog "MySQL seed applied: $mysqlDbName" -Level Success
        } catch {
            Write-AdeLog "MySQL seed failed: $_" -Level Warning
            $script:_seedFailed = $true
        }
    }
}

# ─── Redis Cache ──────────────────────────────────────────────────────────────

if ($seedAll -or $Modules -contains 'redis') {
    Write-AdeSection "Seeding: Redis Cache"

    $dbRg      = "$Prefix-databases-rg"
    $redisName = Get-AdeResource -ResourceGroup $dbRg -ResourceType 'Microsoft.Cache/redis'

    if (-not $redisName) {
        Write-AdeLog "No Redis Cache found in '$dbRg'. Skipping." -Level Warning
    } else {
        Write-AdeLog "Redis Cache: $redisName" -Level Info

        $redisKeys  = az redis list-keys `
            --resource-group $dbRg `
            --name $redisName `
            --output json 2>$null | ConvertFrom-Json

        if (-not $redisKeys) {
            Write-AdeLog "Could not retrieve Redis keys. Skipping." -Level Warning
        } else {
            $primaryKey = $redisKeys.primaryKey
            $redisHost  = "$redisName.redis.cache.windows.net"

            # Demo key-value pairs to seed
            $demoKeys = [ordered]@{
                'demo:session:001'      = '{"userId":"user-001","role":"admin","exp":9999999999}'
                'demo:session:002'      = '{"userId":"user-002","role":"reader","exp":9999999999}'
                'demo:config:app'       = '{"theme":"dark","language":"en","maxRetries":3}'
                'demo:counter:requests' = '42000'
                'demo:feature:flags'    = '{"newUI":true,"betaSearch":false,"darkMode":true}'
            }

            $tcp = $null
            try {
                $tcp = Get-RedisTcpClient -Host $redisHost -Port 6380
                #region DEMO ONLY — DO NOT COPY TO PRODUCTION
                # RemoteCertificateValidationCallback returns $true unconditionally.
                # This is acceptable here because:
                # 1. The target is always *.redis.cache.windows.net — an Azure-managed cert.
                # 2. This is a one-shot demo seed script, not a long-lived service connection.
                # 3. SslStream + port 6380 still provides transport encryption (TLS 1.2+).
                # For production workloads use the StackExchange.Redis NuGet package, which
                # validates the full certificate chain by default.
                $ssl = [System.Net.Security.SslStream]::new(
                    $tcp.GetStream(), $false,
                    [System.Net.Security.RemoteCertificateValidationCallback]{ param($s, $c, $ch, $e) $true }
                )
                #endregion DEMO ONLY
                $ssl.AuthenticateAsClient($redisHost)

                # Authenticate
                $null = Invoke-RedisCommand -Stream $ssl -Parts @('AUTH', $primaryKey)

                # SET demo keys (sessions with 24h TTL, others permanent)
                foreach ($kv in $demoKeys.GetEnumerator()) {
                    $null = Invoke-RedisCommand -Stream $ssl -Parts @('SET', $kv.Key, $kv.Value)
                    Write-AdeLog "Redis SET: $($kv.Key)" -Level Success
                }
                $null = Invoke-RedisCommand -Stream $ssl -Parts @('EXPIRE', 'demo:session:001', '86400')
                $null = Invoke-RedisCommand -Stream $ssl -Parts @('EXPIRE', 'demo:session:002', '86400')

                $null = Invoke-RedisCommand -Stream $ssl -Parts @('QUIT')
            } catch {
                Write-AdeLog "Redis TLS connection failed: $_. Ensure the runner has network access to $redisHost." -Level Warning
            } finally {
                if ($tcp) { $tcp.Dispose() }
            }
        }
    }
}

# ─── Key Vault ────────────────────────────────────────────────────────────────
if ($seedAll -or $Modules -contains 'keyvault') {
    Write-AdeSection "Seeding: Key Vault"

    $secRg    = "$Prefix-security-rg"
    $vaultName = Get-AdeResource -ResourceGroup $secRg -ResourceType 'Microsoft.KeyVault/vaults'

    if (-not $vaultName) {
        Write-AdeLog "No Key Vault found in '$secRg'. Skipping." -Level Warning
    } else {
        Write-AdeLog "Key Vault: $vaultName" -Level Info

        # Ensure current caller has secret set permission
        $kvTenantId = az account show --query tenantId -o tsv 2>$null
        if (-not $kvTenantId) { $kvTenantId = 'demo-tenant-id' }

        # Resolve real SQL Server FQDN if deployed — replaces the Bicep placeholder
        $dbRg      = "$Prefix-databases-rg"
        $sqlServer = az sql server list --resource-group $dbRg --query '[0].name' -o tsv 2>$null
        $sqlFqdn   = if ($sqlServer) {
            az sql server show --resource-group $dbRg --name $sqlServer --query 'fullyQualifiedDomainName' -o tsv 2>$null
        }
        $dbConnString = if ($sqlFqdn) {
            "Server=$sqlFqdn;Database=$Prefix-sqldb;User Id=sqladmin;Password=REPLACE_IN_PRODUCTION"
        } else {
            'Server=REPLACE_WITH_SQL_FQDN;Database=demodb;User Id=sqladmin;Password=REPLACE_IN_PRODUCTION'
        }

        $kvSecrets = @{
            'db-connection-string'  = $dbConnString
            'app-client-id'        = 'demo-client-id-00000000-0000-0000-0000-000000000001'
            # Randomised per-seed so each deployment gets a unique placeholder value.
            # These are NOT real credentials — replace with actual secrets before production use.
            'app-client-secret'    = "demo-secret-$([guid]::NewGuid().ToString('N').Substring(0,16))"
            'app-tenant-id'        = $kvTenantId
            'smtp-password'        = "demo-smtp-$([guid]::NewGuid().ToString('N').Substring(0,16))"
            'third-party-api-key'  = "demo-apikey-$([guid]::NewGuid().ToString('N').Substring(0,16))"
            'jwt-signing-key'      = [System.Convert]::ToBase64String(
                                        [System.Security.Cryptography.RandomNumberGenerator]::GetBytes(32)
                                    )
        }

        foreach ($secret in $kvSecrets.GetEnumerator()) {
            az keyvault secret set `
                --vault-name $vaultName `
                --name $secret.Key `
                --value $secret.Value `
                --output none 2>$null
            if ($LASTEXITCODE -eq 0) {
                Write-AdeLog "Secret set: $($secret.Key)" -Level Success
            } else {
                Write-AdeLog "Secret '$($secret.Key)' could not be written — KV may have restricted network access in hardened mode." -Level Warning
            }
        }

        # ─── Key: RSA 2048 encryption key ─────────────────────────────────────────
        # Requires Key Vault Crypto Officer (or Administrator) on the vault.
        az keyvault key create `
            --vault-name $vaultName `
            --name 'demo-encryption-key' `
            --kty RSA `
            --size 2048 `
            --ops encrypt decrypt wrapKey unwrapKey `
            --output none 2>$null
        if ($LASTEXITCODE -eq 0) {
            Write-AdeLog "Key created: demo-encryption-key (RSA 2048)" -Level Success
        } else {
            Write-AdeLog "Key 'demo-encryption-key' could not be created — ensure Key Vault Crypto Officer role is assigned." -Level Warning
        }

        # ─── Certificate: self-signed TLS demo certificate ────────────────────────
        # Requires Key Vault Certificates Officer (or Administrator) on the vault.
        # Policy: Self-signed, RSA 2048, CN=ade-demo, 12-month validity, server+client EKU.
        # NOTE: Passing JSON via --policy inline on Windows PowerShell causes double-quote
        # stripping before az CLI receives it. Write to a temp file and use @path instead.
        $selfSignedPolicy = @{
            issuerParameters = @{ name = 'Self' }
            keyProperties    = @{ exportable = $true; keySize = 2048; keyType = 'RSA'; reuseKey = $false }
            lifetimeActions  = @(@{ action = @{ actionType = 'AutoRenew' }; trigger = @{ daysBeforeExpiry = 90 } })
            secretProperties = @{ contentType = 'application/x-pkcs12' }
            x509CertificateProperties = @{
                ekus    = @('1.3.6.1.5.5.7.3.1', '1.3.6.1.5.5.7.3.2')
                keyUsage = @('digitalSignature', 'keyEncipherment')
                subject  = 'CN=ade-demo'
                validityInMonths = 12
            }
        } | ConvertTo-Json -Depth 6 -Compress
        $certPolicyFile = Join-Path ([System.IO.Path]::GetTempPath()) 'ade-cert-policy.json'
        $selfSignedPolicy | Set-Content -Path $certPolicyFile -Encoding UTF8
        $certErr = az keyvault certificate create `
            --vault-name $vaultName `
            --name 'demo-tls-cert' `
            --policy "@$certPolicyFile" `
            --output none 2>&1
        Remove-Item $certPolicyFile -Force -ErrorAction SilentlyContinue
        if ($LASTEXITCODE -eq 0) {
            Write-AdeLog "Certificate created: demo-tls-cert (self-signed, CN=ade-demo, RSA 2048)" -Level Success
        } else {
            $certErrMsg = ($certErr | Out-String).Trim()
            Write-AdeLog "Certificate 'demo-tls-cert' could not be created: $certErrMsg" -Level Warning
        }
    }
}

# ─── Service Bus ─────────────────────────────────────────────────────────────

if ($seedAll -or $Modules -contains 'servicebus') {
    Write-AdeSection "Seeding: Service Bus"

    $intRg    = "$Prefix-integration-rg"
    $sbNs     = Get-AdeResource -ResourceGroup $intRg -ResourceType 'Microsoft.ServiceBus/namespaces'

    if (-not $sbNs) {
        Write-AdeLog "No Service Bus namespace found in '$intRg'. Skipping." -Level Warning
    } else {
        Write-AdeLog "Service Bus namespace: $sbNs" -Level Info

        $sbKey = az servicebus namespace authorization-rule keys list `
            --resource-group $intRg `
            --namespace-name $sbNs `
            --name RootManageSharedAccessKey `
            --query primaryConnectionString -o tsv 2>$null

        if ($sbKey) {
            $testMessages = @(
                '{"orderId":"test-001","event":"OrderCreated","timestamp":"2026-01-01T00:00:00Z"}'
                '{"orderId":"test-002","event":"OrderShipped","timestamp":"2026-01-01T01:00:00Z"}'
            )
            foreach ($msg in $testMessages) {
                az servicebus message send `
                    --connection-string $sbKey `
                    --entity-path 'orders' `
                    --message $msg `
                    --output none 2>$null
            }
            Write-AdeLog "Sent $($testMessages.Count) test messages to 'orders' queue." -Level Success
        }
    }
}

# ─── Event Hub ───────────────────────────────────────────────────────────────

if ($seedAll -or $Modules -contains 'eventhub') {
    Write-AdeSection "Seeding: Event Hub"

    $intRg    = "$Prefix-integration-rg"
    $ehNs     = Get-AdeResource -ResourceGroup $intRg -ResourceType 'Microsoft.EventHub/namespaces'

    if (-not $ehNs) {
        Write-AdeLog "No Event Hub namespace found in '$intRg'. Skipping." -Level Warning
    } else {
        Write-AdeLog "Event Hub namespace: $ehNs" -Level Info

        $ehKey = az eventhubs namespace authorization-rule keys list `
            --resource-group $intRg `
            --namespace-name $ehNs `
            --name RootManageSharedAccessKey `
            --query primaryConnectionString -o tsv 2>$null

        if ($ehKey) {
            # Extract the bare SAS key from the connection string
            # Format: Endpoint=sb://...;SharedAccessKeyName=...;SharedAccessKey=<key>
            $ehPrimaryKey = ($ehKey -split ';' | Where-Object { $_ -like 'SharedAccessKey=*' }) -replace 'SharedAccessKey=', ''
            $ehHubName    = "$Prefix-telemetry"
            $ehUri        = "https://$ehNs.servicebus.windows.net/$ehHubName"

            $sas = New-EhSasToken -ResourceUri $ehUri -KeyName 'RootManageSharedAccessKey' -Key $ehPrimaryKey

            $telemetryEvents = @(
                @{ device = 'dev-001'; temperature = 22.5; humidity = 60;   ts = '2026-01-01T00:00:00Z'; unit = 'celsius' }
                @{ device = 'dev-002'; temperature = 19.3; humidity = 65;   ts = '2026-01-01T00:01:00Z'; unit = 'celsius' }
                @{ device = 'dev-003'; motion = $true;     zone = 'entrance'; ts = '2026-01-01T00:02:00Z' }
            )
            $sentCount = 0
            foreach ($event in $telemetryEvents) {
                try {
                    Invoke-RestMethod `
                        -Method POST `
                        -Uri "$ehUri/messages" `
                        -Headers @{ Authorization = $sas } `
                        -ContentType 'application/json; charset=utf-8' `
                        -Body ($event | ConvertTo-Json -Compress) | Out-Null
                    $sentCount++
                } catch {
                    Write-AdeLog "Event Hub send failed: $_" -Level Warning
                }
            }
            Write-AdeLog "Sent $sentCount telemetry events to '$ehHubName' Event Hub." -Level Success
            # Note: connection string intentionally not persisted in the environment
            # after use — SAS keys should not outlive the operation that needs them.
        }
    }
}

# ─── Event Grid ───────────────────────────────────────────────────────────────

if ($seedAll -or $Modules -contains 'eventgrid') {
    Write-AdeSection "Seeding: Event Grid"

    $intRg     = "$Prefix-integration-rg"
    $topicName = Get-AdeResource -ResourceGroup $intRg -ResourceType 'Microsoft.EventGrid/topics'

    if (-not $topicName) {
        Write-AdeLog "No Event Grid custom topic found in '$intRg'. Skipping." -Level Warning
    } else {
        Write-AdeLog "Event Grid topic: $topicName" -Level Info

        $topicEndpoint = az eventgrid topic show `
            --name $topicName `
            --resource-group $intRg `
            --query 'endpoint' -o tsv 2>$null

        $topicKey = az eventgrid topic key list `
            --name $topicName `
            --resource-group $intRg `
            --query 'key1' -o tsv 2>$null

        if ($topicEndpoint -and $topicKey) {
            $demoEgEvents = @(
                [ordered]@{
                    id          = [Guid]::NewGuid().ToString()
                    eventType   = 'Demo.Order.Created'
                    subject     = '/ade/orders/order-001'
                    eventTime   = (Get-Date -Format 'o')
                    dataVersion = '1.0'
                    data        = @{ orderId = 'order-001'; customerId = 1; total = 149.99 }
                }
                [ordered]@{
                    id          = [Guid]::NewGuid().ToString()
                    eventType   = 'Demo.Resource.Updated'
                    subject     = '/ade/resources/storage-001'
                    eventTime   = (Get-Date -Format 'o')
                    dataVersion = '1.0'
                    data        = @{ resourceId = 'storage-001'; action = 'updated'; module = 'storage' }
                }
            ) | ConvertTo-Json -Depth 5

            try {
                Invoke-RestMethod `
                    -Method POST `
                    -Uri $topicEndpoint `
                    -Headers @{ 'aeg-sas-key' = $topicKey } `
                    -ContentType 'application/json' `
                    -Body $demoEgEvents | Out-Null
                Write-AdeLog "Published 2 demo events to Event Grid topic '$topicName'." -Level Success
            } catch {
                Write-AdeLog "Event Grid publish failed: $_" -Level Warning
            }
        } else {
            Write-AdeLog "Could not retrieve Event Grid topic endpoint or key. Skipping." -Level Warning
        }
    }
}

# ─── Complete ─────────────────────────────────────────────────────────────────

Write-AdeSection "Data Seeding Complete"
if ($script:_seedFailed) {
    Write-AdeLog "One or more seeding steps failed (see [WARN] lines above)." -Level Warning
    Write-AdeLog "Run './scripts/dashboard/Get-AdeCostDashboard.ps1' to review resource status." -Level Info
    exit 1
} else {
    Write-AdeLog "Dummy data has been seeded into deployed ADE resources." -Level Success
    Write-AdeLog "Run './scripts/dashboard/Get-AdeCostDashboard.ps1' to review resource status." -Level Info
    exit 0
}