internal/functions/AzureDataExplorer.ps1

function Get-XdrAzureDataExplorerConnection {
    [CmdletBinding()]
    param()

    process {
        if (-not $script:AzureDataExplorerConnection) {
            throw "Azure Data Explorer connection not configured. Run Set-XdrAzureDataExplorerConnection first."
        }

        return $script:AzureDataExplorerConnection
    }
}

function Resolve-XdrAzureDataExplorerUris {
    [CmdletBinding()]
    param(
        [Parameter(Mandatory)]
        [uri]$ClusterUri,

        [uri]$IngestionUri
    )

    $normalizedClusterUri = [uri]$ClusterUri.GetLeftPart([System.UriPartial]::Authority)
    $clusterHost = $normalizedClusterUri.DnsSafeHost
    $port = if ($normalizedClusterUri.IsDefaultPort) { -1 } else { $normalizedClusterUri.Port }

    if ($clusterHost.StartsWith('ingest-', [System.StringComparison]::OrdinalIgnoreCase)) {
        $engineHost = $clusterHost.Substring(7)
        $ingestionHost = $clusterHost
    }
    else {
        $engineHost = $clusterHost
        $ingestionHost = "ingest-$clusterHost"
    }

    $clusterBuilder = [System.UriBuilder]::new($normalizedClusterUri.Scheme, $engineHost, $port)

    if ($IngestionUri) {
        $normalizedIngestionUri = [uri]$IngestionUri.GetLeftPart([System.UriPartial]::Authority)
    }
    else {
        $normalizedIngestionUri = [uri]([System.UriBuilder]::new($normalizedClusterUri.Scheme, $ingestionHost, $port).Uri.GetLeftPart([System.UriPartial]::Authority))
    }

    [pscustomobject]@{
        ClusterUri   = [uri]$clusterBuilder.Uri.GetLeftPart([System.UriPartial]::Authority)
        IngestionUri = $normalizedIngestionUri
    }
}

function Select-XdrAzureDataExplorerDiscoveredValue {
    [CmdletBinding()]
    param(
        [Parameter(Mandatory)]
        [string]$Heading,

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

        [Parameter(Mandatory)]
        [object[]]$Items,

        [Parameter(Mandatory)]
        [scriptblock]$DisplayScript,

        [switch]$NonInteractive,

        [string]$AmbiguousSelectionMessage = 'Multiple Azure Data Explorer values matched the requested discovery criteria in non-interactive mode.'
    )

    $resolvedItems = @($Items)
    if ($resolvedItems.Count -eq 0) {
        return $null
    }

    if ($resolvedItems.Count -eq 1) {
        return $resolvedItems[0]
    }

    if ($NonInteractive) {
        throw $AmbiguousSelectionMessage
    }

    Write-Information $Heading -InformationAction Continue
    for ($i = 0; $i -lt $resolvedItems.Count; $i++) {
        $displayValue = & $DisplayScript $resolvedItems[$i]
        Write-Information " [$($i + 1)] $displayValue" -InformationAction Continue
    }

    while ($true) {
        $selection = Read-Host $Prompt
        $index = 0
        if ([int]::TryParse([string]$selection, [ref]$index) -and $index -ge 1 -and $index -le $resolvedItems.Count) {
            return $resolvedItems[$index - 1]
        }

        Write-Information 'Invalid selection. Try again.' -InformationAction Continue
    }
}

