modules/RiskEngine/RiskEngine.psm1

<#
    RiskEngine.psm1
    Ultimate Modular Windows Server Discovery Toolkit - Risk analysis engine.

    Collectors collect FACTS. The RiskEngine analyzes facts. It:
      - Evaluates data-driven rules (config\risk-rules.json) with a small set of
        explicit, SAFE condition types (no arbitrary expression evaluation).
      - Emits standard Finding objects.
      - Applies the compliance lens (interpretive relevance only).
      - Builds the MigrationComplexity, WBS inputs, and DependencyGraph datasets.

    Runs as a synthesis module AFTER all collectors, via Invoke-DiscoverySynthesis.
#>


function Get-DiscoveryModuleMetadata {
    [pscustomobject]@{
        ModuleName                = 'RiskEngine'
        DisplayName               = 'Risk Analysis Engine'
        Category                  = 'Synthesis'
        Version                   = '1.0.0'
        DefaultInFast             = $true
        DefaultInDeep             = $true
        RequiresAdmin             = $false
        RequiresDomainContext     = $false
        RequiresRole              = $null
        EstimatedImpact           = 'Minimal'
        CanRunAsSystem            = $true
        ProducesDatasets          = @('MigrationComplexity','DependencyGraph','WbsInputs','CriticalPaths','ReadinessScore','ReadinessScoreSubsections')
        ProducesRisks             = $true
        ProducesFollowUpQuestions = $true
        SupportsDeepMode          = $true
        SupportsComplianceLens    = $true
        IsSynthesis               = $true
    }
}

function Test-DiscoveryPrerequisites {
    param([object]$Context)
    [pscustomobject]@{ ModuleName='RiskEngine'; CanRun=$true; Status='Ready'; Reason=''; Limitations=@() }
}

#region Rule evaluation -------------------------------------------------------

function Test-FieldEquals {
    param($RowValue, $RuleValue)
    if ($null -eq $RowValue) { return $false }
    if ($RuleValue -is [bool]) {
        try { return ([bool]$RowValue -eq [bool]$RuleValue) } catch { return $false }
    }
    $rn = 0.0; $vn = 0.0
    if ([double]::TryParse([string]$RowValue, [ref]$rn) -and [double]::TryParse([string]$RuleValue, [ref]$vn)) {
        return ($rn -eq $vn)
    }
    return ([string]$RowValue -eq [string]$RuleValue)
}

function Test-RuleCondition {
    <# Returns the list of matching rows (or a single sentinel) for a rule condition. #>
    param([object]$Context, [object]$Condition)
    $result = [pscustomobject]@{ Matched = $false; Rows = @() }
    if (-not $Condition -or -not $Condition.type) { return $result }
    $dsName = $Condition.dataset
    $exists = ($dsName -and $Context.DataSets.Contains($dsName))
    $rows = @(if ($exists) { @($Context.DataSets[$dsName].Rows) } else { @() })

    switch ($Condition.type) {
        'datasetNotEmpty'      { if ($exists -and $rows.Count -gt 0) { $result.Matched = $true; $result.Rows = $rows } }
        'datasetMissingOrEmpty'{ if (-not $exists -or $rows.Count -eq 0) { $result.Matched = $true; $result.Rows = @() } }
        'datasetRowCountAtLeast' {
            # $exists is load-bearing: without it a rule omitting 'count' gets [int]$null = 0,
            # and "0 rows >= 0" matches an ABSENT dataset, emitting a finding with no evidence
            # rows behind it. Same silent-failure class as F29.
            #
            # The key must be probed via PSObject.Properties, NOT as $Condition.count. Every
            # PowerShell object carries an intrinsic ETS 'Count' property that returns 1 for a
            # scalar, so $Condition.count is 1 on a rule that never declared it - which is
            # exactly the malformed-rule case this is meant to reject.
            $need = 0
            $cp = $Condition.PSObject.Properties['count']
            if ($cp -and ($null -ne $cp.Value)) { try { $need = [int]$cp.Value } catch { $need = 0 } }
            if ($exists -and $need -ge 1 -and $rows.Count -ge $need) { $result.Matched = $true; $result.Rows = $rows }
        }
        'anyRowFieldEquals' {
            $m = @($rows | Where-Object { Test-FieldEquals -RowValue (Get-RowValue -Row $_ -Column $Condition.field) -RuleValue $Condition.value })
            if ($m.Count -gt 0) { $result.Matched = $true; $result.Rows = $m }
        }
        'anyRowFieldNotEquals' {
            $m = @($rows | Where-Object { $v = Get-RowValue -Row $_ -Column $Condition.field; ($null -ne $v) -and -not (Test-FieldEquals -RowValue $v -RuleValue $Condition.value) })
            if ($m.Count -gt 0) { $result.Matched = $true; $result.Rows = $m }
        }
        'anyRowFieldMatches' {
            $m = @($rows | Where-Object { $v = Get-RowValue -Row $_ -Column $Condition.field; ($null -ne $v) -and ([string]$v -match $Condition.pattern) })
            if ($m.Count -gt 0) { $result.Matched = $true; $result.Rows = $m }
        }
        'anyRowFieldGreaterThan' {
            $m = @($rows | Where-Object { $v=0.0; ([double]::TryParse([string](Get-RowValue -Row $_ -Column $Condition.field), [ref]$v)) -and ($v -gt [double]$Condition.value) })
            if ($m.Count -gt 0) { $result.Matched = $true; $result.Rows = $m }
        }
        'anyRowFieldLessThan' {
            $m = @($rows | Where-Object { $v=0.0; ([double]::TryParse([string](Get-RowValue -Row $_ -Column $Condition.field), [ref]$v)) -and ($v -lt [double]$Condition.value) })
            if ($m.Count -gt 0) { $result.Matched = $true; $result.Rows = $m }
        }
        default { }
    }
    return $result
}

