<#
.SYNOPSIS
    Deploy custom detection rules to Microsoft Defender XDR via the Graph API.

.DESCRIPTION
    Reads a JSON manifest of detection rule definitions and creates them as
    custom detection rules using Invoke-MgGraphRequest (POST /beta/security/rules/detectionRules).

    Supports dry-run mode, duplicate checking (default: skip existing), and post-creation verification.
    Manifest supports dynamic alert titles ({{ColumnName}} syntax) and recommended actions.
    Response actions are always set to empty — automated response actions are prohibited.

.PARAMETER ManifestPath
    Path to a JSON file containing an array of detection rule definitions.

.PARAMETER DryRun
    List rules to deploy without creating them. Shows status (New/Exists), schedule, and severity for each rule.

.PARAMETER Force
    Deploy rules even if a rule with the same displayName already exists.
    Without -Force, existing rules are skipped automatically to prevent 409 errors.

.EXAMPLE
    .\Deploy-CustomDetections.ps1 -ManifestPath .\temp\4799_4702.json -DryRun
    .\Deploy-CustomDetections.ps1 -ManifestPath .\temp\4799_4702.json
    .\Deploy-CustomDetections.ps1 -ManifestPath .\temp\4799_4702.json -Force
#>

[CmdletBinding()]
param(
    [Parameter(Mandatory)]
    [ValidateScript({ Test-Path $_ -PathType Leaf })]
    [string]$ManifestPath,

    [switch]$DryRun,

    [switch]$Force
)

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

# ── Helpers ──────────────────────────────────────────────────────────────────

function Connect-GraphIfNeeded {
    try {
        $ctx = Get-MgContext
        if (-not $ctx) { throw "Not connected" }
        Write-Host "✅ Connected to Graph as $($ctx.Account)" -ForegroundColor Green
    }
    catch {
        Write-Host "🔐 Connecting to Microsoft Graph..." -ForegroundColor Cyan
        Connect-MgGraph -Scopes "CustomDetection.ReadWrite.All" -NoWelcome
        $ctx = Get-MgContext
        Write-Host "✅ Connected as $($ctx.Account)" -ForegroundColor Green
    }
}

