Private/Invoke-Analysis.ps1

function Invoke-Analysis {
    <#
    .SYNOPSIS
        Computes the cost-value matrix and generates prioritised recommendations.
    .OUTPUTS
        PSCustomObject with TableAnalysis (array), Recommendations (array),
        and Summary statistics.
    #>

    [CmdletBinding()]
    param(
        [Parameter(Mandatory)][array]$TableUsage,
        [Parameter(Mandatory)][PSCustomObject]$Classifications,
        [Parameter(Mandatory)][PSCustomObject]$RulesData,
        [Parameter(Mandatory)][PSCustomObject]$HuntingData,
        [PSCustomObject]$DefenderXDR,
        [array]$SocRecommendations,
        [array]$TableRetention,
        [int]$WorkspaceRetentionDays = 0,
        [decimal]$PricePerGB = 5.59,
        [PSCustomObject]$DataTransforms,
        [hashtable]$HighValueFields,
        [hashtable]$FieldFrequencyStats = @{},
        [array]$Incidents = @(),
        [array]$AutomationRules = @(),
        [hashtable]$AutoCloseHealthData,
        [switch]$IncludeDetectionAnalyzer
    )

    $classMap       = $Classifications.Classifications   # hashtable
    $ruleCoverage   = $RulesData.TableCoverage            # hashtable: table -> count
    $huntCoverage   = $HuntingData.TableCoverage          # hashtable: table -> count
    $xdrCoverage    = if ($DefenderXDR) { $DefenderXDR.XDRTableCoverage } else { @{} }
    $knownXDRTables = if ($DefenderXDR) { $DefenderXDR.KnownXDRTables } else { @() }

    # Build retention lookup from Tables API data
    $retentionMap = @{}
    if ($TableRetention) {
        foreach ($tr in $TableRetention) {
            $retentionMap[$tr.TableName] = $tr
        }
    }

    # Build transform lookup from DCR data
    $transformLookup = @{}
    if ($DataTransforms -and $DataTransforms.TableLookup) {
        $transformLookup = $DataTransforms.TableLookup
    }

    Write-Verbose "Starting analysis for $($TableUsage.Count) table(s)."

    # Per-table analysis
    $tableAnalysis = foreach ($table in $TableUsage) {
        $name = $table.TableName
        $cls  = $classMap[$name]

        $ruleCount    = if ($ruleCoverage.ContainsKey($name)) { [int]$ruleCoverage[$name] } else { 0 }
        $huntCount    = if ($huntCoverage.ContainsKey($name)) { [int]$huntCoverage[$name] } else { 0 }
        $xdrRuleCount = if ($xdrCoverage.ContainsKey($name)) { [int]$xdrCoverage[$name] } else { 0 }
        $totalCoverage = $ruleCount + $huntCount
        $effectiveCoverage = $ruleCount + $huntCount + $xdrRuleCount

        # Cost tier
        $costTier = switch ($true) {
            ($table.IsFree)              { 'Free'; break }
            ($table.MonthlyGB -ge 50)    { 'Very High'; break }
            ($table.MonthlyGB -ge 10)    { 'High'; break }
            ($table.MonthlyGB -ge 1)     { 'Medium'; break }
            default                      { 'Low' }
        }

        # Detection value tier (includes CDR coverage for accurate assessment)
        $detectionTier = switch ($true) {
            ($effectiveCoverage -ge 10) { 'High'; break }
            ($effectiveCoverage -ge 3)  { 'Medium'; break }
            ($effectiveCoverage -ge 1)  { 'Low'; break }
            default                     { 'None' }
        }

        $classification = if ($cls) { $cls.Classification } else { 'unknown' }

        # Combined assessment
        $assessment = Get-Assessment -Classification $classification `
                                      -CostTier $costTier `
                                      -DetectionTier $detectionTier `
                                      -IsFree $table.IsFree

        # Retention data
        $ret = $retentionMap[$name]

        # XDR state: known XDR table + plan from workspace (null = not XDR, NotStreaming = XDR default 30d only)
        $isKnownXDR = $name -in $knownXDRTables
        $xdrState = if (-not $isKnownXDR) { $null }
                    elseif (-not $ret) { 'NotStreaming' }
                    else { $ret.Plan }  # Analytics, Basic, or Auxiliary
        $isXDRStreaming = $isKnownXDR -and ($null -ne $ret)

        $recommendedRetention = if ($cls -and $null -ne $cls.RecommendedRetentionDays -and $cls.RecommendedRetentionDays -gt 0) { [int]$cls.RecommendedRetentionDays } else { 90 }
        $actualTotal = if ($ret) { [int]$ret.TotalRetentionInDays } else { $null }
        $actualInteractive = if ($ret) { [int]$ret.RetentionInDays } else { $null }
        $tablePlan = if ($ret) { $ret.Plan } else { $null }
        $tableSubType = if ($ret) { $ret.TableSubType } else { $null }
        # Compliant = at least 90 days total retention (baseline)
        $retentionCompliant = if ($null -ne $actualTotal -and $tablePlan -eq 'Analytics') { $actualTotal -ge 90 } else { $null }
        # Can improve = meets 90d baseline but below category-specific recommendation
        $retentionCanImprove = if ($retentionCompliant -and $recommendedRetention -gt 90) { $actualTotal -lt $recommendedRetention } else { $false }

        # Transform data
        $tableTransforms = $transformLookup[$name]
        $hasTransform = $null -ne $tableTransforms -and $tableTransforms.Count -gt 0
        $transformTypes = if ($hasTransform) { @($tableTransforms | ForEach-Object { $_.TransformType } | Select-Object -Unique) } else { @() }
        $transformKql = if ($hasTransform) { @($tableTransforms | ForEach-Object { $_.TransformKql }) } else { @() }

        # Split table detection
        $isSplitTable = if ($cls) { [bool]$cls.IsSplitTable } else { $false }
        $parentTable = if ($cls) { $cls.ParentTable } else { $null }

        # Schema columns from retention data
        $schemaColumns = if ($ret -and $ret.Columns) { @($ret.Columns) } else { @() }

        $splitSuggestion = Get-SplitKql -TableName $name `
                                        -Rules $RulesData.Rules `
                                        -HighValueFieldsDB $HighValueFields `
                                        -FieldFrequencyStats $FieldFrequencyStats `
                                        -TableCategory $(if ($cls) { $cls.Category } else { $null })

        [PSCustomObject]@{
            TableName                    = $name
            Classification               = $classification
            Category                     = if ($cls) { $cls.Category } else { 'Unknown' }
            MonthlyGB                    = $table.MonthlyGB
            EstMonthlyCostUSD            = $table.EstMonthlyCostUSD
            IsFree                       = $table.IsFree
            AnalyticsRules               = $ruleCount
            HuntingQueries               = $huntCount
            XDRRules                     = $xdrRuleCount
            TotalCoverage                = $totalCoverage
            EffectiveCoverage            = $effectiveCoverage
            CostTier                     = $costTier
            DetectionTier                = $detectionTier
            Assessment                   = $assessment
            IsXDRStreaming               = $isXDRStreaming
            XDRState                     = $xdrState
            RecommendedTier              = if ($cls) { $cls.RecommendedTier } else { 'analytics' }
            ActualRetentionDays          = $actualTotal
            ActualInteractiveRetentionDays = $actualInteractive
            ArchiveRetentionInDays       = if ($ret) { [int]$ret.ArchiveRetentionInDays } else { $null }
            RecommendedRetentionDays     = $recommendedRetention
            TablePlan                    = $tablePlan
            TableSubType                 = $tableSubType
            RetentionCompliant           = $retentionCompliant
            RetentionCanImprove          = $retentionCanImprove
            HasTransform                 = $hasTransform
            TransformTypes               = $transformTypes
            TransformKql                 = $transformKql
            IsSplitTable                 = $isSplitTable
            ParentTable                  = $parentTable
            SchemaColumns                = $schemaColumns
            SplitSuggestion              = $splitSuggestion
        }
    }

    $cdrRules = if ($DefenderXDR -and $DefenderXDR.CustomRules) { $DefenderXDR.CustomRules } else { @() }
    $detectionAnalyzer = if ($IncludeDetectionAnalyzer) {
        Get-DetectionAnalyzerData -Rules $RulesData.Rules `
                                  -Incidents $Incidents `
                                  -AutomationRules $AutomationRules `
                                  -CustomDetectionRules $cdrRules `
                                  -AutoCloseHealthData $AutoCloseHealthData
    }
    else {
        $null
    }

    $xdrChecker = Get-XdrCheckerData -TableAnalysis $tableAnalysis -KnownXDRTables $knownXDRTables -RetentionMap $retentionMap

    # Generate recommendations
    $recommendations = [System.Collections.Generic.List[PSCustomObject]]::new()

    foreach ($t in $tableAnalysis) {
        # 1. Data lake candidates: secondary + high cost + low rules
        if ($t.Classification -eq 'secondary' -and
            $t.CostTier -in @('High', 'Very High') -and
            $t.DetectionTier -in @('None', 'Low')) {

            $savings = [math]::Round($t.EstMonthlyCostUSD * 0.95, 2)  # ~95% savings at data lake pricing
            $recommendations.Add([PSCustomObject]@{
                Priority     = 'High'
                Type         = 'DataLake'
                TableName    = $t.TableName
                Title        = "Move $($t.TableName) to Data Lake tier"
                Detail       = "Secondary source ingesting $($t.MonthlyGB) GB/mo with $($t.EffectiveCoverage) detection(s). " +
                               "Create summary rules to aggregate key events back to analytics tier."
                EstSavingsUSD = $savings
                CurrentCost   = $t.EstMonthlyCostUSD
            })
        }

        # 2. Zero-detection high-cost tables
        if (-not $t.IsFree -and
            $t.CostTier -in @('High', 'Very High') -and
            $t.DetectionTier -eq 'None') {

            $recommendations.Add([PSCustomObject]@{
                Priority     = 'High'
                Type         = 'LowValue'
                TableName    = $t.TableName
                Title        = "$($t.TableName) has zero detections"
                Detail       = "Ingesting $($t.MonthlyGB) GB/mo (~`$$($t.EstMonthlyCostUSD)/mo) with no analytics rules or hunting queries. " +
                               "Consider: add analytics rules, apply ingest-time filtering, or move to data lake."
                EstSavingsUSD = $t.EstMonthlyCostUSD
                CurrentCost   = $t.EstMonthlyCostUSD
            })
        }

        # 3. XDR streaming optimization
        if ($t.IsXDRStreaming -and $t.AnalyticsRules -eq 0 -and $t.XDRRules -gt 0) {
            $recommendations.Add([PSCustomObject]@{
                Priority     = 'Medium'
                Type         = 'XDROptimize'
                TableName    = $t.TableName
                Title        = "Stop streaming $($t.TableName) to Sentinel"
                Detail       = "$($t.XDRRules) Defender XDR custom detection rule(s) cover this table. " +
                               "No Sentinel-specific rules exist. Stop streaming to save cost; use unified XDR portal instead."
                EstSavingsUSD = $t.EstMonthlyCostUSD
                CurrentCost   = $t.EstMonthlyCostUSD
            })
        }

        # 4. Missing coverage on primary sources
        if ($t.Classification -eq 'primary' -and
            -not $t.IsFree -and
            $t.EffectiveCoverage -eq 0) {
            $recommendations.Add([PSCustomObject]@{
                Priority     = 'Medium'
                Type         = 'MissingCoverage'
                TableName    = $t.TableName
                Title        = "No detections for $($t.TableName)"
                Detail       = "Primary security source with zero analytics rules or hunting queries. " +
                               "Add detection rules to realise value from this data source."
                EstSavingsUSD = 0
                CurrentCost   = $t.EstMonthlyCostUSD
            })
        }

        # 5. Ingest-time filtering candidates
        if (-not $t.IsFree -and
            $t.MonthlyGB -ge 20 -and
            $t.Classification -eq 'primary' -and
            $t.EffectiveCoverage -ge 1 -and $t.EffectiveCoverage -le 3) {
            $recommendations.Add([PSCustomObject]@{
                Priority     = 'Low'
                Type         = 'Filter'
                TableName    = $t.TableName
                Title        = "Consider filtering $($t.TableName)"
                Detail       = "High-volume primary source ($($t.MonthlyGB) GB/mo) with only $($t.EffectiveCoverage) detection(s). " +
                               "Review if ingest-time transformation can filter unneeded event types."
                EstSavingsUSD = [math]::Round($t.EstMonthlyCostUSD * 0.3, 2)
                CurrentCost   = $t.EstMonthlyCostUSD
            })
        }

        # 9. Split candidate — high-volume tables with some detections that could benefit from split
        if (-not $t.IsFree -and
            -not $t.IsSplitTable -and
            -not $t.HasTransform -and
            $t.MonthlyGB -ge 10 -and
            $t.EffectiveCoverage -ge 1 -and
            $t.Classification -eq 'primary') {

            $splitSavings = [math]::Round($t.EstMonthlyCostUSD * 0.50, 2)

            # Use cached split KQL suggestion
            $splitSuggestion = $t.SplitSuggestion

            $detail = "High-volume primary source ($($t.MonthlyGB) GB/mo) with $($t.EffectiveCoverage) detection(s). " +
                      "Use a Sentinel split transform to route low-value events to Data Lake tier " +
                      "while keeping detection-relevant events in Analytics."
            if ($splitSuggestion.Source -ne 'none') {
                $detail += " Split KQL suggestion available (source: $($splitSuggestion.Source))."
            }

            $recommendations.Add([PSCustomObject]@{
                Priority        = 'Medium'
                Type            = 'SplitCandidate'
                TableName       = $t.TableName
                Title           = "Consider split transform for $($t.TableName)"
                Detail          = $detail
                EstSavingsUSD   = $splitSavings
                CurrentCost     = $t.EstMonthlyCostUSD
                SplitSuggestion = $splitSuggestion
            })
        }
    }

    # Correlation data pass-through
    $corrExcluded = @($RulesData.Rules | Where-Object ExcludedFromCorrelation)
    $corrIncluded = @($RulesData.Rules | Where-Object IncludedInCorrelation)

    # 6. Workspace retention check
    if ($WorkspaceRetentionDays -gt 0 -and $WorkspaceRetentionDays -lt 90) {
        $recommendations.Add([PSCustomObject]@{
            Priority      = 'High'
            Type          = 'RetentionShortfall'
            TableName     = '(workspace default)'
            Title         = "Workspace default retention is $($WorkspaceRetentionDays)d — increase to at least 90d"
            Detail        = "The workspace default retention is $($WorkspaceRetentionDays) days. " +
                            "A 90-day minimum is recommended as a security baseline. " +
                            "Tables inheriting the default will not meet compliance requirements."
            EstSavingsUSD = 0
            CurrentCost   = 0
        })
    }

    # 7. Per-table retention below 90d baseline
    foreach ($t in $tableAnalysis) {
        if ($null -eq $t.RetentionCompliant -or $t.RetentionCompliant) { continue }

        $prio = if ($t.Classification -eq 'primary') { 'High' } else { 'Medium' }
        $recommendations.Add([PSCustomObject]@{
            Priority      = $prio
            Type          = 'RetentionShortfall'
            TableName     = $t.TableName
            Title         = "$($t.TableName) retention below 90d baseline ($($t.ActualRetentionDays)d)"
            Detail        = "Total retention is $($t.ActualRetentionDays) days. A 90-day minimum is the recommended " +
                            "security baseline. Increase total retention or add archive retention."
            EstSavingsUSD = 0
            CurrentCost   = $t.EstMonthlyCostUSD
        })
    }

    # 8. Retention improvement opportunities (meets 90d but below category recommendation)
    foreach ($t in $tableAnalysis) {
        if (-not $t.RetentionCanImprove) { continue }

        $recommendations.Add([PSCustomObject]@{
            Priority      = 'Low'
            Type          = 'RetentionImprovement'
            TableName     = $t.TableName
            Title         = "$($t.TableName) could benefit from $($t.RecommendedRetentionDays)d retention (currently $($t.ActualRetentionDays)d)"
            Detail        = "Meets the 90-day baseline but best-practice guidance recommends $($t.RecommendedRetentionDays) days " +
                            "for $($t.Category) tables."
            EstSavingsUSD = 0
            CurrentCost   = $t.EstMonthlyCostUSD
        })
    }

    foreach ($rec in $detectionAnalyzer.Recommendations) {
        $recommendations.Add($rec)
    }

    foreach ($rec in $xdrChecker.Recommendations) {
        $recommendations.Add($rec)
    }

    $sortedRecs = $recommendations | Sort-Object EstSavingsUSD -Descending

    # Build schema lookup for live tuning analysis
    $schemaLookup = @{}
    foreach ($t in $tableAnalysis) {
        if ($t.SchemaColumns -and $t.SchemaColumns.Count -gt 0) {
            $schemaLookup[$t.TableName] = $t.SchemaColumns
        }
    }

    # Live tuning analysis (deployed rules + schema)
    $liveTuningAnalysis = Get-LiveTuningAnalysis -Rules $RulesData.Rules `
                                                  -HuntingQueries $HuntingData.Queries `
                                                  -TableAnalysis $tableAnalysis `
                                                  -SchemaLookup $schemaLookup

    # Summary stats
    $primaryTables   = @($tableAnalysis | Where-Object Classification -eq 'primary')
    $secondaryTables = @($tableAnalysis | Where-Object Classification -eq 'secondary')
    $unknownTables   = @($tableAnalysis | Where-Object Classification -eq 'unknown')

    $totalMonthlyGB   = ($tableAnalysis | Measure-Object MonthlyGB -Sum).Sum
    $totalMonthlyCost = ($tableAnalysis | Measure-Object EstMonthlyCostUSD -Sum).Sum
    $totalSavings     = ($sortedRecs | Measure-Object EstSavingsUSD -Sum).Sum

    $tablesWithRules  = @($tableAnalysis | Where-Object { $_.EffectiveCoverage -gt 0 }).Count
    $coveragePercent  = if ($tableAnalysis.Count -gt 0) {
        [math]::Round(($tablesWithRules / $tableAnalysis.Count) * 100, 0)
    } else { 0 }

    $retentionCompliantCount  = @($tableAnalysis | Where-Object { $_.RetentionCompliant -eq $true }).Count
    $retentionNonCompliant    = @($tableAnalysis | Where-Object { $_.RetentionCompliant -eq $false }).Count
    $retentionChecked         = $retentionCompliantCount + $retentionNonCompliant
    $retentionImprovableCount = @($tableAnalysis | Where-Object { $_.RetentionCanImprove -eq $true }).Count

    # Transform stats
    $tablesWithTransforms = @($tableAnalysis | Where-Object { $_.HasTransform }).Count
    $splitTables          = @($tableAnalysis | Where-Object { $_.IsSplitTable }).Count
    $transformDCRCount    = if ($DataTransforms) { $DataTransforms.RelevantDCRs.Count } else { 0 }

    # Detection coverage stats (table-count-based, all tables including free)
    $allTables         = @($tableAnalysis)
    $totalTableCount   = $allTables.Count
    $nonFreeTables     = @($tableAnalysis | Where-Object { -not $_.IsFree })
    $totalNonFreeGB    = ($nonFreeTables | Measure-Object MonthlyGB -Sum).Sum
    if ($totalNonFreeGB -le 0) { $totalNonFreeGB = 0 }

    $tablesWithDetection = @($allTables | Where-Object { ($_.AnalyticsRules + $_.XDRRules) -gt 0 })
    $tablesWithHunting   = @($allTables | Where-Object { $_.HuntingQueries -gt 0 })
    $tablesWithCombined  = @($allTables | Where-Object { $_.EffectiveCoverage -gt 0 })

    $detectionCoveredGB = ($tablesWithDetection | Measure-Object MonthlyGB -Sum).Sum
    $huntingCoveredGB   = ($tablesWithHunting | Measure-Object MonthlyGB -Sum).Sum
    $combinedCoveredGB  = ($tablesWithCombined | Measure-Object MonthlyGB -Sum).Sum

    $detectionCoveragePct = if ($totalTableCount -gt 0) { [math]::Round(($tablesWithDetection.Count / $totalTableCount) * 100, 1) } else { 0 }
    $huntingCoveragePct   = if ($totalTableCount -gt 0) { [math]::Round(($tablesWithHunting.Count / $totalTableCount) * 100, 1) } else { 0 }
    $combinedCoveragePct  = if ($totalTableCount -gt 0) { [math]::Round(($tablesWithCombined.Count / $totalTableCount) * 100, 1) } else { 0 }

    $avgDetectionsPerTable = if ($tablesWithDetection.Count -gt 0) {
        [math]::Round((($tablesWithDetection | ForEach-Object { $_.AnalyticsRules + $_.XDRRules } | Measure-Object -Sum).Sum / $tablesWithDetection.Count), 1)
    } else { 0 }

    # Enrich DetectionAnalyzer Summary with coverage stats
    if ($detectionAnalyzer) {
        Add-Member -InputObject $detectionAnalyzer.Summary -NotePropertyName DetectionCoverageGB -NotePropertyValue ([math]::Round($detectionCoveredGB, 2))
        Add-Member -InputObject $detectionAnalyzer.Summary -NotePropertyName HuntingCoverageGB -NotePropertyValue ([math]::Round($huntingCoveredGB, 2))
        Add-Member -InputObject $detectionAnalyzer.Summary -NotePropertyName CombinedCoverageGB -NotePropertyValue ([math]::Round($combinedCoveredGB, 2))
        Add-Member -InputObject $detectionAnalyzer.Summary -NotePropertyName TotalIngestionGB -NotePropertyValue ([math]::Round($totalNonFreeGB, 2))
        Add-Member -InputObject $detectionAnalyzer.Summary -NotePropertyName DetectionCoveragePct -NotePropertyValue $detectionCoveragePct
        Add-Member -InputObject $detectionAnalyzer.Summary -NotePropertyName HuntingCoveragePct -NotePropertyValue $huntingCoveragePct
        Add-Member -InputObject $detectionAnalyzer.Summary -NotePropertyName CombinedCoveragePct -NotePropertyValue $combinedCoveragePct
        Add-Member -InputObject $detectionAnalyzer.Summary -NotePropertyName AvgDetectionsPerTable -NotePropertyValue $avgDetectionsPerTable
        Add-Member -InputObject $detectionAnalyzer.Summary -NotePropertyName TablesWithDetection -NotePropertyValue $tablesWithDetection.Count
        Add-Member -InputObject $detectionAnalyzer.Summary -NotePropertyName TablesWithHunting -NotePropertyValue $tablesWithHunting.Count
        Add-Member -InputObject $detectionAnalyzer.Summary -NotePropertyName TablesWithCombined -NotePropertyValue $tablesWithCombined.Count
        Add-Member -InputObject $detectionAnalyzer.Summary -NotePropertyName TotalTables -NotePropertyValue $totalTableCount
    }

    [PSCustomObject]@{
        TableAnalysis        = $tableAnalysis
        Recommendations      = @($sortedRecs)
        KeywordGaps          = $Classifications.KeywordGaps
        SocRecommendations   = $SocRecommendations
        CorrelationExcluded  = $corrExcluded
        CorrelationIncluded  = $corrIncluded
        DataTransforms       = $DataTransforms
        DetectionAnalyzer    = $detectionAnalyzer
        XdrChecker           = $xdrChecker
        LiveTuningAnalysis   = @($liveTuningAnalysis)
        Summary             = [PSCustomObject]@{
            TotalTables            = $tableAnalysis.Count
            PrimaryCount           = $primaryTables.Count
            SecondaryCount         = $secondaryTables.Count
            UnknownCount           = $unknownTables.Count
            TotalMonthlyGB         = [math]::Round($totalMonthlyGB, 2)
            TotalMonthlyCost       = [math]::Round($totalMonthlyCost, 2)
            TotalRules             = $RulesData.TotalRules
            EnabledRules           = $RulesData.EnabledRules
            DontCorrCount          = $RulesData.DontCorrCount
            IncCorrCount           = $RulesData.IncCorrCount
            HuntingQueries         = $HuntingData.TotalQueries
            CoveragePercent        = $coveragePercent
            EstTotalSavings        = [math]::Round($totalSavings, 2)
            PricePerGB             = $PricePerGB
            WorkspaceRetentionDays = $WorkspaceRetentionDays
            RetentionCompliant     = $retentionCompliantCount
            RetentionNonCompliant  = $retentionNonCompliant
            RetentionChecked       = $retentionChecked
            RetentionImprovable    = $retentionImprovableCount
            TablesWithTransforms   = $tablesWithTransforms
            SplitTables            = $splitTables
            TransformDCRs          = $transformDCRCount
            DetectionRulesAnalyzed = $detectionAnalyzer.Summary.RulesAnalyzed
            NoisyRulesDetected     = $detectionAnalyzer.Summary.NoisyRules
            AutoClosedIncidents    = $detectionAnalyzer.Summary.AutoClosedIncidents
            XdrCheckerIssues       = $xdrChecker.Summary.IssueCount
            XdrAdvisoryRetention   = $xdrChecker.Summary.AdvisoryRetentionDays
        }
    }
}

function Get-Assessment {
    [CmdletBinding()]
    param(
        [string]$Classification,
        [string]$CostTier,
        [string]$DetectionTier,
        [bool]$IsFree
    )

    if ($IsFree) { return 'Free Tier' }

    switch ($true) {
        ($Classification -eq 'primary' -and $DetectionTier -in @('High', 'Medium')) {
            'High Value'; break
        }
        ($Classification -eq 'primary' -and $DetectionTier -eq 'Low' -and $CostTier -in @('Low', 'Medium')) {
            'Good Value'; break
        }
        ($Classification -eq 'primary' -and $DetectionTier -eq 'None') {
            'Missing Coverage'; break
        }
        ($Classification -eq 'secondary' -and $CostTier -in @('High', 'Very High') -and $DetectionTier -in @('None', 'Low')) {
            'Optimize'; break
        }
        ($CostTier -in @('High', 'Very High') -and $DetectionTier -eq 'None') {
            'Low Value'; break
        }
        ($DetectionTier -eq 'None') {
            'Underutilized'; break
        }
        default {
            'Good Value'
        }
    }
}

function Get-DetectionAnalyzerData {
    [CmdletBinding()]
    param(
        [array]$Rules,
        [array]$Incidents,
        [array]$AutomationRules,
        [array]$CustomDetectionRules = @(),
        [hashtable]$AutoCloseHealthData
    )

    $allRulesEmpty = (-not $Rules -or $Rules.Count -eq 0) -and (-not $CustomDetectionRules -or $CustomDetectionRules.Count -eq 0)
    if ($allRulesEmpty) {
        return [PSCustomObject]@{
            RuleMetrics = @()
            Recommendations = @()
            Summary = [PSCustomObject]@{
                RulesAnalyzed = 0
                NoisyRules = 0
                IncidentsAnalyzed = 0
                AutoClosedIncidents = 0
                CustomDetectionRules = 0
                CDRCorrelatedIncidents = 0
            }
        }
    }

    # Build a unified rule list: analytics rules first, then CDRs
    $unifiedRules = [System.Collections.Generic.List[object]]::new()

    if ($Rules) {
        foreach ($rule in $Rules) {
            $unifiedRules.Add([PSCustomObject]@{
                RuleName = $rule.RuleName
                Kind     = $rule.Kind
                Enabled  = $rule.Enabled
                Source   = 'Sentinel'
                Tables   = @()
                Frequency = $null
            })
        }
    }

    $cdrCount = 0
    if ($CustomDetectionRules -and $CustomDetectionRules.Count -gt 0) {
        foreach ($cdr in $CustomDetectionRules) {
            $displayName = $null
            if ($cdr.PSObject.Properties.Name -contains 'displayName') { $displayName = $cdr.displayName }
            if (-not $displayName -and $cdr.PSObject.Properties.Name -contains 'detectionAction') { $displayName = $cdr.detectionAction }
            if (-not $displayName) { $displayName = "CDR-$cdrCount" }

            $isEnabled = $true
            if ($cdr.PSObject.Properties.Name -contains 'isEnabled') { $isEnabled = [bool]$cdr.isEnabled }

            $tables = @()
            $query = $null
            if ($cdr.PSObject.Properties.Name -contains 'queryCondition' -and $cdr.queryCondition) {
                $query = $cdr.queryCondition.queryText
            }
            if ($query) {
                $tables = @(Get-TablesFromKql -Kql $query)
            }

            $frequency = $null
            if ($cdr.PSObject.Properties.Name -contains 'schedule' -and $cdr.schedule) {
                if ($cdr.schedule.PSObject.Properties.Name -contains 'period') {
                    $frequency = $cdr.schedule.period
                }
            }

            $unifiedRules.Add([PSCustomObject]@{
                RuleName  = $displayName
                Kind      = 'CustomDetection'
                Enabled   = $isEnabled
                Source    = 'DefenderXDR'
                Tables    = $tables
                Frequency = $frequency
            })
            $cdrCount++
        }
    }

    # Build incident buckets keyed by rule name
    $incidentBuckets = @{}
    foreach ($rule in $unifiedRules) {
        $incidentBuckets[$rule.RuleName] = [System.Collections.Generic.List[object]]::new()
    }

    foreach ($incident in $Incidents) {
        $candidateNames = [System.Collections.Generic.HashSet[string]]::new([StringComparer]::OrdinalIgnoreCase)
        foreach ($n in @($incident.RelatedAnalyticRuleNames)) {
            if (-not [string]::IsNullOrWhiteSpace($n)) { [void]$candidateNames.Add($n) }
        }

        # Title heuristic fallback — works for both analytics and CDR rules
        if ($candidateNames.Count -eq 0 -and -not [string]::IsNullOrWhiteSpace($incident.Title)) {
            foreach ($rule in $unifiedRules) {
                if ($incident.Title.IndexOf($rule.RuleName, [System.StringComparison]::OrdinalIgnoreCase) -ge 0) {
                    [void]$candidateNames.Add($rule.RuleName)
                }
            }
        }

        foreach ($ruleName in $candidateNames) {
            if ($incidentBuckets.ContainsKey($ruleName)) {
                $incidentBuckets[$ruleName].Add($incident)
            }
        }
    }

    # Compute per-rule metrics
    $ruleMetrics = [System.Collections.Generic.List[object]]::new()
    foreach ($rule in $unifiedRules) {
        $ruleIncidents = @($incidentBuckets[$rule.RuleName])
        $total = $ruleIncidents.Count
        $closed = @($ruleIncidents | Where-Object { $_.Status -eq 'Closed' })

        $autoClosed = @()
        foreach ($inc in $closed) {
            $isAutoClose = $false

            # Primary: SentinelHealth data (definitive if available)
            if ($null -ne $AutoCloseHealthData -and $inc.IncidentNumber -and $AutoCloseHealthData.ContainsKey([int]$inc.IncidentNumber)) {
                $isAutoClose = $true
            }

            # Fallback: automation rule condition matching
            if (-not $isAutoClose) {
                $matched = @($AutomationRules | Where-Object {
                    $_.IsCloseIncidentRule -and $_.Enabled -and (Test-AutomationRuleIncidentMatch -AutomationRule $_ -IncidentTitle $inc.Title -IncidentRuleIds $inc.RelatedAnalyticRuleIds)
                })
                if ($matched.Count -gt 0) {
                    $isAutoClose = $true
                }
            }

            if ($isAutoClose) {
                $autoClosed += $inc
            }
        }

        $manualClosed = @($closed | Where-Object { $_ -notin $autoClosed })
        $falsePositive = @($closed | Where-Object { $_.Classification -eq 'FalsePositive' })
        $benignPositive = @($closed | Where-Object { $_.Classification -eq 'BenignPositive' })
        $truePositive = @($closed | Where-Object { $_.Classification -eq 'TruePositive' })

        $closeMinutes = @($closed | ForEach-Object {
            if ($_.CreatedTimeUtc -and $_.ClosedTimeUtc) {
                [math]::Max([math]::Round(($_.ClosedTimeUtc - $_.CreatedTimeUtc).TotalMinutes, 2), 0)
            }
        } | Where-Object { $null -ne $_ })

        $avgClose = if ($closeMinutes.Count -gt 0) { [math]::Round((($closeMinutes | Measure-Object -Average).Average), 2) } else { $null }
        $autoCloseRatio = if ($closed.Count -gt 0) { [math]::Round(($autoClosed.Count / $closed.Count), 4) } else { 0 }
        $falseRatio = if ($closed.Count -gt 0) { [math]::Round(($falsePositive.Count / $closed.Count), 4) } else { 0 }
        $benignRatio = if ($closed.Count -gt 0) { [math]::Round(($benignPositive.Count / $closed.Count), 4) } else { 0 }

        $ruleMetrics.Add([PSCustomObject]@{
            RuleName                = $rule.RuleName
            RuleKind                = $rule.Kind
            Enabled                 = $rule.Enabled
            Source                  = $rule.Source
            Tables                  = $rule.Tables
            Frequency               = $rule.Frequency
            IncidentsTotal          = $total
            IncidentsClosed         = $closed.Count
            IncidentsAutoClosed     = $autoClosed.Count
            IncidentsManualClosed   = $manualClosed.Count
            FalsePositiveClosed     = $falsePositive.Count
            BenignPositiveClosed    = $benignPositive.Count
            TruePositiveClosed      = $truePositive.Count
            AutoCloseRatio          = $autoCloseRatio
            FalsePositiveRatio      = $falseRatio
            BenignPositiveRatio     = $benignRatio
            AvgCloseMinutes         = $avgClose
            LinkedAutomationRules   = @($AutomationRules | Where-Object {
                $_.IsCloseIncidentRule -and $_.Enabled -and $_.TitleFilters.Count -gt 0
            } | ForEach-Object DisplayName | Select-Object -Unique)
        })
    }

    # Noisiness scoring — only score rules that have incident data
    $scorableMetrics = @($ruleMetrics | Where-Object { $_.IncidentsTotal -gt 0 })

    if ($scorableMetrics.Count -gt 0) {
        $volumes = @($scorableMetrics | ForEach-Object IncidentsTotal)
        $autoRatios = @($scorableMetrics | ForEach-Object AutoCloseRatio)
        $falseRatios = @($scorableMetrics | ForEach-Object FalsePositiveRatio)

        foreach ($metric in $scorableMetrics) {
            $volumePct = Get-PercentileRank -Value $metric.IncidentsTotal -Population $volumes
            $autoPct = Get-PercentileRank -Value $metric.AutoCloseRatio -Population $autoRatios
            $falsePct = Get-PercentileRank -Value $metric.FalsePositiveRatio -Population $falseRatios

            $score = [math]::Round(($volumePct * 0.35) + ($autoPct * 0.40) + ($falsePct * 0.25), 2)
            Add-Member -InputObject $metric -NotePropertyName NoisinessScore -NotePropertyValue $score
            Add-Member -InputObject $metric -NotePropertyName PercentileVolume -NotePropertyValue $volumePct
            Add-Member -InputObject $metric -NotePropertyName PercentileAutoClose -NotePropertyValue $autoPct
            Add-Member -InputObject $metric -NotePropertyName PercentileFalsePositive -NotePropertyValue $falsePct
        }
    }

    # Rules with no incidents get null score (listing-only in UI)
    foreach ($metric in $ruleMetrics) {
        if (-not ($metric.PSObject.Properties.Name -contains 'NoisinessScore')) {
            Add-Member -InputObject $metric -NotePropertyName NoisinessScore -NotePropertyValue $null
            Add-Member -InputObject $metric -NotePropertyName PercentileVolume -NotePropertyValue $null
            Add-Member -InputObject $metric -NotePropertyName PercentileAutoClose -NotePropertyValue $null
            Add-Member -InputObject $metric -NotePropertyName PercentileFalsePositive -NotePropertyValue $null
        }
    }

    $noisyRules = @($ruleMetrics | Where-Object {
        $_.Enabled -and $_.IncidentsTotal -ge 5 -and $null -ne $_.NoisinessScore -and $_.NoisinessScore -ge 70
    })

    $recList = [System.Collections.Generic.List[object]]::new()
    foreach ($rule in $noisyRules) {
        $recList.Add([PSCustomObject]@{
            Priority     = 'High'
            Type         = 'DetectionAnalyzer'
            TableName    = '(rule-level)'
            Title        = "Review noisy rule: $($rule.RuleName)"
            Detail       = "Rule appears noisy (score $($rule.NoisinessScore)). Auto-close ratio: $($rule.AutoCloseRatio), false positive ratio: $($rule.FalsePositiveRatio), incidents: $($rule.IncidentsTotal)."
            EstSavingsUSD = 0
            CurrentCost   = 0
        })
    }

    $cdrMetrics = @($ruleMetrics | Where-Object { $_.RuleKind -eq 'CustomDetection' })
    $cdrCorrelated = @($cdrMetrics | Where-Object { $_.IncidentsTotal -gt 0 }).Count

    [PSCustomObject]@{
        RuleMetrics = @($ruleMetrics)
        Recommendations = @($recList)
        Summary = [PSCustomObject]@{
            RulesAnalyzed = $ruleMetrics.Count
            NoisyRules = $noisyRules.Count
            IncidentsAnalyzed = $Incidents.Count
            AutoClosedIncidents = @($ruleMetrics | Measure-Object IncidentsAutoClosed -Sum).Sum
            CustomDetectionRules = $cdrMetrics.Count
            CDRCorrelatedIncidents = $cdrCorrelated
        }
    }
}

function Get-XdrCheckerData {
    [CmdletBinding()]
    param(
        [array]$TableAnalysis,
        [array]$KnownXDRTables = @(),
        [hashtable]$RetentionMap = @{},
        [int]$AdvisoryRetentionDays = 365
    )

    $findings = [System.Collections.Generic.List[object]]::new()
    $recommendations = [System.Collections.Generic.List[object]]::new()

    $xdrStreamedTables = @($TableAnalysis | Where-Object IsXDRStreaming)

    # Identify known XDR tables not streamed to Sentinel at all.
    # A known XDR table may exist in the workspace (RetentionMap) with zero ingestion during the
    # lookback window, meaning it won't appear in TableAnalysis. Use the union of usage-based
    # and retention-based sources so workspace-present tables are never falsely flagged as NotStreaming.
    # The retention fallback requires ArchiveRetentionInDays > 0 because the Tables API creates
    # schema entries for all known XDR tables when streaming is configured, even if no data has
    # been received. Only tables with explicit archive retention are treated as actively managed.
    $streamedFromUsage     = @($xdrStreamedTables | ForEach-Object { $_.TableName })
    $streamedFromRetention = @($KnownXDRTables | Where-Object {
        $_ -and $RetentionMap.ContainsKey($_) -and
        $RetentionMap[$_].ArchiveRetentionInDays -gt 0
    })
    $streamedNames         = @($streamedFromUsage + $streamedFromRetention | Select-Object -Unique)
    $notStreamedNames = @($KnownXDRTables | Where-Object { $_ -and ($_ -notin $streamedNames) })

    foreach ($tableName in $notStreamedNames) {
        $findings.Add([PSCustomObject]@{
            Type      = 'NotStreaming'
            TableName = $tableName
            Severity  = 'Information'
            Detail    = 'Known Defender XDR table is not streamed to Sentinel. Data is only available via XDR Advanced Hunting with 30-day retention.'
        })

        $recommendations.Add([PSCustomObject]@{
            Priority      = 'Low'
            Type          = 'XDRChecker'
            TableName     = $tableName
            Title         = "Consider streaming $tableName to Sentinel"
            Detail        = 'This Defender XDR table is not ingested into the workspace. Consider streaming to Analytics or Data Lake tier for long-term retention and cross-workspace correlation.'
            EstSavingsUSD = 0
            CurrentCost   = 0
        })
    }

    foreach ($table in $xdrStreamedTables) {
        if ($table.AnalyticsRules -eq 0 -and $table.XDRRules -eq 0) {
            $findings.Add([PSCustomObject]@{
                Type = 'StreamingNoCoverage'
                TableName = $table.TableName
                Severity = 'Medium'
                Detail = 'Table is streamed from Defender XDR but has no Sentinel analytics or Defender custom rule coverage.'
            })

            $recommendations.Add([PSCustomObject]@{
                Priority      = 'Medium'
                Type          = 'XDRChecker'
                TableName     = $table.TableName
                Title         = "Validate necessity of streaming $($table.TableName)"
                Detail        = 'No Sentinel or Defender custom detection coverage found for this streamed table. Consider reducing ingestion if not needed.'
                EstSavingsUSD = $table.EstMonthlyCostUSD
                CurrentCost   = $table.EstMonthlyCostUSD
            })
        }

        if ($table.XDRState -ne 'Auxiliary' -and $null -ne $table.ArchiveRetentionInDays -and $table.ArchiveRetentionInDays -eq 0) {
            $findings.Add([PSCustomObject]@{
                Type = 'NotForwardedToDataLake'
                TableName = $table.TableName
                Severity = 'Low'
                Detail = "XDR streaming table has no archive/data lake retention configured. Consider forwarding to Data Lake tier for long-term investigations."
            })

            $recommendations.Add([PSCustomObject]@{
                Priority      = 'Low'
                Type          = 'XDRChecker'
                TableName     = $table.TableName
                Title         = "Forward $($table.TableName) to Data Lake tier"
                Detail        = "XDR streaming table is only in Analytics tier with no archive retention. Configure Data Lake forwarding for at least $AdvisoryRetentionDays days."
                EstSavingsUSD = 0
                CurrentCost   = $table.EstMonthlyCostUSD
            })
        }
        elseif ($null -ne $table.ActualRetentionDays -and $table.ActualRetentionDays -lt $AdvisoryRetentionDays) {
            $findings.Add([PSCustomObject]@{
                Type = 'AdvisoryRetentionGap'
                TableName = $table.TableName
                Severity = 'Low'
                Detail = "Retention is $($table.ActualRetentionDays)d. Advisory target for XDR-related logs is at least $AdvisoryRetentionDays days in Data Lake."
            })

            $recommendations.Add([PSCustomObject]@{
                Priority      = 'Low'
                Type          = 'XDRChecker'
                TableName     = $table.TableName
                Title         = "Consider one-year retention path for $($table.TableName)"
                Detail        = "Advisory guidance: keep XDR-related telemetry available in Data Lake for at least $AdvisoryRetentionDays days for long-term investigations."
                EstSavingsUSD = 0
                CurrentCost   = $table.EstMonthlyCostUSD
            })
        }
    }

    [PSCustomObject]@{
        Findings = @($findings)
        Recommendations = @($recommendations)
        Summary = [PSCustomObject]@{
            IssueCount = $findings.Count
            AdvisoryRetentionDays = $AdvisoryRetentionDays
            StreamedTableCount = $xdrStreamedTables.Count
            NotStreamedCount = $notStreamedNames.Count
        }
    }
}

function Get-PercentileRank {
    [CmdletBinding()]
    param(
        [double]$Value,
        [array]$Population
    )

    $clean = @($Population | Where-Object { $null -ne $_ } | ForEach-Object { [double]$_ })
    if ($clean.Count -eq 0) { return 0 }
    if ($clean.Count -eq 1) { return 100 }

    # If all values are identical, percentile carries no relative signal.
    # Return 0 so rules are not falsely classified as noisy when everything is flat (for example all zeros).
    $min = ($clean | Measure-Object -Minimum).Minimum
    $max = ($clean | Measure-Object -Maximum).Maximum
    if ($min -eq $max) { return 0 }

    $lessOrEqual = @($clean | Where-Object { $_ -le $Value }).Count
    return [math]::Round((100 * $lessOrEqual / $clean.Count), 2)
}

function Test-AutomationRuleIncidentMatch {
    [CmdletBinding()]
    param(
        [PSCustomObject]$AutomationRule,
        [string]$IncidentTitle,
        [string[]]$IncidentRuleIds
    )

    # Blanket close rule: no conditions at all means it matches everything
    if (-not $AutomationRule.HasConditions) { return $true }

    # Check analytic rule ID conditions
    $ruleIdFilters = @($AutomationRule.RuleIdFilters)
    if ($ruleIdFilters.Count -gt 0 -and $IncidentRuleIds.Count -gt 0) {
        foreach ($filter in $ruleIdFilters) {
            foreach ($incidentRuleId in $IncidentRuleIds) {
                # Exact match or GUID-tail match for ARM resource IDs
                if ($incidentRuleId -eq $filter) { return $true }
                $filterGuid = ($filter -split '/')[-1]
                $incidentGuid = ($incidentRuleId -split '/')[-1]
                if ($filterGuid -and $incidentGuid -and $filterGuid -eq $incidentGuid) { return $true }
            }
        }
    }

    # Check title conditions with operator awareness
    if ([string]::IsNullOrWhiteSpace($IncidentTitle)) { return $false }

    $titleFilters = @($AutomationRule.TitleFilters)
    $titleOperators = @($AutomationRule.TitleOperators)
    for ($i = 0; $i -lt $titleFilters.Count; $i++) {
        $filter = $titleFilters[$i]
        if ([string]::IsNullOrWhiteSpace($filter)) { continue }
        $op = if ($i -lt $titleOperators.Count) { $titleOperators[$i] } else { 'Contains' }

        switch ($op) {
            'Equals'     { if ($IncidentTitle -eq $filter) { return $true } }
            'StartsWith' { if ($IncidentTitle.StartsWith($filter, [System.StringComparison]::OrdinalIgnoreCase)) { return $true } }
            'EndsWith'   { if ($IncidentTitle.EndsWith($filter, [System.StringComparison]::OrdinalIgnoreCase)) { return $true } }
            default {
                # Contains or unknown operator: substring match; also support wildcard patterns
                $pattern = [regex]::Escape($filter).Replace('\*', '.*')
                if ($IncidentTitle -match $pattern) { return $true }
            }
        }
    }

    return $false
}