function Expand-RuleTokens {
    <# Replaces {FieldName} tokens in a template using a row's field values. #>
    param([string]$Template, [object]$Row)
    if ([string]::IsNullOrEmpty($Template)) { return $Template }
    if ($null -eq $Row) { return $Template }
    return [regex]::Replace($Template, '\{(\w+)\}', {
        param($match)
        $field = $match.Groups[1].Value
        $val = Get-RowValue -Row $Row -Column $field
        if ($null -eq $val) { return '' }
        return (ConvertTo-DisplayString -Value $val)
    })
}

function Get-ComplianceRelevanceForFinding {
    <# Returns interpretive compliance relevance notes for a finding given the active lens. #>
    param([object]$Context, [object]$Finding, [string[]]$BaseRelevance = @())
    $notes = [System.Collections.Generic.List[string]]::new()
    foreach ($b in @($BaseRelevance)) { if ($b -and -not $notes.Contains($b)) { $notes.Add($b) } }
    $lensName = $Context.ComplianceLens
    if (-not $lensName -or $lensName -eq 'None') { return @($notes) }
    if (-not ($Context.Config -and $Context.Config.Compliance -and $Context.Config.Compliance.lenses)) { return @($notes) }
    $lens = $Context.Config.Compliance.lenses.$lensName
    if (-not $lens) { return @($notes) }
    # By category.
    if ($lens.relevanceByCategory -and $Finding.Category) {
        $byCat = $lens.relevanceByCategory.($Finding.Category)
        foreach ($n in @($byCat)) { if ($n -and -not $notes.Contains($n)) { $notes.Add($n) } }
    }
    # By keyword in title/evidence.
    if ($lens.relevanceByFindingKeyword) {
        $hay = ("{0} {1}" -f $Finding.Title, $Finding.Evidence)
        foreach ($kw in $lens.relevanceByFindingKeyword.PSObject.Properties.Name) {
            if ($hay -match [regex]::Escape($kw)) {
                foreach ($n in @($lens.relevanceByFindingKeyword.$kw)) { if ($n -and -not $notes.Contains($n)) { $notes.Add($n) } }
            }
        }
    }
    return @($notes)
}

function Test-RuleEmphasizedForProjectType {
    <#
        A rule may declare projectTypeEmphasis, e.g. ["Decommission","ServerRefresh"].
        When the active -ProjectType is listed, the resulting findings are flagged as
        emphasized so the reports lead with them.

        Emphasis is PRESENTATION ONLY - it never changes Severity, Confidence, or which
        rules fire, which is what "same data, different emphasis" in docs\README.md means.
        GeneralDiscovery intentionally matches nothing: it is the no-lens default.
    #>

    param([object]$Context, [object]$Rule)
    if (-not $Rule.PSObject.Properties['projectTypeEmphasis']) { return $false }
    $emphasis = @($Rule.projectTypeEmphasis)
    if ($emphasis.Count -eq 0) { return $false }
    $active = [string]$Context.ProjectType
    if ([string]::IsNullOrWhiteSpace($active)) { return $false }
    foreach ($pt in $emphasis) { if ([string]$pt -eq $active) { return $true } }
    return $false
}