function Resolve-XdrAzureDataExplorerDiscoveredConnection {
    [CmdletBinding()]
    param(
        [string[]]$SubscriptionId,

        [SupportsWildcards()]
        [string]$ClusterName,

        [SupportsWildcards()]
        [string]$DatabaseName,

        [string]$TenantId,

        [string]$ManagedIdentityClientId,

        [switch]$NonInteractive,

        [switch]$AllowPartialDiscovery,

        [ValidateRange(1, 600)]
        [int]$RequestTimeout = 60
    )

    $discoveryParams = @{
        IncludeDatabases        = $true
        TenantId                = $TenantId
        ManagedIdentityClientId = $ManagedIdentityClientId
        RequestTimeout          = $RequestTimeout
    }

    if ($SubscriptionId) {
        $discoveryParams['SubscriptionId'] = $SubscriptionId
    }

    if (-not [string]::IsNullOrWhiteSpace($ClusterName)) {
        $discoveryParams['ClusterName'] = $ClusterName
    }

    if (-not [string]::IsNullOrWhiteSpace($DatabaseName)) {
        $discoveryParams['DatabaseName'] = $DatabaseName
    }

    $clusters = @(Get-XdrAzureDataExplorerCluster @discoveryParams)
    if ($clusters.Count -eq 0) {
        throw 'No Azure Data Explorer clusters matched the requested discovery criteria.'
    }

    $partialDiscoveryFailures = @(
        $clusters | ForEach-Object {
            if ($null -ne $_.DiscoveryStatus -and -not $_.DiscoveryStatus.IsComplete) {
                $_.DiscoveryStatus.Failures
            }
        }
    )
    if ($partialDiscoveryFailures.Count -gt 0 -and -not $AllowPartialDiscovery) {
        $failureSummary = @(
            $partialDiscoveryFailures | ForEach-Object {
                "$($_.Provider) [$($_.Scope)]: $($_.Message)"
            }
        ) | Select-Object -Unique
        throw "Azure Data Explorer connection discovery was incomplete and automatic selection was stopped. $($failureSummary -join ' | ') Re-run with -AllowPartialDiscovery to select from the partial results, or narrow discovery with -SubscriptionId."
    }

    $selectedCluster = Select-XdrAzureDataExplorerDiscoveredValue `
        -Heading 'Available Azure Data Explorer clusters:' `
        -Prompt "Select cluster [1-$($clusters.Count)]" `
        -Items $clusters `
        -NonInteractive:$NonInteractive `
        -AmbiguousSelectionMessage 'Multiple Azure Data Explorer clusters matched the requested discovery criteria in non-interactive mode. Narrow the match with -ClusterName or -SubscriptionId, or specify -ClusterUri and -Database explicitly.' `
        -DisplayScript {
            param($Cluster)

            $subscriptionLabel = if ([string]::IsNullOrWhiteSpace([string]$Cluster.SubscriptionId)) {
                'Free/personal'
            }
            elseif ([string]::IsNullOrWhiteSpace([string]$Cluster.SubscriptionName)) {
                [string]$Cluster.SubscriptionId
            }
            else {
                "$($Cluster.SubscriptionName) [$($Cluster.SubscriptionId)]"
            }

            $databaseCount = @($Cluster.Databases).Count
            $databaseLabel = if ($databaseCount -eq 1) {
                '1 database'
            }
            elseif ($databaseCount -gt 1) {
                "$databaseCount databases"
            }
            else {
                'no databases discovered'
            }

            $stateLabel = if (-not [string]::IsNullOrWhiteSpace([string]$Cluster.ProvisioningState)) {
                [string]$Cluster.ProvisioningState
            }
            elseif (-not [string]::IsNullOrWhiteSpace([string]$Cluster.State)) {
                [string]$Cluster.State
            }
            else {
                'Unknown'
            }

            "$($Cluster.ClusterName) | $subscriptionLabel | $($Cluster.Location) | $($Cluster.SkuTier) | $databaseLabel | state: $stateLabel"
        }

    $databases = @($selectedCluster.Databases)
    if ($databases.Count -eq 0) {
        $stateFragments = [System.Collections.Generic.List[string]]::new()
        if (-not [string]::IsNullOrWhiteSpace([string]$selectedCluster.ProvisioningState)) {
            $stateFragments.Add("ProvisioningState=$($selectedCluster.ProvisioningState)") | Out-Null
        }
        if (-not [string]::IsNullOrWhiteSpace([string]$selectedCluster.State) -and $selectedCluster.State -ne $selectedCluster.ProvisioningState) {
            $stateFragments.Add("State=$($selectedCluster.State)") | Out-Null
        }

        $stateSuffix = if ($stateFragments.Count -gt 0) {
            " ($($stateFragments -join ', '))"
        }
        else {
            ''
        }

        throw "No databases were discovered for cluster '$($selectedCluster.ClusterName)'$stateSuffix. Retry discovery when the cluster is ready, or configure the connection explicitly with -ClusterUri and -Database."
    }

    $selectedDatabase = Select-XdrAzureDataExplorerDiscoveredValue `
        -Heading "Available databases for '$($selectedCluster.ClusterName)':" `
        -Prompt "Select database [1-$($databases.Count)]" `
        -Items $databases `
        -NonInteractive:$NonInteractive `
        -AmbiguousSelectionMessage "Multiple databases were discovered for cluster '$($selectedCluster.ClusterName)' in non-interactive mode. Narrow the match with -DatabaseName, or specify -ClusterUri and -Database explicitly." `
        -DisplayScript {
            param($Database)

            if ([string]::IsNullOrWhiteSpace([string]$Database.Kind)) {
                return [string]$Database.DatabaseName
            }

            return "$($Database.DatabaseName) ($($Database.Kind))"
        }

    return [pscustomobject]@{
        ClusterName  = $selectedCluster.ClusterName
        ClusterUri   = [uri]$selectedCluster.ClusterUri
        IngestionUri = [uri]$selectedCluster.IngestionUri
        TenantId     = $selectedCluster.TenantId
        Database     = [string]$selectedDatabase.DatabaseName
    }
}

