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,
        [decimal]$BasicPricePerGB = 1.15,
        [decimal]$LakePricePerGB = 0.20,
        [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
    $implicitCoverage = if ($RulesData.PSObject.Properties.Name -contains 'ImplicitCoverage' -and $RulesData.ImplicitCoverage) { $RulesData.ImplicitCoverage } else { @{} }
    $platformTables = if ($RulesData.PSObject.Properties.Name -contains 'PlatformTables' -and $RulesData.PlatformTables) { @($RulesData.PlatformTables) } else { @() }
    $xdrCoverage    = if ($DefenderXDR -and $DefenderXDR.XDRTableCoverage) { $DefenderXDR.XDRTableCoverage } else { @{} }
    $knownXDRTables = if ($DefenderXDR -and $DefenderXDR.KnownXDRTables) { @($DefenderXDR.KnownXDRTables) } else { @() }
    # The classification DB is the source of truth for which XDR tables the connector can stream
    $knownXDRTables = @($knownXDRTables | Where-Object {
        $c = $classMap[$_]
        -not ($c -and $c.PSObject.Properties.Name -contains 'XdrStreamable' -and $c.XdrStreamable -eq $false)
    })

    # 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)."

    # Group enabled rules by table once so Get-SplitKql does not rescan every rule per table
    $rulesByTable = @{}
    foreach ($rule in @($RulesData.Rules)) {
        if (-not $rule.Enabled) { continue }
        foreach ($t in @($rule.Tables)) {
            if ([string]::IsNullOrWhiteSpace($t)) { continue }
            if (-not $rulesByTable.ContainsKey($t)) { $rulesByTable[$t] = [System.Collections.Generic.List[object]]::new() }
            $rulesByTable[$t].Add($rule)
        }
    }

    # 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 }
        $implicitCount = if ($implicitCoverage.ContainsKey($name)) { [int]$implicitCoverage[$name] } else { 0 }
        $isPlatform   = ($name -in $platformTables) -or ($cls -and $cls.PSObject.Properties.Name -contains 'IsPlatform' -and $cls.IsPlatform -eq $true)
        $lifecycleStatus = if ($cls -and $cls.PSObject.Properties.Name -contains 'Status') { $cls.Status } else { $null }
        $replacedBy = if ($cls -and $cls.PSObject.Properties.Name -contains 'ReplacedBy') { @($cls.ReplacedBy) } else { @() }
        $totalCoverage = $ruleCount + $huntCount
        $effectiveCoverage = $ruleCount + $huntCount + $xdrRuleCount + $implicitCount

        # Where the coverage signal comes from (first match wins for display)
        $coverageSource = if ($ruleCount -gt 0 -or $huntCount -gt 0) { 'kql' }
                          elseif ($xdrRuleCount -gt 0) { 'xdr' }
                          elseif ($implicitCount -gt 0) { 'implicit' }
                          elseif ($isPlatform) { 'platform' }
                          else { 'none' }

        # Usage.IsBillable wins; the classification DB only decides when Usage had no flag
        $isFree = [bool]$table.IsFree
        $monthlyCost = $table.EstMonthlyCostUSD
        if ($table.IsFreeSource -eq 'database' -and $cls -and $null -ne $cls.IsFree) {
            $isFree = [bool]$cls.IsFree
            if ($isFree) { $monthlyCost = 0 }
        }

        # Cost tier
        $costTier = switch ($true) {
            ($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 (platform tables are never "missing" coverage)
        $assessment = if ($isPlatform -and $detectionTier -eq 'None' -and -not $isFree) { 'Platform' }
                      else {
                          Get-Assessment -Classification $classification `
                                         -CostTier $costTier `
                                         -DetectionTier $detectionTier `
                                         -IsFree $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 -and $null -ne $ret.TotalRetentionInDays) { [int]$ret.TotalRetentionInDays } else { $null }
        $actualInteractive = if ($ret -and $null -ne $ret.RetentionInDays) { [int]$ret.RetentionInDays } else { $null }
        $tablePlan = if ($ret) { $ret.Plan } else { $null }
        $tableSubType = if ($ret) { $ret.TableSubType } else { $null }
        $observedPlanBreakdown = if ($table.ObservedPlanBreakdown) { @($table.ObservedPlanBreakdown) } else { @() }
        $observedPlans = if ($table.ObservedPlans) { @($table.ObservedPlans) } else { @() }
        $observedKnownPlans = @($observedPlans | Where-Object { -not [string]::IsNullOrWhiteSpace($_) -and $_ -ne 'Unknown' } | Sort-Object -Unique)
        $observedPlanCount = $observedKnownPlans.Count
        $observedPlanSummary = if ($observedPlanBreakdown.Count -gt 0) {
            (($observedPlanBreakdown | ForEach-Object { "$($_.Plan) $($_.MonthlyGB) GB/mo" }) -join '; ')
        } else {
            $null
        }
        $hasMultipleObservedPlans = $observedPlanCount -gt 1
        $observedPlanMismatch = if ($tablePlan -and $observedPlanCount -gt 0) {
            $tablePlan -notin $observedKnownPlans
        } else {
            $false
        }
        # 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; free and platform tables are excluded
        $retentionCanImprove = if ($retentionCompliant -and $recommendedRetention -gt 90 -and -not $isFree -and -not $isPlatform) { $actualTotal -lt $recommendedRetention } else { $false }
        # Interactive (hot) retention below the 90-day Sentinel baseline on an Analytics table
        $interactiveBelowBaseline = ($tablePlan -eq 'Analytics' -and $null -ne $actualInteractive -and $actualInteractive -lt 90)

        # 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 { @() }

        $supportsAuxiliary = Test-TableSupportsAuxiliaryPlan -Table ([PSCustomObject]@{ TableName = $name; Plan = $tablePlan; TableSubType = $tableSubType })

        $splitSuggestion = Get-SplitKql -TableName $name `
                                        -Rules $(if ($rulesByTable.ContainsKey($name)) { @($rulesByTable[$name]) } else { @() }) `
                                        -HighValueFieldsDB $HighValueFields `
                                        -FieldFrequencyStats $FieldFrequencyStats `
                                        -TableCategory $(if ($cls) { $cls.Category } else { $null }) `
                                        -SchemaColumns $schemaColumns

        [PSCustomObject]@{
            TableName                    = $name
            Classification               = $classification
            Category                     = if ($cls) { $cls.Category } else { 'Unknown' }
            MonthlyGB                    = $table.MonthlyGB
            EstMonthlyCostUSD            = $monthlyCost
            IsFree                       = $isFree
            IsFreeSource                 = $(if ($table.IsFreeSource) { $table.IsFreeSource } else { 'database' })
            AnalyticsRules               = $ruleCount
            HuntingQueries               = $huntCount
            XDRRules                     = $xdrRuleCount
            ImplicitRules                = $implicitCount
            IsPlatform                   = $isPlatform
            Status                       = $lifecycleStatus
            ReplacedBy                   = $replacedBy
            SupportsAuxiliaryPlan        = $supportsAuxiliary
            CoverageSource               = $coverageSource
            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 -and $null -ne $ret.ArchiveRetentionInDays) { [int]$ret.ArchiveRetentionInDays } else { $null }
            RecommendedRetentionDays     = $recommendedRetention
            TablePlan                    = $tablePlan
            TableSubType                 = $tableSubType
            TableType                    = if ($ret -and $ret.PSObject.Properties.Name -contains 'TableType') { $ret.TableType } else { $null }
            RetentionInDaysAsDefault      = if ($ret -and $ret.PSObject.Properties.Name -contains 'RetentionInDaysAsDefault') { [bool]$ret.RetentionInDaysAsDefault } else { $false }
            TotalRetentionInDaysAsDefault = if ($ret -and $ret.PSObject.Properties.Name -contains 'TotalRetentionInDaysAsDefault') { [bool]$ret.TotalRetentionInDaysAsDefault } else { $false }
            LastPlanModifiedDate         = if ($ret -and $ret.PSObject.Properties.Name -contains 'LastPlanModifiedDate') { $ret.LastPlanModifiedDate } else { $null }
            ObservedPlans                = $observedPlans
            ObservedKnownPlans           = $observedKnownPlans
            ObservedPlanCount            = $observedPlanCount
            ObservedPlanBreakdown        = $observedPlanBreakdown
            ObservedPlanSummary          = $observedPlanSummary
            HasMultipleObservedPlans     = $hasMultipleObservedPlans
            ObservedPlanMismatch         = $observedPlanMismatch
            RetentionCompliant           = $retentionCompliant
            RetentionCanImprove          = $retentionCanImprove
            InteractiveBelowBaseline     = $interactiveBelowBaseline
            HasTransform                 = $hasTransform
            TransformTypes               = $transformTypes
            TransformKql                 = $transformKql
            IsSplitTable                 = $isSplitTable
            ParentTable                  = $parentTable
            SchemaColumns                = $schemaColumns
            SplitSuggestion              = $splitSuggestion
        }
    }
    $tableAnalysis = @($tableAnalysis)

    $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 (split copies are already lake data).
        # Only when the table can actually move: Auxiliary where the feature matrix allows it, else Basic.
        if ($t.Classification -eq 'secondary' -and
            -not $t.IsSplitTable -and
            $t.TablePlan -ne 'Auxiliary' -and
            $t.CostTier -in @('High', 'Very High') -and
            $t.DetectionTier -in @('None', 'Low')) {

            $planProbe = [PSCustomObject]@{ TableName = $t.TableName; Plan = $t.TablePlan; TableSubType = $t.TableSubType }
            if ($t.SupportsAuxiliaryPlan) {
                $savings = [math]::Max(0, [math]::Round($t.EstMonthlyCostUSD - ($t.MonthlyGB * $LakePricePerGB), 2))
                $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
                })
            }
            elseif ($t.TablePlan -ne 'Basic' -and (Test-TableSupportsBasicPlan -Table $planProbe)) {
                $savings = [math]::Max(0, [math]::Round($t.EstMonthlyCostUSD - ($t.MonthlyGB * $BasicPricePerGB), 2))
                $recommendations.Add([PSCustomObject]@{
                    Priority     = 'High'
                    Type         = 'DataLake'
                    TableName    = $t.TableName
                    Title        = "Move $($t.TableName) to Basic plan"
                    Detail       = "Secondary source ingesting $($t.MonthlyGB) GB/mo with $($t.EffectiveCoverage) detection(s). " +
                                   "This table does not support the Auxiliary (Data Lake) plan; Basic is the lowest supported tier."
                    EstSavingsUSD = $savings
                    CurrentCost   = $t.EstMonthlyCostUSD
                })
            }
            else {
                Write-Verbose "$($t.TableName) is a data lake candidate but supports neither the Auxiliary nor the Basic plan; no tier recommendation."
            }
        }

        # 12. Deprecated or legacy source still ingesting. Informational: migrating moves the
        # ingestion to the replacement table rather than removing it, so no savings are claimed.
        if ($t.Status -in @('deprecated', 'legacy') -and $t.MonthlyGB -gt 0) {
            $replacement = if (@($t.ReplacedBy).Count -gt 0) { "Replacement table(s): $(@($t.ReplacedBy) -join ', '). " } else { 'No direct replacement table is documented. ' }
            $verb = if ($t.Status -eq 'deprecated') { 'is deprecated' } else { 'uses a legacy collection path' }
            $recommendations.Add([PSCustomObject]@{
                Priority      = 'Medium'
                Type          = 'DeprecatedSource'
                TableName     = $t.TableName
                Title         = "$($t.TableName) $verb"
                Detail        = "Ingesting $($t.MonthlyGB) GB/mo (~`$$($t.EstMonthlyCostUSD)/mo) into a table whose connector $verb. " + $replacement +
                                'Migrate detections to the replacement, then retire the old connector to avoid paying for both.'
                EstSavingsUSD = 0
                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') {

            $lowValueNextStep = if ($t.TablePlan -eq 'Auxiliary') {
                'Consider: add analytics rules, apply ingest-time filtering, or review whether the current Data Lake placement is still appropriate.'
            }
            else {
                'Consider: add analytics rules, apply ingest-time filtering, or move to data lake.'
            }

            $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. " +
                               $lowValueNextStep
                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 (not platform tables, which are consumed by Sentinel itself)
        if ($t.Classification -eq 'primary' -and
            -not $t.IsFree -and
            -not $t.IsPlatform -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
            })
        }

        # 10. Tables observed under multiple plans during the selected period
        if ($t.HasMultipleObservedPlans) {
            $configuredPlan = if ($t.TablePlan) { $t.TablePlan } else { 'Unknown' }
            $recommendations.Add([PSCustomObject]@{
                Priority      = 'Low'
                Type          = 'PlanUsage'
                TableName     = $t.TableName
                Title         = "$($t.TableName) was observed under multiple plans"
                Detail        = "Usage records show $($t.ObservedKnownPlans -join ', ') during the selected period. " +
                                "Configured plan is $configuredPlan. Review whether the transition was expected."
                EstSavingsUSD = 0
                CurrentCost   = $t.EstMonthlyCostUSD
            })
        }

        # 11. Tables whose configured plan does not appear in observed Usage data
        if (-not $t.HasMultipleObservedPlans -and $t.ObservedPlanMismatch) {
            $observedPlan = if ($t.ObservedKnownPlans.Count -gt 0) { $t.ObservedKnownPlans -join ', ' } else { 'Unknown' }
            $recommendations.Add([PSCustomObject]@{
                Priority      = 'Low'
                Type          = 'PlanUsage'
                TableName     = $t.TableName
                Title         = "$($t.TableName) configured plan differs from observed usage"
                Detail        = "Configured plan is $($t.TablePlan), but Usage records show $observedPlan during the selected period. " +
                                "Review recent table plan changes and confirm expected billing behavior."
                EstSavingsUSD = 0
                CurrentCost   = $t.EstMonthlyCostUSD
            })
        }
    }

    # 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
        })
    }

    # 12. Interactive retention below the 90-day Sentinel baseline (total may still be compliant via archive)
    foreach ($t in $tableAnalysis) {
        if (-not $t.InteractiveBelowBaseline) { continue }

        $recommendations.Add([PSCustomObject]@{
            Priority      = 'Medium'
            Type          = 'RetentionInteractiveBelowBaseline'
            TableName     = $t.TableName
            Title         = "$($t.TableName) interactive retention is $($t.ActualInteractiveRetentionDays)d"
            Detail        = "Analytics-tier interactive retention is $($t.ActualInteractiveRetentionDays) days; Sentinel includes 90 days at no extra charge. " +
                            "Total retention is $($t.ActualRetentionDays) days. Raise interactive retention to 90 days unless the shorter hot window is deliberate."
            EstSavingsUSD = 0
            CurrentCost   = $t.EstMonthlyCostUSD
        })
    }

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

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

    # Single canonical order: High > Medium > Low, then savings descending
    $sortedRecs = @(Get-SortedRecommendation -Recommendations $recommendations)

    # 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
    $multiPlanUsageCount      = @($tableAnalysis | Where-Object { $_.HasMultipleObservedPlans }).Count
    $observedPlanMismatchCount = @($tableAnalysis | Where-Object { $_.ObservedPlanMismatch }).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
    $totalAllGB         = ($allTables | Measure-Object MonthlyGB -Sum).Sum
    if ($totalAllGB -le 0) { $totalAllGB = 0 }

    $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 }

    $detectionCoverageGBPct = if ($totalAllGB -gt 0) { [math]::Round(($detectionCoveredGB / $totalAllGB) * 100, 1) } else { 0 }
    $huntingCoverageGBPct   = if ($totalAllGB -gt 0) { [math]::Round(($huntingCoveredGB / $totalAllGB) * 100, 1) } else { 0 }
    $combinedCoverageGBPct  = if ($totalAllGB -gt 0) { [math]::Round(($combinedCoveredGB / $totalAllGB) * 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 TotalAllGB -NotePropertyValue ([math]::Round($totalAllGB, 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 DetectionCoverageGBPct -NotePropertyValue $detectionCoverageGBPct
        Add-Member -InputObject $detectionAnalyzer.Summary -NotePropertyName HuntingCoverageGBPct -NotePropertyValue $huntingCoverageGBPct
        Add-Member -InputObject $detectionAnalyzer.Summary -NotePropertyName CombinedCoverageGBPct -NotePropertyValue $combinedCoverageGBPct
        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
            BasicPricePerGB        = $BasicPricePerGB
            LakePricePerGB         = $LakePricePerGB
            UsageObservedDays      = $(if ($TableUsage.Count -gt 0 -and $null -ne $TableUsage[0].ObservedDays) { [int]$TableUsage[0].ObservedDays } else { $null })
            WorkspaceRetentionDays = $WorkspaceRetentionDays
            RetentionCompliant     = $retentionCompliantCount
            RetentionNonCompliant  = $retentionNonCompliant
            RetentionChecked       = $retentionChecked
            RetentionImprovable    = $retentionImprovableCount
            MultiPlanUsageTables   = $multiPlanUsageCount
            ObservedPlanMismatches = $observedPlanMismatchCount
            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-SortedRecommendation {
    <#
    .SYNOPSIS
        Orders recommendations High > Medium > Low, then by estimated savings descending.
        This is the only place recommendations are sorted; exports and the TUI keep this order.
    #>

    [CmdletBinding()]
    param([array]$Recommendations)

    $prioOrder = @{ 'High' = 0; 'Medium' = 1; 'Low' = 2 }
    @($Recommendations | Sort-Object `
        @{ Expression = { if ($prioOrder.ContainsKey("$($_.Priority)")) { $prioOrder["$($_.Priority)"] } else { 3 } } }, `
        @{ Expression = { [double]($_.EstSavingsUSD ?? 0) }; Descending = $true })
}

function Get-TableStatusLabel {
    <#
    .SYNOPSIS
        Short lifecycle label for a table analysis row ("deprecated, use X, Y"
        or "legacy"), or $null when the source has no status.
    #>

    [CmdletBinding()]
    param([object]$Table)

    if ($null -eq $Table -or $Table.PSObject.Properties.Name -notcontains 'Status') { return $null }
    $status = "$($Table.Status)".Trim().ToLowerInvariant()
    if ($status -notin 'deprecated', 'legacy') { return $null }
    $replacement = @()
    if ($Table.PSObject.Properties.Name -contains 'ReplacedBy') { $replacement = @($Table.ReplacedBy | Where-Object { -not [string]::IsNullOrWhiteSpace("$_") }) }
    if ($replacement.Count -gt 0) { "$status, use $($replacement -join ', ')" } else { $status }
}

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.
    # RuleKey is the stable bucket key: rule id when known, display name otherwise.
    $unifiedRules = [System.Collections.Generic.List[object]]::new()

    if ($Rules) {
        foreach ($rule in $Rules) {
            $ruleId = if ($rule.PSObject.Properties.Name -contains 'RuleId' -and $rule.RuleId) { "$($rule.RuleId)" } else { $null }
            $unifiedRules.Add([PSCustomObject]@{
                RuleKey  = if ($ruleId) { $ruleId } else { "name:$($rule.RuleName)" }
                RuleId   = $ruleId
                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
                }
            }

            $cdrId = if ($cdr.PSObject.Properties.Name -contains 'id' -and $cdr.id) { "cdr:$($cdr.id)" } else { $null }
            $unifiedRules.Add([PSCustomObject]@{
                RuleKey   = if ($cdrId) { $cdrId } else { "name:$displayName" }
                RuleId    = $cdrId
                RuleName  = $displayName
                Kind      = 'CustomDetection'
                Enabled   = $isEnabled
                Source    = 'DefenderXDR'
                Tables    = $tables
                Frequency = $frequency
            })
            $cdrCount++
        }
    }

    # Lookups: GUID tail of the rule id -> keys, display name -> keys (duplicate names map to several keys)
    $incidentBuckets = @{}
    $keysByGuid = @{}
    $keysByName = [System.Collections.Generic.Dictionary[string, System.Collections.Generic.List[string]]]::new([StringComparer]::OrdinalIgnoreCase)
    foreach ($rule in $unifiedRules) {
        $incidentBuckets[$rule.RuleKey] = [System.Collections.Generic.List[object]]::new()
        if ($rule.RuleId) {
            $guid = ("$($rule.RuleId)" -split '/')[-1]
            if (-not $keysByGuid.ContainsKey($guid)) { $keysByGuid[$guid] = [System.Collections.Generic.List[string]]::new() }
            $keysByGuid[$guid].Add($rule.RuleKey)
        }
        if (-not [string]::IsNullOrWhiteSpace($rule.RuleName)) {
            if (-not $keysByName.ContainsKey($rule.RuleName)) { $keysByName[$rule.RuleName] = [System.Collections.Generic.List[string]]::new() }
            $keysByName[$rule.RuleName].Add($rule.RuleKey)
        }
    }

    foreach ($incident in $Incidents) {
        $candidateKeys = [System.Collections.Generic.HashSet[string]]::new([StringComparer]::OrdinalIgnoreCase)

        # 1. Rule ids (most precise)
        foreach ($id in @($incident.RelatedAnalyticRuleIds)) {
            if ([string]::IsNullOrWhiteSpace($id)) { continue }
            $guid = ("$id" -split '/')[-1]
            if ($keysByGuid.ContainsKey($guid)) { foreach ($k in $keysByGuid[$guid]) { [void]$candidateKeys.Add($k) } }
        }

        # 2. Rule names
        if ($candidateKeys.Count -eq 0) {
            foreach ($n in @($incident.RelatedAnalyticRuleNames)) {
                if ([string]::IsNullOrWhiteSpace($n)) { continue }
                if ($keysByName.ContainsKey($n)) { foreach ($k in $keysByName[$n]) { [void]$candidateKeys.Add($k) } }
            }
        }

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

        foreach ($key in $candidateKeys) {
            if ($incidentBuckets.ContainsKey($key)) {
                $incidentBuckets[$key].Add($incident)
            }
        }
    }

    # Automation rules that can close incidents, computed once
    $enabledAutoCloseRules = @($AutomationRules | Where-Object {
        ($_.IsCloseIncidentRule -or $_.HasPlaybookAction) -and $_.Enabled
    })
    $distinctAutoClosed = [System.Collections.Generic.HashSet[string]]::new([StringComparer]::OrdinalIgnoreCase)

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

        $autoClosed = [System.Collections.Generic.List[object]]::new()
        $autoClosedIds = [System.Collections.Generic.HashSet[string]]::new([StringComparer]::OrdinalIgnoreCase)
        $linkedAutomation = [System.Collections.Generic.HashSet[string]]::new([StringComparer]::OrdinalIgnoreCase)

        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
            }

            # Automation rule condition matching: fallback attribution, and the source of linked rule names
            if ($enabledAutoCloseRules.Count -gt 0) {
                $matched = @($enabledAutoCloseRules | Where-Object {
                    Test-AutomationRuleIncidentMatch -AutomationRule $_ -IncidentTitle $inc.Title -IncidentRuleIds $inc.RelatedAnalyticRuleIds -IncidentSeverity "$($inc.Severity)"
                })
                if ($matched.Count -gt 0) {
                    $isAutoClose = $true
                    foreach ($m in $matched) { if ($m.DisplayName) { [void]$linkedAutomation.Add($m.DisplayName) } }
                }
            }

            # Timing heuristic: only use this when no enabled close/playbook rule exists
            # to avoid overriding an explicit non-match on automation conditions.
            if (-not $isAutoClose -and $enabledAutoCloseRules.Count -eq 0 -and $inc.CreatedTimeUtc -and $inc.ClosedTimeUtc) {
                $closeDelta = ($inc.ClosedTimeUtc - $inc.CreatedTimeUtc).TotalMinutes
                if ($closeDelta -ge 0 -and $closeDelta -le 5) {
                    $isAutoClose = $true
                }
            }

            if ($isAutoClose) {
                $autoClosed.Add($inc)
                $incKey = if ($inc.IncidentId) { "$($inc.IncidentId)" } elseif ($inc.IncidentNumber) { "num:$($inc.IncidentNumber)" } else { $null }
                if ($incKey) { [void]$autoClosedIds.Add($incKey); [void]$distinctAutoClosed.Add($incKey) }
            }
        }

        $manualClosed = @($closed | Where-Object {
            $k = if ($_.IncidentId) { "$($_.IncidentId)" } elseif ($_.IncidentNumber) { "num:$($_.IncidentNumber)" } else { $null }
            -not ($k -and $autoClosedIds.Contains($k))
        })
        $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]@{
            RuleKey                 = $rule.RuleKey
            RuleId                  = $rule.RuleId
            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   = @($linkedAutomation | Sort-Object)
        })
    }

    # Noisiness scoring - only score rules that have incident data, and only when
    # the population is large enough for a percentile to carry meaning.
    $scorableMetrics = @($ruleMetrics | Where-Object { $_.IncidentsTotal -gt 0 })
    $minScorablePopulation = 3

    if ($scorableMetrics.Count -ge $minScorablePopulation) {
        $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 ScoreStatus -NotePropertyValue 'Scored'
            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 without a score: no incidents, or too few scorable rules for a percentile
    foreach ($metric in $ruleMetrics) {
        if (-not ($metric.PSObject.Properties.Name -contains 'NoisinessScore')) {
            $status = if ($metric.IncidentsTotal -gt 0) { 'InsufficientSample' } else { 'NoIncidents' }
            Add-Member -InputObject $metric -NotePropertyName NoisinessScore -NotePropertyValue $null
            Add-Member -InputObject $metric -NotePropertyName ScoreStatus -NotePropertyValue $status
            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 = $distinctAutoClosed.Count
            ScorableRules = $scorableMetrics.Count
            MinScorablePopulation = $minScorablePopulation
            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 {
    <#
    .SYNOPSIS
        Percentage of the population less than or equal to Value. Returns 0 when
        the population is empty or flat (no relative signal), so a single rule or
        an all-equal set is never ranked as noisy.
    #>

    [CmdletBinding()]
    param(
        [double]$Value,
        [array]$Population
    )

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

    $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 {
    <#
    .SYNOPSIS
        Decides whether an automation rule's modelled conditions apply to an incident.
    .DESCRIPTION
        Sentinel ANDs all conditions on a rule, so analytic-rule-id, title and severity
        groups must all match when present. A rule whose conditions are not modelled
        here (status, tactics, entities) is treated as a match, the same as a rule with
        no conditions at all.
    #>

    [CmdletBinding()]
    param(
        [PSCustomObject]$AutomationRule,
        [string]$IncidentTitle,
        [string[]]$IncidentRuleIds,
        [string]$IncidentSeverity
    )

    if (-not $AutomationRule.HasConditions) { return $true }

    $ruleIdFilters = @($AutomationRule.RuleIdFilters | Where-Object { -not [string]::IsNullOrWhiteSpace($_) })
    $titleConditions = @(Get-AutomationTitleConditions -AutomationRule $AutomationRule)
    $severityConditions = @()
    if ($AutomationRule.PSObject.Properties.Name -contains 'SeverityConditions') {
        $severityConditions = @($AutomationRule.SeverityConditions | Where-Object { $_ -and @($_.Values).Count -gt 0 })
    }

    # Only unmodelled conditions (status, tactics, entities ...) - cannot exclude, so it applies
    if ($ruleIdFilters.Count -eq 0 -and $titleConditions.Count -eq 0 -and $severityConditions.Count -eq 0) { return $true }

    foreach ($sc in $severityConditions) {
        # Unknown incident severity cannot satisfy an Equals condition and cannot be excluded by NotEquals
        if ([string]::IsNullOrWhiteSpace($IncidentSeverity)) { if ("$($sc.Operator)" -ne 'NotEquals') { return $false } else { continue } }
        $inSet = $IncidentSeverity -in @($sc.Values)
        if ("$($sc.Operator)" -eq 'NotEquals') { if ($inSet) { return $false } }
        elseif (-not $inSet) { return $false }
    }

    if ($ruleIdFilters.Count -gt 0) {
        $idMatch = $false
        foreach ($filter in $ruleIdFilters) {
            $filterGuid = ($filter -split '/')[-1]
            foreach ($incidentRuleId in @($IncidentRuleIds)) {
                if ([string]::IsNullOrWhiteSpace($incidentRuleId)) { continue }
                if ($incidentRuleId -eq $filter) { $idMatch = $true; break }
                $incidentGuid = ($incidentRuleId -split '/')[-1]
                if ($filterGuid -and $incidentGuid -and $filterGuid -eq $incidentGuid) { $idMatch = $true; break }
            }
            if ($idMatch) { break }
        }
        if (-not $idMatch) { return $false }
    }

    if ($titleConditions.Count -gt 0) {
        if ([string]::IsNullOrWhiteSpace($IncidentTitle)) { return $false }
        $titleMatch = $false
        foreach ($cond in $titleConditions) {
            $filter = $cond.Value
            switch ($cond.Operator) {
                'Equals'     { if ($IncidentTitle -eq $filter) { $titleMatch = $true } }
                'StartsWith' { if ($IncidentTitle.StartsWith($filter, [System.StringComparison]::OrdinalIgnoreCase)) { $titleMatch = $true } }
                'EndsWith'   { if ($IncidentTitle.EndsWith($filter, [System.StringComparison]::OrdinalIgnoreCase)) { $titleMatch = $true } }
                default {
                    # Contains or unknown operator: substring match; also support wildcard patterns
                    $pattern = [regex]::Escape($filter).Replace('\*', '.*')
                    if ($IncidentTitle -match $pattern) { $titleMatch = $true }
                }
            }
            if ($titleMatch) { break }
        }
        if (-not $titleMatch) { return $false }
    }

    return $true
}

function Get-AutomationTitleConditions {
    <#
    .SYNOPSIS
        Returns the rule's title conditions as (Value, Operator) pairs. Prefers the
        TitleConditions property; falls back to zipping TitleFilters with TitleOperators
        (missing operators default to Contains).
    #>

    [CmdletBinding()]
    param([PSCustomObject]$AutomationRule)

    $result = [System.Collections.Generic.List[object]]::new()
    if ($AutomationRule.PSObject.Properties.Name -contains 'TitleConditions' -and $AutomationRule.TitleConditions) {
        foreach ($c in @($AutomationRule.TitleConditions)) {
            if ([string]::IsNullOrWhiteSpace("$($c.Value)")) { continue }
            $op = if ([string]::IsNullOrWhiteSpace("$($c.Operator)")) { 'Contains' } else { "$($c.Operator)" }
            $result.Add([PSCustomObject]@{ Value = "$($c.Value)"; Operator = $op })
        }
        return @($result)
    }

    $filters = @($AutomationRule.TitleFilters)
    $operators = @($AutomationRule.TitleOperators)
    for ($i = 0; $i -lt $filters.Count; $i++) {
        if ([string]::IsNullOrWhiteSpace("$($filters[$i])")) { continue }
        $op = if ($i -lt $operators.Count -and -not [string]::IsNullOrWhiteSpace("$($operators[$i])")) { "$($operators[$i])" } else { 'Contains' }
        $result.Add([PSCustomObject]@{ Value = "$($filters[$i])"; Operator = $op })
    }
    @($result)
}