function Get-ExistingRules {
    $response = Invoke-MgGraphRequest -Method GET `
        -Uri "/beta/security/rules/detectionRules" -OutputType PSObject
    return $response.value
}

function Build-RuleBody {
    param([PSCustomObject]$Rule)

    # Validate impactedAssets is non-empty (API rejects empty arrays with 400 BadRequest)
    if (-not $Rule.impactedAssets -or $Rule.impactedAssets.Count -eq 0) {
        throw "impactedAssets must contain at least 1 element. The API rejects empty arrays."
    }

    # Validate max 3 unique dynamic {{Column}} references across title + description combined
    # Graph API rejects >3 with 400: "Dynamic properties in alertTitle and alertDescription must not exceed 3 fields"
    $alertTitle = if ($Rule.PSObject.Properties['title'] -and $Rule.title) { $Rule.title } else { $Rule.displayName }
    $combinedText = "$alertTitle $($Rule.description)"
    # NOTE: @() wrapper is mandatory — Sort-Object -Unique returns a scalar string (not array)
    # when only 1 unique column exists. Under Set-StrictMode -Version Latest, calling .Count
    # on a scalar string throws "The property 'Count' cannot be found on this object".
    $dynamicCols = @([regex]::Matches($combinedText, '\{\{(\w+)\}\}') | ForEach-Object { $_.Groups[1].Value } | Sort-Object -Unique)
    if ($dynamicCols.Count -gt 3) {
        throw "title + description contain $($dynamicCols.Count) unique dynamic columns ($($dynamicCols -join ', ')) — max 3 allowed. Remove or inline some as static text."
    }

    # Valid identifier values per asset type (from Graph API enum — MS Learn /graph/api/resources/security-impacted*asset)
    $validIdentifiers = @{
        'device'  = @('deviceId', 'deviceName', 'remoteDeviceName', 'targetDeviceName', 'destinationDeviceName')
        'user'    = @('accountObjectId', 'accountSid', 'accountUpn', 'accountName', 'accountDomain', 'accountId',
                       'requestAccountSid', 'requestAccountName', 'requestAccountDomain', 'recipientObjectId',
                       'processAccountObjectId', 'initiatingAccountSid', 'initiatingProcessAccountUpn',
                       'initiatingAccountName', 'initiatingAccountDomain', 'servicePrincipalId',
                       'servicePrincipalName', 'targetAccountUpn')
        'mailbox' = @('accountUpn', 'fileOwnerUpn', 'initiatingProcessAccountUpn', 'lastModifyingAccountUpn',
                       'targetAccountUpn', 'senderFromAddress', 'senderDisplayName', 'recipientEmailAddress',
                       'senderMailFromAddress')
    }

    $impactedAssets = @()
    foreach ($asset in $Rule.impactedAssets) {
        $odataType = switch ($asset.type) {
            'device'  { "#microsoft.graph.security.impactedDeviceAsset" }
            'user'    { "#microsoft.graph.security.impactedUserAsset" }
            'mailbox' { "#microsoft.graph.security.impactedMailboxAsset" }
            default   { throw "Unknown asset type: $($asset.type)" }
        }

        # Validate identifier against the predefined API enum (Pitfall 9 — silent 400 InvalidInput)
        $allowed = $validIdentifiers[$asset.type]
        if ($asset.identifier -cnotin $allowed) {
            $suggestion = $allowed -join ', '
            throw "Invalid $($asset.type) identifier '$($asset.identifier)'. Valid values (case-sensitive): $suggestion. See SKILL.md Pitfall 9 — the query must also project a column matching this identifier name."
        }

        $assetEntry = @{
            identifier = $asset.identifier
        }
        $assetEntry['@odata.type'] = $odataType
        $impactedAssets += $assetEntry
    }

    # Alert title: use 'title' field if specified, fallback to displayName
    $alertTitle = if ($Rule.PSObject.Properties['title'] -and $Rule.title) {
        $Rule.title
    } else {
        $Rule.displayName
    }

    # Recommended actions: optional string field
    $recActions = if ($Rule.PSObject.Properties['recommendedActions'] -and $Rule.recommendedActions) {
        $Rule.recommendedActions
    } else {
        $null
    }

    # Response actions: ALWAYS empty — automated response actions are prohibited.
    # They must only be configured manually in the Defender portal after rule validation.
    # NOTE: Must be @(), not $null — $null serializes to "responseActions": null which
    # the API rejects with 400 Bad Request.
    $respActions = @()

    $body = @{
        displayName    = $Rule.displayName
        isEnabled      = $true
        queryCondition = @{
            queryText = $Rule.queryText
        }
        schedule = @{
            period = $Rule.schedule
        }
        detectionAction = @{
            alertTemplate = @{
                title              = $alertTitle
                description        = $Rule.description
                severity           = $Rule.severity
                category           = $Rule.category
                recommendedActions = $recActions
                mitreTechniques    = @($Rule.mitreTechniques)
                impactedAssets     = $impactedAssets
            }
            responseActions     = $respActions
        }
    }

    return $body | ConvertTo-Json -Depth 10
}

function Deploy-SingleRule {
    param(
        [string]$Body,
        [string]$DisplayName
    )

    try {
        $result = Invoke-MgGraphRequest -Method POST `
            -Uri "/beta/security/rules/detectionRules" `
            -Body $Body -ContentType "application/json" -OutputType PSObject
        return @{ Success = $true; Id = $result.id; Error = $null }
    }
    catch {
        $statusCode = $null
        if ($_.Exception.Response) {
            $statusCode = [int]$_.Exception.Response.StatusCode
        }

        # 409 Conflict may still create the rule (deletion propagation race)
        if ($statusCode -eq 409) {
            Write-Warning "⚠️  409 Conflict for '$DisplayName' — checking if rule was created despite error..."
            Start-Sleep -Seconds 5
            $existing = Get-ExistingRules | Where-Object { $_.displayName -eq $DisplayName }
            if ($existing) {
                return @{ Success = $true; Id = $existing.id; Error = "409 (rule created despite conflict)" }
            }
        }

        return @{ Success = $false; Id = $null; Error = $_.Exception.Message }
    }
}

# ── Main ─────────────────────────────────────────────────────────────────────

Write-Host "`n╔══════════════════════════════════════════════════╗" -ForegroundColor Cyan
Write-Host   "║    Custom Detection Rule Deployment              ║" -ForegroundColor Cyan
Write-Host   "╚══════════════════════════════════════════════════╝`n" -ForegroundColor Cyan

# Load manifest — @() forces array even for single-rule manifests (ConvertFrom-Json
# returns a scalar PSCustomObject for single-element JSON arrays under StrictMode)
$manifest = @(Get-Content -Path $ManifestPath -Raw | ConvertFrom-Json)
Write-Host "📋 Loaded $($manifest.Count) rule(s) from manifest" -ForegroundColor White

if ($DryRun) {
    Write-Host "🔍 DRY RUN — listing rules to deploy (no queries executed, no rules created)`n" -ForegroundColor Yellow
}

# Connect to Graph
Connect-GraphIfNeeded

# Get existing rules for dedup — @() forces array for tenants with 0 or 1 existing rule
$existingRules = @(Get-ExistingRules)
$existingNames = @($existingRules | ForEach-Object { $_.displayName })
Write-Host "📊 Found $($existingRules.Count) existing rule(s) in tenant`n" -ForegroundColor White

# Process each rule
$results = @()
$index = 0

foreach ($rule in $manifest) {
    $index++
    $prefix = "[$index/$($manifest.Count)]"

    Write-Host "$prefix $($rule.displayName)" -ForegroundColor White

    # Duplicate check (default: skip existing; -Force overrides)
    if ($rule.displayName -in $existingNames) {
        if (-not $Force) {
            $existingId = ($existingRules | Where-Object { $_.displayName -eq $rule.displayName }).id
            Write-Host "   ⏭️  Already exists (ID: $existingId) — skipping (use -Force to override)" -ForegroundColor Yellow
            $results += @{ Name = $rule.displayName; Status = "Skipped"; Id = $existingId }
            continue
        }
        Write-Host "   ⚠️  Exists but -Force specified — attempting POST anyway" -ForegroundColor Yellow
    }

    if ($DryRun) {
        $status = if ($rule.displayName -in $existingNames) { "Exists" } else { "New" }
        # Run Build-RuleBody to validate even in dry-run (catches identifier, dynamic column, and other errors)
        try {
            $null = Build-RuleBody -Rule $rule
            Write-Host "   📝 $status — schedule: $($rule.schedule), severity: $($rule.severity)" -ForegroundColor Gray
            $results += @{ Name = $rule.displayName; Status = "Validated"; Id = $null }
        }
        catch {
            Write-Host "   ❌ Validation failed: $($_.Exception.Message)" -ForegroundColor Red
            $results += @{ Name = $rule.displayName; Status = "Failed"; Id = $null }
        }
        continue
    }

    # Build and deploy
    $body = Build-RuleBody -Rule $rule
    $deployResult = Deploy-SingleRule -Body $body -DisplayName $rule.displayName

    if ($deployResult.Success) {
        Write-Host "   ✅ Created (ID: $($deployResult.Id))" -ForegroundColor Green
        if ($deployResult.Error) {
            Write-Host "   ⚠️  Note: $($deployResult.Error)" -ForegroundColor Yellow
        }
        $results += @{ Name = $rule.displayName; Status = "Created"; Id = $deployResult.Id }
    }
    else {
        Write-Host "   ❌ Failed: $($deployResult.Error)" -ForegroundColor Red
        $results += @{ Name = $rule.displayName; Status = "Failed"; Id = $null }
    }
}

# Summary
Write-Host "`n╔══════════════════════════════════════════════════╗" -ForegroundColor Cyan
Write-Host   "║    Deployment Summary                            ║" -ForegroundColor Cyan
Write-Host   "╚══════════════════════════════════════════════════╝`n" -ForegroundColor Cyan

$created = @($results | Where-Object { $_.Status -eq 'Created' }).Count
$skipped = @($results | Where-Object { $_.Status -eq 'Skipped' }).Count
$failed  = @($results | Where-Object { $_.Status -eq 'Failed' }).Count
$validated = @($results | Where-Object { $_.Status -eq 'Validated' }).Count

if ($DryRun) {
    Write-Host "   Validated: $validated" -ForegroundColor Green
}
else {
    Write-Host "   Created:  $created" -ForegroundColor Green
    Write-Host "   Skipped:  $skipped" -ForegroundColor Yellow
    Write-Host "   Failed:   $failed" -ForegroundColor Red
}

Write-Host ""

# Return results for pipeline use
return $results