function ConvertFrom-XdrAzureDataExplorerResponseTable {
    [OutputType([pscustomobject[]])]
    [CmdletBinding()]
    param(
        [Parameter(Mandatory)]
        $Response
    )

    process {
        if (-not $Response -or -not $Response.Tables) {
            return [pscustomobject[]]@()
        }

        $table = @($Response.Tables)[0]
        if (-not $table -or -not $table.Columns -or -not $table.Rows) {
            return [pscustomobject[]]@()
        }

        $rows = foreach ($row in @($table.Rows)) {
            $values = [ordered]@{}
            for ($i = 0; $i -lt $table.Columns.Count; $i++) {
                $values[$table.Columns[$i].ColumnName] = $row[$i]
            }

            [pscustomobject]$values
        }

        return [pscustomobject[]]$rows
    }
}

function Test-XdrAzureDataExplorerTransientRestError {
    [CmdletBinding()]
    [OutputType([bool])]
    param(
        [Parameter(Mandatory)]
        $ErrorRecord
    )

    $messages = [System.Collections.Generic.List[string]]::new()
    if ($ErrorRecord.Exception) {
        $currentException = $ErrorRecord.Exception
        while ($currentException) {
            if (-not [string]::IsNullOrWhiteSpace($currentException.Message)) {
                $messages.Add($currentException.Message) | Out-Null
            }
            $currentException = $currentException.InnerException
        }
    }
    if ($ErrorRecord.ErrorDetails -and -not [string]::IsNullOrWhiteSpace($ErrorRecord.ErrorDetails.Message)) {
        $messages.Add($ErrorRecord.ErrorDetails.Message) | Out-Null
    }
    $messages.Add([string]$ErrorRecord) | Out-Null

    $message = $messages -join "`n"
    return (
        $message -match 'unexpected EOF' -or
        $message -match '0 bytes from the transport stream' -or
        $message -match 'transport stream' -or
        $message -match 'response ended prematurely' -or
        $message -match 'connection.*closed' -or
        $message -match 'connection.*reset'
    )
}

function Invoke-XdrRetryingAzureRestMethod {
    [CmdletBinding()]
    param(
        [Parameter(Mandatory)]
        [hashtable]$RequestParams,

        [Parameter(Mandatory)]
        [uri]$RequestUri,

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

        [ValidateRange(1, 10)]
        [int]$RetryCount = 10
    )

    for ($attempt = 1; $attempt -le $RetryCount; $attempt++) {
        try {
            return Invoke-RestMethod @RequestParams
        }
        catch {
            if (-not (Test-XdrAzureDataExplorerTransientRestError -ErrorRecord $_) -or $attempt -ge $RetryCount) {
                throw
            }

            $delaySeconds = [math]::Min([math]::Pow(2, $attempt - 1), 8)
            Write-Verbose "$RequestDescription to '$requestUri' failed with a transient transport error on attempt $attempt of $RetryCount. Retrying in $delaySeconds second(s)."
            Start-Sleep -Seconds $delaySeconds
        }
    }
}

function Invoke-XdrAzureDataExplorerRestRequest {
    [CmdletBinding()]
    param(
        [Parameter(Mandatory)]
        [uri]$BaseUri,

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

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

        [ValidateSet('GET', 'POST')]
        [string]$Method = 'GET',

        $Body,

        [int]$TimeoutSec,

        [ValidateRange(1, 10)]
        [int]$RetryCount = 10
    )

    $requestUri = [uri]::new($BaseUri, $Path)
    $headers = @{
        'Accept'                 = 'application/json'
        'Authorization'          = "Bearer $Token"
        'Connection'             = 'Close'
        'x-ms-app'               = 'XDRInternals'
        'x-ms-client-version'    = 'XDRInternals/1.0.12'
        'x-ms-client-request-id' = "XDRInternals.AzureDataExplorer;$([guid]::NewGuid())"
    }

    $requestParams = @{
        Uri         = $requestUri
        Method      = $Method
        Headers     = $headers
        ErrorAction = 'Stop'
        Verbose     = $false
    }

    if ($TimeoutSec -gt 0) {
        $requestParams['TimeoutSec'] = $TimeoutSec
    }

    if ($Method -eq 'POST') {
        $requestBody = if ($Body -is [string]) {
            $Body
        }
        else {
            $Body | ConvertTo-Json -Depth 20 -Compress
        }

        $requestParams['Method'] = 'Post'
        $requestParams['ContentType'] = 'application/json; charset=utf-8'
        $requestParams['Body'] = $requestBody
    }

    Invoke-XdrRetryingAzureRestMethod -RequestParams $requestParams -RequestUri $requestUri -RequestDescription 'Azure Data Explorer REST request' -RetryCount $RetryCount
}