function Get-WbsAreasForCategory {
    <#
        Default work-breakdown areas per finding category, used when a rule does not declare
        likelyAffectedWBSAreas explicitly. Without this, wbs-inputs.csv fell back to echoing
        the raw category, which is not a WBS area and is useless for building an estimate.
        An explicit likelyAffectedWBSAreas on the rule always wins.
    #>

    param([string]$Category)
    $map = @{
        'Identity / AD / DC' = @('Active Directory Migration','Cutover Validation','Client Coordination')
        'Service Account'    = @('Application Migration','Cutover Validation','Client Coordination')
        'Database'           = @('Database Migration','Backup and Recovery Validation','Cutover Validation')
        'Applications'       = @('Application Migration','Vendor Coordination','Cutover Validation')
        'Web'                = @('Application Migration','Certificate and Binding Migration','Cutover Validation')
        'File / Print'       = @('File Share Migration','Print Services Migration','Data Migration')
        'Certificates'       = @('Certificate and Binding Migration','Cutover Validation')
        'Backup / DR'        = @('Backup and Recovery Validation','Rollback Planning')
        'Vendor'             = @('Vendor Coordination','Application Migration')
        'Licensing'          = @('Licensing Review','Vendor Coordination')
        'Network'            = @('Network Configuration','Cutover Validation')
        'Storage'            = @('Storage and Capacity Planning','Data Migration')
        'Security'           = @('Security Remediation','Compliance Review')
        'Hyper-V / Cluster'  = @('Virtualization Migration','Cutover Validation','Rollback Planning')
        'Operating System'   = @('Operating System Upgrade','Cutover Validation')
        'Performance'        = @('Capacity and Performance Review','Target Sizing')
        'Services / Tasks'   = @('Service and Automation Migration','Cutover Validation')
        'Discovery Quality'  = @('Discovery and Documentation','Client Coordination')
    }
    if ($Category -and $map.ContainsKey($Category)) { return ,@($map[$Category]) }
    return ,@()
}