function Invoke-XdrAzureResourceManagerRequest {
    [CmdletBinding()]
    param(
        [uri]$Uri,

        [string]$Path,

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

        [int]$TimeoutSec,

        [ValidateRange(1, 10)]
        [int]$RetryCount = 10
    )

    if (-not $Uri -and [string]::IsNullOrWhiteSpace($Path)) {
        throw 'Specify either -Uri or -Path.'
    }

    $requestUri = if ($Uri) {
        $Uri
    }
    else {
        [uri]::new([uri]'https://management.azure.com/', $Path)
    }

    $headers = @{
        'Accept'                 = 'application/json'
        'Authorization'          = "Bearer $Token"
        'Connection'             = 'Close'
        'x-ms-app'               = 'XDRInternals'
        'x-ms-client-version'    = 'XDRInternals/1.0.12'
        'x-ms-client-request-id' = "XDRInternals.AzureResourceManager;$([guid]::NewGuid())"
    }

    $requestParams = @{
        Uri         = $requestUri
        Method      = 'Get'
        Headers     = $headers
        ErrorAction = 'Stop'
        Verbose     = $false
    }

    if ($TimeoutSec -gt 0) {
        $requestParams['TimeoutSec'] = $TimeoutSec
    }

    Invoke-XdrRetryingAzureRestMethod -RequestParams $requestParams -RequestUri $requestUri -RequestDescription 'Azure Resource Manager request' -RetryCount $RetryCount
}

function Get-XdrAzureResourceManagerCollection {
    [CmdletBinding()]
    [OutputType([object[]])]
    param(
        [Parameter(Mandatory)]
        [string]$Path,

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

        [int]$TimeoutSec
    )

    $items = [System.Collections.Generic.List[object]]::new()
    $nextUri = [uri]::new([uri]'https://management.azure.com/', $Path)
    $initialUri = $nextUri
    $initialQuery = [System.Web.HttpUtility]::ParseQueryString($initialUri.Query)
    $defaultApiVersion = $initialQuery['api-version']

    while ($nextUri) {
        $response = Invoke-XdrAzureResourceManagerRequest -Uri $nextUri -Token $Token -TimeoutSec $TimeoutSec
        foreach ($item in @($response.value)) {
            $items.Add($item) | Out-Null
        }

        if ([string]::IsNullOrWhiteSpace([string]$response.nextLink)) {
            $nextUri = $null
        }
        else {
            $nextUri = [uri]$response.nextLink
            if (-not [string]::IsNullOrWhiteSpace($defaultApiVersion)) {
                $nextQuery = [System.Web.HttpUtility]::ParseQueryString($nextUri.Query)
                if ([string]::IsNullOrWhiteSpace($nextQuery['api-version'])) {
                    $builder = [System.UriBuilder]::new($nextUri)
                    $nextQuery['api-version'] = $defaultApiVersion
                    $builder.Query = $nextQuery.ToString()
                    $nextUri = $builder.Uri
                }
            }
        }
    }

    return [object[]]$items.ToArray()
}

function Invoke-XdrAzureDataExplorerManagementCommand {
    [CmdletBinding()]
    param(
        [Parameter(Mandatory)]
        [uri]$ClusterUri,

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

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

        [Parameter(Mandatory)]
        [string]$Token
    )

    $body = @{
        db  = $Database
        csl = $Command
    }

    Invoke-XdrAzureDataExplorerRestRequest -BaseUri $ClusterUri -Path '/v1/rest/mgmt' -Method POST -Token $Token -Body $body
}

function Test-XdrAzureDataExplorerNotFound {
    [CmdletBinding()]
    [OutputType([bool])]
    param(
        [Parameter(Mandatory)]
        $ErrorRecord
    )

    $response = $ErrorRecord.Exception.Response
    if ($response) {
        try {
            if ([int]$response.StatusCode -eq 404) {
                return $true
            }
        }
        catch {
            Write-Verbose "Could not inspect the Azure Data Explorer response status code while handling a not-found check."
        }
    }

    $messages = @(
        $ErrorRecord.Exception.Message,
        $ErrorRecord.ErrorDetails.Message,
        $(if ($response -and $response.Content) { [string]$response.Content } else { $null })
    ) | Where-Object { -not [string]::IsNullOrWhiteSpace($_) }

    return (@($messages | Where-Object {
                $_ -match '404' -or
                $_ -match 'NotFound' -or
                $_ -match 'BadRequest_EntityNotFound' -or
                $_ -match 'EntityNotFoundException' -or
                $_ -match 'does not exist' -or
                $_ -match 'was not found'
            }).Count -gt 0)
}

function Test-XdrAzureDataExplorerTable {
    [CmdletBinding()]
    [OutputType([bool])]
    param(
        [Parameter(Mandatory)]
        [uri]$ClusterUri,

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

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

        [Parameter(Mandatory)]
        [string]$Token
    )

    $escapedTableName = $TableName -replace "'", "''"
    $response = Invoke-XdrAzureDataExplorerManagementCommand `
        -ClusterUri $ClusterUri `
        -Database $Database `
        -Command ".show tables | where TableName == '$escapedTableName'" `
        -Token $Token

    return (@(ConvertFrom-XdrAzureDataExplorerResponseTable -Response $response).Count -gt 0)
}

function Get-XdrAzureDataExplorerTableSchema {
    [CmdletBinding()]
    [OutputType([hashtable])]
    param(
        [Parameter(Mandatory)]
        [uri]$ClusterUri,

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

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

        [Parameter(Mandatory)]
        [string]$Token
    )

    try {
        $response = Invoke-XdrAzureDataExplorerManagementCommand `
            -ClusterUri $ClusterUri `
            -Database $Database `
            -Command ".show table $TableName schema as json" `
            -Token $Token
        $schemaRow = @(ConvertFrom-XdrAzureDataExplorerResponseTable -Response $response) | Select-Object -First 1
        if (-not $schemaRow -or [string]::IsNullOrWhiteSpace([string]$schemaRow.Schema)) {
            return $null
        }

        $schema = [string]$schemaRow.Schema | ConvertFrom-Json -ErrorAction Stop
        $columns = @{}
        foreach ($column in @($schema.OrderedColumns)) {
            if (-not [string]::IsNullOrWhiteSpace([string]$column.Name)) {
                $columns[[string]$column.Name] = [string]$column.CslType
            }
        }

        return $columns
    }
    catch {
        if (Test-XdrAzureDataExplorerNotFound -ErrorRecord $_) {
            return $null
        }

        throw
    }
}

function Get-XdrAzureDataExplorerMapping {
    [CmdletBinding()]
    param(
        [Parameter(Mandatory)]
        [uri]$ClusterUri,

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

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

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

        [Parameter(Mandatory)]
        [string]$Token
    )

    try {
        $escapedMappingName = $MappingName -replace "'", "''"
        $response = Invoke-XdrAzureDataExplorerManagementCommand `
            -ClusterUri $ClusterUri `
            -Database $Database `
            -Command ".show table $TableName ingestion json mappings | where Name == '$escapedMappingName'" `
            -Token $Token
        return @(ConvertFrom-XdrAzureDataExplorerResponseTable -Response $response) | Select-Object -First 1
    }
    catch {
        if (Test-XdrAzureDataExplorerNotFound -ErrorRecord $_) {
            return $null
        }

        throw
    }
}

function Test-XdrAzureDataExplorerMapping {
    [CmdletBinding()]
    [OutputType([bool])]
    param(
        [Parameter(Mandatory)]
        [uri]$ClusterUri,

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

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

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

        [Parameter(Mandatory)]
        [string]$Token
    )

    return $null -ne (Get-XdrAzureDataExplorerMapping -ClusterUri $ClusterUri -Database $Database -TableName $TableName -MappingName $MappingName -Token $Token)
}

function Initialize-XdrAzureDataExplorerTable {
    [CmdletBinding()]
    param(
        [Parameter(Mandatory)]
        [uri]$ClusterUri,

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

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

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

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

        [hashtable]$TableProfile,

        [switch]$PreserveExistingMapping
    )

    $desiredColumns = if ($TableProfile) {
        @($TableProfile.Columns)
    }
    else {
        @(@{ Name = 'Event'; Type = 'dynamic' })
    }
    $columnSpec = ($desiredColumns | ForEach-Object { "$($_.Name):$($_.Type)" }) -join ', '
    $existingColumns = Get-XdrAzureDataExplorerTableSchema -ClusterUri $ClusterUri -Database $Database -TableName $TableName -Token $Token
    $missingColumns = [System.Collections.Generic.List[string]]::new()
    $incompatibleColumns = [System.Collections.Generic.List[string]]::new()

    if ($null -ne $existingColumns) {
        foreach ($column in $desiredColumns) {
            if (-not $existingColumns.ContainsKey([string]$column.Name)) {
                $missingColumns.Add([string]$column.Name) | Out-Null
                continue
            }

            $actualType = [string]$existingColumns[[string]$column.Name]
            if ($actualType -ine [string]$column.Type) {
                $incompatibleColumns.Add("$($column.Name) (expected $($column.Type), found $actualType)") | Out-Null
            }
        }
    }

    if ($incompatibleColumns.Count -gt 0) {
        throw "Azure Data Explorer table '$TableName' has incompatible column types: $($incompatibleColumns -join '; '). No schema or mapping changes were applied."
    }

    if ($null -eq $existingColumns -or $missingColumns.Count -gt 0) {
        $schemaAction = if ($null -eq $existingColumns) { 'Creating' } else { "Adding missing columns ($($missingColumns -join ', ')) to" }
        Write-Verbose "$schemaAction Azure Data Explorer table '$TableName' in database '$Database' with schema ($columnSpec)"
        $null = Invoke-XdrAzureDataExplorerManagementCommand -ClusterUri $ClusterUri -Database $Database -Command ".create-merge tables $TableName ($columnSpec)" -Token $Token
    }

    $mappingEntries = if ($TableProfile) {
        @($TableProfile.ColumnMappings | ForEach-Object {
                @{ column = $_.Column; Properties = @{ path = $_.Properties.Path } }
            })
    }
    else {
        @(@{ column = 'Event'; Properties = @{ path = '$' } })
    }
    $mapping = ConvertTo-Json -InputObject @($mappingEntries) -Depth 4 -Compress
    $existingMapping = Get-XdrAzureDataExplorerMapping -ClusterUri $ClusterUri -Database $Database -TableName $TableName -MappingName $MappingName -Token $Token

    if ($PreserveExistingMapping -and $existingMapping) {
        Write-Verbose "Preserving existing caller-owned Azure Data Explorer JSON mapping '$MappingName' on table '$TableName'."
        return
    }

    $mappingMatches = $false
    if ($existingMapping -and -not [string]::IsNullOrWhiteSpace([string]$existingMapping.Mapping)) {
        try {
            $existingEntries = @([string]$existingMapping.Mapping | ConvertFrom-Json -ErrorAction Stop)
            $existingSignature = @($existingEntries | ForEach-Object {
                    "$([string]$_.Column)|$([string]$_.DataType)|$([string]$_.Properties.Path)|$([string]$_.Properties.ConstValue)|$([string]$_.Properties.Transform)"
                }) -join "`n"
            $desiredSignature = @($mappingEntries | ForEach-Object {
                    "$([string]$_.column)||$([string]$_.Properties.path)||"
                }) -join "`n"
            $mappingMatches = $existingSignature -ceq $desiredSignature
        }
        catch {
            Write-Verbose "Existing Azure Data Explorer mapping '$MappingName' could not be normalized and will be replaced."
        }
    }

    if (-not $mappingMatches) {
        Write-Verbose "Converging Azure Data Explorer JSON mapping '$MappingName' on table '$TableName'"
        $null = Invoke-XdrAzureDataExplorerManagementCommand -ClusterUri $ClusterUri -Database $Database -Command ".create-or-alter table $TableName ingestion json mapping '$MappingName' '$mapping'" -Token $Token
    }
}