function Invoke-DiscoveryRiskAnalysis {
    <# Evaluates all enabled rules against the collected datasets and emits findings. #>
    param([object]$Context)
    $rulesConfig = $null
    if ($Context.Config -and $Context.Config.RiskRules) { $rulesConfig = $Context.Config.RiskRules }
    if (-not $rulesConfig -or -not $rulesConfig.rules) {
        Write-Log -Level WARN -Message 'No risk rules loaded; skipping rule evaluation.' -Module 'RiskEngine' -Context $Context
        return
    }
    $emitted = 0
    # Computed once, not per-rule - reused below wherever a rule's suggestedScopeLanguage names
    # the acting party inline via the literal '{Brand}' token ("{Brand} assumes...").
    $brandName = (Get-DiscoveryBranding -Context $Context).Brand
    foreach ($rule in $rulesConfig.rules) {
        try {
            if ($rule.PSObject.Properties['enabled'] -and -not $rule.enabled) { continue }
            $eval = Test-RuleCondition -Context $Context -Condition $rule.condition
            if (-not $eval.Matched) { continue }

            $emitPerRow = ($rule.PSObject.Properties['emitPerRow'] -and $rule.emitPerRow)
            $baseCompliance = @()
            if ($rule.PSObject.Properties['complianceRelevance']) { $baseCompliance = @($rule.complianceRelevance) }
            $impact = @()
            if ($rule.PSObject.Properties['potentialProjectImpact']) { $impact = @($rule.potentialProjectImpact) }
            $wbs = @()
            if ($rule.PSObject.Properties['likelyAffectedWBSAreas']) { $wbs = @($rule.likelyAffectedWBSAreas) }
            if ($wbs.Count -eq 0) { $wbs = @(Get-WbsAreasForCategory -Category ([string]$rule.category)) }
            $scopeLang = ''
            if ($rule.PSObject.Properties['suggestedScopeLanguage']) { $scopeLang = [string]$rule.suggestedScopeLanguage }
            # Rules author this as a markdown blockquote ('> {Brand} assumes ...'), but the
            # markdown writer prefixes its own '> **[Type]** ', which would double the marker.
            if ($scopeLang) {
                $scopeLang = ($scopeLang -replace '^\s*>\s*', '')
                # risk-rules.json's suggestedScopeLanguage strings name the acting party inline via
                # the literal '{Brand}' token ("{Brand} assumes...") - swapped for the configured
                # brand name here (same source ScopeLanguage.psm1 and every report header already
                # use) so whoever is running this sees their own company's name in generated scope
                # language, not a placeholder token or someone else's name.
                $scopeLang = $scopeLang.Replace('{Brand}', $brandName)
            }
            $emphasized = Test-RuleEmphasizedForProjectType -Context $Context -Rule $rule
            $emphasisReason = ''
            if ($emphasized) { $emphasisReason = ("Prioritised for project type '{0}'." -f $Context.ProjectType) }
            # evidenceField names the row's primary identifier (service name, task name, ...).
            $evidenceField = ''
            if ($rule.PSObject.Properties['evidenceField']) { $evidenceField = [string]$rule.evidenceField }
            # confidenceField lets a per-row rule take its confidence from the row itself (e.g. a
            # detected-but-not-running service is weaker evidence than a running one) instead of
            # every emitted finding sharing one static rule-level confidence regardless of row
            # content. Falls back to $rule.confidence when the field is absent/blank on a row.
            $confidenceField = ''
            if ($rule.PSObject.Properties['confidenceField']) { $confidenceField = [string]$rule.confidenceField }

            if ($emitPerRow -and $eval.Rows.Count -gt 0) {
                $cap = 200  # avoid pathological finding explosions; note truncation
                $rowsToEmit = $eval.Rows
                $truncated = $false
                if ($rowsToEmit.Count -gt $cap) { $rowsToEmit = $rowsToEmit[0..($cap-1)]; $truncated = $true }
                foreach ($row in $rowsToEmit) {
                    $evidence = if ($rule.PSObject.Properties['evidenceTemplate']) { Expand-RuleTokens -Template $rule.evidenceTemplate -Row $row } else { '' }
                    $question = if ($rule.PSObject.Properties['suggestedValidationQuestion']) { Expand-RuleTokens -Template $rule.suggestedValidationQuestion -Row $row } else { '' }
                    $subject = ''
                    if ($evidenceField) {
                        $sv = Get-RowValue -Row $row -Column $evidenceField
                        if ($null -ne $sv) { $subject = ConvertTo-DisplayString -Value $sv }
                    }
                    $rowConfidence = $rule.confidence
                    if ($confidenceField) {
                        $cv = Get-RowValue -Row $row -Column $confidenceField
                        if ($cv) { $rowConfidence = [string]$cv }
                    }
                    $f = Add-Finding -Context $Context -Category $rule.category -Severity $rule.severity -Confidence $rowConfidence `
                        -Title $rule.title -EvidenceSource $rule.sourceDataset -Evidence $evidence `
                        -WhyItMattersForScoping $rule.whyItMattersForScoping -PotentialProjectImpact $impact `
                        -SuggestedValidationQuestion $question -SuggestedScopeLanguage $scopeLang `
                        -LikelyAffectedWBSAreas $wbs -Subject $subject -IsEmphasized $emphasized -EmphasisReason $emphasisReason `
                        -SourceModule $rule.sourceModule -SourceDataset $rule.sourceDataset
                    $f.ComplianceRelevance = Get-ComplianceRelevanceForFinding -Context $Context -Finding $f -BaseRelevance $baseCompliance
                    $emitted++
                }
                if ($truncated) {
                    Add-Limitation -Context $Context -Module 'RiskEngine' -Message ("Rule {0} matched more than {1} rows; only the first {1} findings were emitted." -f $rule.id, $cap) -Impact 'Finding volume capped' | Out-Null
                }
            } else {
                $question = ''
                if ($rule.PSObject.Properties['suggestedValidationQuestion']) { $question = [string]$rule.suggestedValidationQuestion }
                $evidence = ''
                if ($rule.condition.type -eq 'datasetMissingOrEmpty') { $evidence = ("Dataset '{0}' was not present or contained no rows." -f $rule.condition.dataset) }
                elseif ($rule.condition.dataset -and $Context.DataSets.Contains($rule.condition.dataset)) { $evidence = ("{0} matching row(s) in dataset '{1}'." -f $eval.Rows.Count, $rule.condition.dataset) }
                $f = Add-Finding -Context $Context -Category $rule.category -Severity $rule.severity -Confidence $rule.confidence `
                    -Title $rule.title -EvidenceSource $rule.sourceDataset -Evidence $evidence `
                    -WhyItMattersForScoping $rule.whyItMattersForScoping -PotentialProjectImpact $impact `
                    -SuggestedValidationQuestion $question -SuggestedScopeLanguage $scopeLang `
                    -LikelyAffectedWBSAreas $wbs -IsEmphasized $emphasized -EmphasisReason $emphasisReason `
                    -SourceModule $rule.sourceModule -SourceDataset $rule.sourceDataset
                $f.ComplianceRelevance = Get-ComplianceRelevanceForFinding -Context $Context -Finding $f -BaseRelevance $baseCompliance
                $emitted++
            }
            # A rule's suggestedScopeLanguage previously lived only on the finding object, so it
            # never reached draft-scope-language.md / scope-assumptions.md. Contribute it to the
            # shared scope-language list exactly once per matched rule.
            if ($scopeLang) {
                $slType = 'Assumption'
                if ($rule.PSObject.Properties['suggestedScopeLanguageType'] -and $rule.suggestedScopeLanguageType) { $slType = [string]$rule.suggestedScopeLanguageType }
                $already = @($Context.ScopeLanguage | Where-Object { $_.Text -eq $scopeLang }).Count -gt 0
                if (-not $already) {
                    Add-ScopeLanguage -Context $Context -Text $scopeLang -Type $slType -Module 'RiskEngine' -RelatedFinding ([string]$rule.id) | Out-Null
                }
            }
        } catch {
            Write-Log -Level WARN -Message ("Rule '{0}' evaluation failed." -f $rule.id) -Module 'RiskEngine' -Exception $_ -Context $Context
        }
    }
    $emphCount = @($Context.Findings | Where-Object { $_.IsEmphasized }).Count
    Write-Log -Level INFO -Message ("RiskEngine emitted {0} finding(s) from {1} rule(s); {2} emphasised for project type '{3}'." -f $emitted, @($rulesConfig.rules).Count, $emphCount, $Context.ProjectType) -Module 'RiskEngine' -Context $Context
}

#endregion

#region Migration complexity / WBS / dependency graph -------------------------

function Get-ComplexityRating {
    <# Transparent rubric: rating derived from matching finding categories/severities. #>
    param([object[]]$Findings, [string[]]$Categories, [string[]]$ImpactKeywords = @())
    $related = @($Findings | Where-Object {
        ($Categories -contains $_.Category) -or
        (@($_.PotentialProjectImpact | Where-Object { $ImpactKeywords -contains $_ }).Count -gt 0)
    })
    if ($related.Count -eq 0) { return @{ Rating='None'; Evidence='No related findings detected.' } }
    $hasHigh = @($related | Where-Object { $_.Severity -in @('High','Critical') }).Count -gt 0
    $hasMed  = @($related | Where-Object { $_.Severity -eq 'Medium' }).Count -gt 0
    $rating = if ($hasHigh) { 'High' } elseif ($hasMed) { 'Medium' } else { 'Low' }
    return @{ Rating=$rating; Evidence=("{0} related finding(s); highest severity contributes to rating." -f $related.Count) }
}

function Build-MigrationComplexity {
    param([object]$Context)
    $f = @($Context.Findings)
    $rubric = @(
        @{ Category='Identity dependency';            Cats=@('Identity / AD / DC');            Kw=@() ; Why='AD/DNS/DHCP/identity roles require careful sequencing and cannot be moved casually.' }
        @{ Category='Network dependency';             Cats=@('Network');                       Kw=@() ; Why='Static IPs, DNS, gateways, and listening ports are frequently referenced by other systems.' }
        @{ Category='Database dependency';            Cats=@('Database');                      Kw=@('Data Migration'); Why='Databases drive data migration effort, backup validation, and cutover coordination.' }
        @{ Category='Application dependency';         Cats=@('Applications','Web');            Kw=@() ; Why='Line-of-business and web apps carry config, runtime, and vendor dependencies.' }
        @{ Category='File/print dependency';          Cats=@('File / Print');                  Kw=@() ; Why='File shares and printers imply user/UNC dependencies and data movement.' }
        @{ Category='Certificate dependency';         Cats=@('Certificates');                  Kw=@() ; Why='Bound certificates may require reissue or migration; keys are never exported by this tool.' }
        @{ Category='Service account dependency';     Cats=@('Service Account');               Kw=@() ; Why='Service/app-pool/task accounts require credential coordination and permission reproduction.' }
        @{ Category='Vendor dependency';              Cats=@('Vendor','Licensing');            Kw=@('Vendor Dependency'); Why='Vendor agents/apps/licensing often require coordination and reactivation.' }
        @{ Category='Data volume';                    Cats=@('Storage','File / Print');        Kw=@('Data Migration'); Why='Data volume affects migration windows, staging, and target sizing.' }
        @{ Category='Downtime sensitivity';           Cats=@('Identity / AD / DC','Database','Web','Hyper-V / Cluster'); Kw=@('Downtime','Cutover Complexity'); Why='Critical roles increase downtime sensitivity during cutover.' }
        @{ Category='Backup/rollback clarity';        Cats=@('Backup / DR');                   Kw=@('Rollback Planning'); Why='Unclear backup/restore state increases rollback risk.' }
        @{ Category='Documentation quality';          Cats=@('Discovery Quality');             Kw=@() ; Why='Unknowns and unavailable data reduce planning confidence.' }
        @{ Category='Security/compliance sensitivity';Cats=@('Security');                      Kw=@('Security/Compliance'); Why='Security posture gaps affect risk and possible remediation scope.' }
        @{ Category='Licensing uncertainty';          Cats=@('Licensing');                     Kw=@('Licensing'); Why='OEM/RDS/SQL/vendor licensing may not transfer and needs validation.' }
        @{ Category='Operating system currency';      Cats=@('Operating System');              Kw=@() ; Why='An OS at or near end of support constrains the target platform and may force an in-place upgrade or a rebuild.' }
        @{ Category='Performance headroom';           Cats=@('Performance');                   Kw=@() ; Why='Observed CPU/memory pressure drives target sizing and whether a like-for-like move is safe.' }
        @{ Category='Automation / scheduled work';    Cats=@('Services / Tasks');              Kw=@() ; Why='Services and scheduled tasks must be reproduced on the target; undocumented automation is a common cutover surprise.' }
    )
    $rows = foreach ($r in $rubric) {
        $res = Get-ComplexityRating -Findings $f -Categories $r.Cats -ImpactKeywords $r.Kw
        [pscustomobject]@{
            Category          = $r.Category
            Rating            = $res.Rating
            Evidence          = $res.Evidence
            WhyItMatters      = $r.Why
            SuggestedValidation = 'Confirm with client/vendor; this rating is a transparent indicator, not a precise estimate.'
        }
    }
    Add-DataSet -Context $Context -Name 'MigrationComplexity' -Description 'Transparent migration-complexity rubric derived from findings.' -Rows $rows -Visibility 'Both' -SourceModule 'RiskEngine' | Out-Null
}

function Build-WbsInputs {
    param([object]$Context)
    $rows = foreach ($f in @($Context.Findings)) {
        if ($f.Severity -eq 'Info') { continue }
        $wbsArea = if (@($f.LikelyAffectedWBSAreas).Count -gt 0) { ($f.LikelyAffectedWBSAreas -join '; ') } else { $f.Category }
        [pscustomobject]@{
            Finding           = $f.Title
            Subject           = $f.Subject
            PriorityForProject= $f.IsEmphasized
            SuggestedWBSArea  = $wbsArea
            LaborDriver       = (@($f.PotentialProjectImpact) -join '; ')
            Complexity        = $f.Severity
            Evidence          = $f.Evidence
    SuggestedScopeNote= $f.SuggestedScopeLanguage
            ValidationQuestion= $f.SuggestedValidationQuestion
        }
    }
    Add-DataSet -Context $Context -Name 'WbsInputs' -Description 'Work-breakdown-structure inputs derived from findings.' -Rows @($rows) -Visibility 'Internal' -SourceModule 'RiskEngine' | Out-Null
}

function Build-ReadinessScore {
    <#
        Deterministic, transparent 0-100 indicator derived from Findings' Severity x
        Confidence - not a certification (see the Caveat field on every returned row,
        wording matching Build-MigrationComplexity's own SuggestedValidation above). Produces
        two datasets: one overall score, and a breakdown across 5 human-readable subsections
        grouping the 18 real finding categories, so a reader can see WHERE risk concentrates
        rather than just one opaque number.
    #>

    param([object]$Context)

    $severityWeight = @{ Critical = 20; High = 10; Medium = 4; Low = 1; Info = 0 }
    # Roughly doubling at each severity step (1-4-10-20, not linear) so a handful of Criticals
    # can legitimately drive a server to 0 while a pile of Lows can't accidentally do the same -
    # matches the qualitative ordering Get-ComplexityRating already encodes (hasHigh beats
    # hasMed beats else) without inventing a new severity taxonomy.
    $confidenceMultiplier = @{ Confirmed = 1.0; Likely = 0.7; Possible = 0.4; NotDetected = 0; Unknown = 0.5 }

    $subsectionMap = [ordered]@{
        'Identity & Access'         = @('Identity / AD / DC', 'Service Account', 'Security')
        'Data & Applications'       = @('Database', 'Applications', 'Web', 'File / Print', 'Storage')
        'Infrastructure & Platform' = @('Operating System', 'Hyper-V / Cluster', 'Network', 'Performance', 'Services / Tasks')
        'Continuity & Compliance'   = @('Backup / DR', 'Certificates', 'Licensing', 'Vendor')
        # Its own subsection, deliberately not folded into another one: a low score here means
        # "we don't know enough," which is a different problem than "we found problems."
        'Discovery Confidence'      = @('Discovery Quality')
    }

    $caveat = 'This score is a transparent, rule-based indicator derived from findings severity and confidence - not a certification, audit, or guarantee. Always validate with the client before using it in scoping or a statement of work.'

    $getBand = {
        param($score)
        if ($score -ge 90)    { return @{ Grade = 'A'; Label = 'Clean';                 Description = 'Few or no notable findings. Still validate before treating this as a certification.' } }
        elseif ($score -ge 75) { return @{ Grade = 'B'; Label = 'Minor gaps';            Description = 'Some findings worth reviewing, nothing that should block planning on its own.' } }
        elseif ($score -ge 55) { return @{ Grade = 'C'; Label = 'Needs attention';       Description = 'Multiple findings, including at least one higher-severity item. Plan time to investigate before committing to a timeline.' } }
        elseif ($score -ge 30) { return @{ Grade = 'D'; Label = 'Significant concerns';  Description = 'Several higher-severity or low-confidence findings. Treat scoping estimates as provisional until these are resolved.' } }
        else                   { return @{ Grade = 'F'; Label = 'High risk';             Description = 'A concentration of Critical/High findings. This server needs hands-on validation before it is scoped or migrated.' } }
    }

    $deductionFor = {
        param($finding)
        $w = if ($severityWeight.ContainsKey($finding.Severity)) { $severityWeight[$finding.Severity] } else { 0 }
        $m = if ($confidenceMultiplier.ContainsKey($finding.Confidence)) { $confidenceMultiplier[$finding.Confidence] } else { 1.0 }
        return [Math]::Round($w * $m)
    }

    # Info findings are never scored - matches Build-WbsInputs's own 'if ($f.Severity -eq
    # ''Info'') { continue }' precedent immediately above.
    $allFindings = @($Context.Findings | Where-Object { $_.Severity -ne 'Info' })
    $totalDeduction = 0
    foreach ($f in $allFindings) { $totalDeduction += (& $deductionFor $f) }
    $overallScore = [Math]::Max(0, 100 - $totalDeduction)
    $overallBand = & $getBand $overallScore

    Add-DataSet -Context $Context -Name 'ReadinessScore' -Description 'Overall server readiness score derived from findings severity and confidence.' -Rows @([pscustomobject]@{
        Score           = $overallScore
        Grade           = $overallBand.Grade
        Label           = $overallBand.Label
        Description     = $overallBand.Description
        FindingsCounted = $allFindings.Count
        Caveat          = $caveat
    }) -Visibility 'Both' -SourceModule 'RiskEngine' | Out-Null

    $subsectionRows = foreach ($subsection in $subsectionMap.Keys) {
        $cats = $subsectionMap[$subsection]
        $subFindings = @($allFindings | Where-Object { $cats -contains $_.Category })
        # Computed once per finding (not inline in Sort-Object's calculated property) to avoid
        # relying on closure capture inside a Sort-Object scriptblock for something this simple.
        $subFindingsWithDeduction = @($subFindings | ForEach-Object { [pscustomobject]@{ Title = $_.Title; Deduction = (& $deductionFor $_) } })
        $subDeduction = ($subFindingsWithDeduction | Measure-Object -Property Deduction -Sum).Sum
        if (-not $subDeduction) { $subDeduction = 0 }
        $subScore = [Math]::Max(0, 100 - $subDeduction)
        $subBand = & $getBand $subScore
        $topFindings = @($subFindingsWithDeduction | Sort-Object -Property Deduction -Descending | Select-Object -First 3 | ForEach-Object { $_.Title })
        [pscustomobject]@{
            Subsection              = $subsection
            Score                   = $subScore
            Grade                   = $subBand.Grade
            Label                   = $subBand.Label
            Categories              = ($cats -join '; ')
            FindingsCounted         = $subFindings.Count
            TopContributingFindings = ($topFindings -join '; ')
        }
    }
    Add-DataSet -Context $Context -Name 'ReadinessScoreSubsections' -Description 'Per-subsection readiness score breakdown.' -Rows @($subsectionRows) -Visibility 'Both' -SourceModule 'RiskEngine' | Out-Null
}

function Build-DependencyGraph {
    param([object]$Context)
    $rows = foreach ($e in @($Context.DependencyEdges)) {
        [pscustomobject]@{
            SourceType         = $e.SourceType
            SourceName         = $e.SourceName
            DependencyType     = $e.DependencyType
            Target             = $e.Target
            Evidence           = $e.Evidence
            Confidence         = $e.Confidence
            SourceDataset      = $e.SourceDataset
            ProjectImpact      = $e.ProjectImpact
            ValidationQuestion = $e.ValidationQuestion
        }
    }
    Add-DataSet -Context $Context -Name 'DependencyGraph' -Description 'Cross-component dependency edges discovered during collection.' -Rows @($rows) -Visibility 'Internal' -SourceModule 'RiskEngine' | Out-Null
}

#endregion

function Build-CriticalPaths {
    <#
        Builds the CriticalPaths dataset from already-collected datasets. This runs
        regardless of whether the config dependency scan ran. ConfigDependencyScan only
        derives Service/SmbShare/IIS paths, so its rows are MERGED with these (deduped on
        Path+Source) rather than replacing them - otherwise SQL and Application paths
        vanish in exactly the Deep runs where they matter.
    #>

    param([object]$Context)
    $have = @{}
    if ($Context.DataSets.Contains('CriticalPaths')) { foreach ($r in @($Context.DataSets['CriticalPaths'].Rows)) { $have[("{0}|{1}" -f $r.Path, $r.Source)] = $true } }
    $rows = [System.Collections.Generic.List[object]]::new()
    $add = {
        param($path, $src, $reason, $related, $impact)
        if ([string]::IsNullOrWhiteSpace($path)) { return }
        if ($have.ContainsKey(("{0}|{1}" -f $path, $src))) { return }
        $have[("{0}|{1}" -f $path, $src)] = $true
        $exists = $false; try { $exists = Test-Path -LiteralPath $path } catch { }
        $rows.Add([pscustomobject]@{ Path=$path; Source=$src; ReasonItMatters=$reason; Exists=$exists; SizeIfSafe=''; RelatedServiceOrApp=$related; Confidence='Likely'; PotentialProjectImpact=$impact })
    }
    try { if ($Context.DataSets.Contains('Services')) { foreach ($s in @($Context.DataSets['Services'].Rows | Where-Object { $_.ExecutablePath -and $_.IsNonMicrosoftAutoStart })) { & $add $s.ExecutablePath 'Service' 'Non-Microsoft service executable location' $s.Name 'Cutover Complexity' } } } catch { }
    try { if ($Context.DataSets.Contains('SmbShares')) { foreach ($s in @($Context.DataSets['SmbShares'].Rows | Where-Object { $_.IsUserShare })) { & $add $s.Path 'SmbShare' 'Shared data location' $s.Name 'Data Migration' } } } catch { }
    try { if ($Context.DataSets.Contains('IisSites')) { foreach ($s in @($Context.DataSets['IisSites'].Rows | Where-Object { $_.PhysicalPath })) { & $add $s.PhysicalPath 'IIS' 'Web content location' $s.Name 'Cutover Complexity' } } } catch { }
    try { if ($Context.DataSets.Contains('SqlInstances')) { foreach ($s in @($Context.DataSets['SqlInstances'].Rows | Where-Object { $_.BinaryPath })) { & $add $s.BinaryPath 'SQL' 'SQL instance binary path' $s.InstanceName 'Data Migration' } } } catch { }
    try { if ($Context.DataSets.Contains('InstalledApplications')) { foreach ($a in @($Context.DataSets['InstalledApplications'].Rows | Where-Object { $_.InstallLocation -and -not $_.SystemComponent })) { & $add $a.InstallLocation 'Application' 'Application install location' $a.DisplayName 'Labor' } } } catch { }
    Add-DataSet -Context $Context -Name 'CriticalPaths' -Description 'Paths critical to service/app function, derived from collected datasets.' -Rows @($rows) -Visibility 'Internal' -SourceModule 'RiskEngine' | Out-Null
}

function Invoke-DiscoverySynthesis {
    <# Synthesis entry point invoked by the orchestrator after all collectors. #>
    param([object]$Context)
    Write-SectionStatus -Title 'Risk Engine' -Status 'Analyzing' -Context $Context
    try { Invoke-DiscoveryRiskAnalysis -Context $Context } catch { Write-Log -Level ERROR -Message 'Risk analysis failed.' -Module 'RiskEngine' -Exception $_ -Context $Context }
    try { Build-DependencyGraph -Context $Context } catch { Write-Log -Level WARN -Message 'Dependency graph build failed.' -Module 'RiskEngine' -Exception $_ -Context $Context }
    try { Build-CriticalPaths -Context $Context } catch { Write-Log -Level WARN -Message 'Critical paths build failed.' -Module 'RiskEngine' -Exception $_ -Context $Context }
    try { Build-MigrationComplexity -Context $Context } catch { Write-Log -Level WARN -Message 'Migration complexity build failed.' -Module 'RiskEngine' -Exception $_ -Context $Context }
    try { Build-WbsInputs -Context $Context } catch { Write-Log -Level WARN -Message 'WBS inputs build failed.' -Module 'RiskEngine' -Exception $_ -Context $Context }
    try { Build-ReadinessScore -Context $Context } catch { Write-Log -Level WARN -Message 'Readiness score build failed.' -Module 'RiskEngine' -Exception $_ -Context $Context }
}

Export-ModuleMember -Function `
    'Get-DiscoveryModuleMetadata','Test-DiscoveryPrerequisites','Invoke-DiscoveryRiskAnalysis', `
    'Invoke-DiscoverySynthesis','Test-RuleCondition','Expand-RuleTokens','Get-ComplianceRelevanceForFinding', `
    'Test-RuleEmphasizedForProjectType','Get-WbsAreasForCategory', `
    'Build-MigrationComplexity','Build-WbsInputs','Build-DependencyGraph','Build-ReadinessScore'