function Clear-XdrAzureDataExplorerExportState {
    [CmdletBinding()]
    param(
        [hashtable]$TableStates,

        [string]$SessionStagingPath,

        [switch]$KeepTempFiles,

        [Parameter(Mandatory)]
        [ref]$CleanupCompleted
    )

    if ($CleanupCompleted.Value) {
        return
    }

    $CleanupCompleted.Value = $true

    foreach ($tableState in @($TableStates.Values)) {
        if (-not $tableState.writer) {
            continue
        }

        try {
            $tableState.writer.Dispose()
        }
        catch {
            Write-Warning "Could not close an Azure Data Explorer staging writer: $($_.Exception.Message)"
        }
        finally {
            $tableState.writer = $null
        }
    }

    if (-not $KeepTempFiles -and
        -not [string]::IsNullOrWhiteSpace($SessionStagingPath) -and
        (Test-Path -LiteralPath $SessionStagingPath)) {
        Remove-Item -LiteralPath $SessionStagingPath -Recurse -Force -ErrorAction SilentlyContinue
    }
}

function Get-XdrAzureDataExplorerIngestionConfiguration {
    [CmdletBinding()]
    param(
        [Parameter(Mandatory)]
        [uri]$IngestionUri,

        [Parameter(Mandatory)]
        [string]$Token
    )

    Invoke-XdrAzureDataExplorerRestRequest -BaseUri $IngestionUri -Path '/v1/rest/ingestion/configuration' -Method GET -Token $Token
}

function Get-XdrAzureDataExplorerIngestionRuntimeConfiguration {
    [CmdletBinding()]
    [OutputType([pscustomobject])]
    param(
        [Parameter(Mandatory)]
        [uri]$IngestionUri,

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

        [Parameter(Mandatory)]
        [int]$MaxBlobSizeMB
    )

    $ingestionConfig = Get-XdrAzureDataExplorerIngestionConfiguration -IngestionUri $IngestionUri -Token $Token
    if ($ingestionConfig.containerSettings.preferredUploadMethod -and $ingestionConfig.containerSettings.preferredUploadMethod -ne 'Storage') {
        throw "Azure Data Explorer returned preferred upload method '$($ingestionConfig.containerSettings.preferredUploadMethod)'. This cmdlet currently supports only Storage-based queued ingestion."
    }

    $containerPath = @($ingestionConfig.containerSettings.containers)[0].path
    if ([string]::IsNullOrWhiteSpace($containerPath)) {
        throw "Azure Data Explorer did not return a writable storage container path for queued ingestion."
    }

    $serviceMaxDataSizeBytes = if ($ingestionConfig.ingestionSettings.maxDataSize) {
        [int64]$ingestionConfig.ingestionSettings.maxDataSize
    }
    else {
        6GB
    }

    $maxBlobsPerRequest = if ($ingestionConfig.ingestionSettings.maxBlobsPerBatch) {
        [int]$ingestionConfig.ingestionSettings.maxBlobsPerBatch
    }
    else {
        20
    }

    $refreshInterval = $null
    if (-not [string]::IsNullOrWhiteSpace($ingestionConfig.containerSettings.refreshInterval)) {
        try {
            $refreshInterval = [TimeSpan]::Parse([string]$ingestionConfig.containerSettings.refreshInterval)
        }
        catch {
            Write-Verbose "Could not parse the Azure Data Explorer ingestion configuration refresh interval '$($ingestionConfig.containerSettings.refreshInterval)'."
        }
    }

    [pscustomobject]@{
        ContainerPath            = [string]$containerPath
        ServiceMaxDataSizeBytes  = $serviceMaxDataSizeBytes
        TargetMaxBlobSizeBytes   = [Math]::Min(([int64]$MaxBlobSizeMB * 1MB), $serviceMaxDataSizeBytes)
        MaxBlobsPerRequest       = $maxBlobsPerRequest
        RefreshInterval          = $refreshInterval
        RetrievedAtUtc           = [DateTime]::UtcNow
        PreferredIngestionMethod = [string]$ingestionConfig.ingestionSettings.preferredIngestionMethod
    }
}

function Test-XdrAzureDataExplorerIngestionConfigurationRefreshDue {
    [CmdletBinding()]
    [OutputType([bool])]
    param(
        [Parameter(Mandatory)]
        $Configuration
    )

    if ($null -eq $Configuration.RefreshInterval) {
        return $false
    }

    if ($Configuration.RefreshInterval.TotalSeconds -le 0) {
        return $true
    }

    return (([DateTime]::UtcNow - $Configuration.RetrievedAtUtc) -ge $Configuration.RefreshInterval)
}

function Compress-XdrFileToGzip {
    [CmdletBinding()]
    [OutputType([string])]
    param(
        [Parameter(Mandatory)]
        [string]$SourcePath,

        [Parameter(Mandatory)]
        [string]$DestinationPath
    )

    $inputStream = [System.IO.File]::OpenRead($SourcePath)
    try {
        $outputStream = [System.IO.File]::Create($DestinationPath)
        try {
            $gzipStream = [System.IO.Compression.GzipStream]::new($outputStream, [System.IO.Compression.CompressionLevel]::Optimal)
            try {
                $inputStream.CopyTo($gzipStream)
            }
            finally {
                $gzipStream.Dispose()
            }
        }
        finally {
            $outputStream.Dispose()
        }
    }
    finally {
        $inputStream.Dispose()
    }

    return $DestinationPath
}

function Send-XdrAzureDataExplorerBlobUpload {
    [CmdletBinding()]
    param(
        [Parameter(Mandatory)]
        [string]$BlobUri,

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

        [switch]$Compressed
    )

    $headers = @{
        'x-ms-blob-type' = 'BlockBlob'
        'x-ms-version'   = '2023-11-03'
    }

    $contentType = if ($Compressed) { 'application/gzip' } else { 'application/json' }

    $null = Invoke-WebRequest -UseBasicParsing -Uri $BlobUri -Method Put -InFile $FilePath -Headers $headers -ContentType $contentType -ErrorAction Stop -Verbose:$false
}

function Send-XdrAzureDataExplorerQueuedIngestion {
    [CmdletBinding()]
    param(
        [Parameter(Mandatory)]
        [uri]$IngestionUri,

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

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

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

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

        [Parameter(Mandatory)]
        [hashtable[]]$Blobs,

        [switch]$TrackIngestion
    )

    $properties = @{
        format                    = 'json'
        deleteAfterDownload       = $true
        ingestionMappingReference = $MappingName
    }

    if ($TrackIngestion) {
        $properties.enableTracking = $true
    }

    $body = @{
        timestamp  = [DateTime]::UtcNow.ToString('o')
        blobs      = @($Blobs)
        properties = $properties
    }

    Invoke-XdrAzureDataExplorerRestRequest -BaseUri $IngestionUri -Path "/v1/rest/ingestion/queued/$Database/$TableName" -Method POST -Token $Token -Body $body
}

function Submit-XdrAzureDataExplorerQueuedIngestionBatch {
    [CmdletBinding()]
    param(
        [Parameter(Mandatory)]
        [uri]$IngestionUri,

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

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

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

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

        [Parameter(Mandatory)]
        [System.Collections.Generic.List[hashtable]]$PendingBlobs,

        [Parameter(Mandatory)]
        [ref]$PendingRawSizeBytes,

        [Parameter(Mandatory)]
        [AllowEmptyCollection()]
        [System.Collections.Generic.List[string]]$QueuedOperationIds,

        [switch]$TrackIngestion
    )

    if ($PendingBlobs.Count -le 0) {
        return $null
    }

    $response = Send-XdrAzureDataExplorerQueuedIngestion -IngestionUri $IngestionUri `
        -Database $Database `
        -TableName $TableName `
        -MappingName $MappingName `
        -Token $Token `
        -Blobs ($PendingBlobs.ToArray()) `
        -TrackIngestion:$TrackIngestion

    if ($response.ingestionOperationId) {
        $QueuedOperationIds.Add([string]$response.ingestionOperationId) | Out-Null
    }

    $PendingBlobs.Clear()
    $PendingRawSizeBytes.Value = 0L

    return $response
}

function Get-XdrAzureDataExplorerQueuedIngestionStatus {
    [CmdletBinding()]
    [OutputType([pscustomobject])]
    param(
        [Parameter(Mandatory)]
        [uri]$IngestionUri,

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

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

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

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

        [switch]$Details
    )

    $path = "/v1/rest/ingestion/queued/$([uri]::EscapeDataString($Database))/$([uri]::EscapeDataString($TableName))/$([uri]::EscapeDataString($OperationId))"
    if ($Details) {
        $path += '?details=true'
    }

    $response = Invoke-XdrAzureDataExplorerRestRequest -BaseUri $IngestionUri -Path $path -Method GET -Token $Token
    $status = $response.status
    $succeeded = if ($null -ne $status.Succeeded) { [int]$status.Succeeded } else { 0 }
    $failed = if ($null -ne $status.Failed) { [int]$status.Failed } else { 0 }
    $inProgress = if ($null -ne $status.InProgress) { [int]$status.InProgress } else { 0 }
    $canceled = if ($null -ne $status.Canceled) { [int]$status.Canceled } else { 0 }
    $knownCount = $succeeded + $failed + $inProgress + $canceled

    $detailItems = if ($Details -and $response.details) {
        @(
            foreach ($detail in @($response.details)) {
                [pscustomobject]@{
                    SourceId      = $detail.sourceId
                    Url           = $detail.url
                    Status        = $detail.status
                    StartTime     = $detail.startTime
                    LastUpdated   = $detail.lastUpdated
                    ErrorCode     = $detail.errorCode
                    FailureStatus = $detail.failureStatus
                    Details       = $detail.details
                }
            }
        )
    }
    else {
        $null
    }

    [pscustomobject]@{
        OperationId = $OperationId
        Status      = if ($failed -gt 0) {
            'Failed'
        }
        elseif ($canceled -gt 0) {
            'Canceled'
        }
        elseif ($inProgress -gt 0 -or $knownCount -eq 0) {
            'InProgress'
        }
        elseif ($succeeded -gt 0) {
            'Succeeded'
        }
        else {
            'Unknown'
        }
        Succeeded   = $succeeded
        Failed      = $failed
        InProgress  = $inProgress
        Canceled    = $canceled
        StartTime   = $response.startTime
        LastUpdated = $response.lastUpdated
        IsTerminal  = ($knownCount -gt 0 -and $inProgress -eq 0)
        HasFailures = ($failed -gt 0 -or $canceled -gt 0)
        Details     = $detailItems
    }
}

function Wait-XdrAzureDataExplorerQueuedIngestion {
    [CmdletBinding()]
    [OutputType([pscustomobject[]])]
    param(
        [Parameter(Mandatory)]
        [uri]$IngestionUri,

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

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

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

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

        [ValidateRange(1, 1440)]
        [int]$TimeoutMinutes = 30,

        [ValidateRange(1, 300)]
        [int]$PollingIntervalSeconds = 15,

        [switch]$Details
    )

    $deadline = [DateTime]::UtcNow.AddMinutes($TimeoutMinutes)
    do {
        try {
            [pscustomobject[]]$statuses = @(
                foreach ($currentOperationId in $OperationId) {
                    Get-XdrAzureDataExplorerQueuedIngestionStatus -IngestionUri $IngestionUri `
                        -Database $Database `
                        -TableName $TableName `
                        -OperationId $currentOperationId `
                        -Token $Token `
                        -Details:$Details
                }
            )
        }
        catch {
            if (-not (Test-XdrAzureDataExplorerTransientRestError -ErrorRecord $_) -or [DateTime]::UtcNow -ge $deadline) {
                throw
            }

            Write-Verbose "Azure Data Explorer ingestion status polling for table '$TableName' hit a transient transport error. Retrying after $PollingIntervalSeconds second(s)."
            Start-Sleep -Seconds $PollingIntervalSeconds
            continue
        }

        if (@($statuses | Where-Object { -not $_.IsTerminal }).Count -eq 0) {
            return [pscustomobject[]]$statuses
        }

        if ([DateTime]::UtcNow -ge $deadline) {
            $statusSummary = $statuses | ForEach-Object {
                "$($_.OperationId): $($_.Status) (Succeeded=$($_.Succeeded), InProgress=$($_.InProgress), Failed=$($_.Failed), Canceled=$($_.Canceled))"
            }
            throw "Timed out waiting for Azure Data Explorer queued ingestion to finish. Last status: $($statusSummary -join '; ')"
        }

        Start-Sleep -Seconds $PollingIntervalSeconds
    } while ($true)
}