modules/SQL/SQL.psm1

<#
    SQL.psm1 - SQL Server detection, optional integrated-auth enumeration, other
    database engines, and ODBC DSNs/drivers (read-only).
    Produces: SqlInstances, SqlServices, SqlComponents, SqlServerSummary, SqlDatabases,
              SqlDatabaseFiles, SqlAgentJobs, SqlLinkedServers, SqlLoginsSummary,
              SqlConfiguration, OtherDatabaseEngines, OdbcDsns, OdbcDrivers.
    Deep query happens ONLY with -AttemptSqlIntegratedAuth (local Windows integrated auth,
    5-second connect timeout, SELECT-only); login names only (no hashes). No SQL setting is
    ever changed and no SQL credential is ever accepted or stored.
#>


function Get-DiscoveryModuleMetadata {
    [pscustomobject]@{
        ModuleName='SQL'; DisplayName='SQL Server, Databases & ODBC'; Category='Database'; Version='1.1.0'
        DefaultInFast=$true; DefaultInDeep=$true; RequiresAdmin=$false; RequiresDomainContext=$false
        RequiresRole=$null; EstimatedImpact='Low'; CanRunAsSystem=$true
        ProducesDatasets=@('SqlInstances','SqlServices','SqlComponents','SqlServerSummary','SqlDatabases','SqlDatabaseFiles','SqlAgentJobs','SqlLinkedServers','SqlLoginsSummary','SqlConfiguration','OtherDatabaseEngines','OdbcDsns','OdbcDrivers')
        ProducesRisks=$true; ProducesFollowUpQuestions=$true; SupportsDeepMode=$true; SupportsComplianceLens=$false
    }
}

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

function Get-SqlServiceAccount {
    param([object]$Context, [string]$ServiceName)
    try { if ($Context.DataSets.Contains('Services')) { $s = $Context.DataSets['Services'].Rows | Where-Object { $_.Name -eq $ServiceName } | Select-Object -First 1; if ($s) { return $s.StartName } } } catch { }
    try { $s = Invoke-CimSafe -ClassName 'Win32_Service' -Filter ("Name='{0}'" -f $ServiceName) | Select-Object -First 1; if ($s) { return $s.StartName } } catch { }
    return ''
}

function Get-SqlVersionInfo {
    <#
        Pure: maps a SQL build (15.0.4153.1) to its product name and Microsoft extended-support end
        date. IsUnsupported compares against today, so it stays correct as dates pass. An unknown
        major version is reported as unknown, never as supported.
    #>

    param([string]$Version, [datetime]$AsOf = (Get-Date))
    $out = [pscustomobject]@{ ProductName=''; MajorVersion=$null; ExtendedSupportEnd=''; IsUnsupported=$false }
    if ([string]::IsNullOrWhiteSpace($Version)) { return $out }
    $parts = $Version.Split('.')
    $major = 0; if (-not [int]::TryParse($parts[0], [ref]$major)) { return $out }
    $minor = 0; if ($parts.Count -gt 1) { [void][int]::TryParse($parts[1], [ref]$minor) }
    $out.MajorVersion = $major
    $map = @{
        8 = @('SQL Server 2000','2013-04-09'); 9 = @('SQL Server 2005','2016-04-12'); 11 = @('SQL Server 2012','2022-07-12')
        12 = @('SQL Server 2014','2024-07-09'); 13 = @('SQL Server 2016','2026-07-14'); 14 = @('SQL Server 2017','2027-10-12')
        15 = @('SQL Server 2019','2030-01-08'); 16 = @('SQL Server 2022','2033-01-11'); 17 = @('SQL Server 2025','')
    }
    $entry = $null
    if ($major -eq 10) { $entry = @(if ($minor -ge 50) { 'SQL Server 2008 R2','2019-07-09' } else { 'SQL Server 2008','2019-07-09' }) }
    elseif ($map.ContainsKey($major)) { $entry = $map[$major] }
    if ($entry) {
        $out.ProductName = $entry[0]; $out.ExtendedSupportEnd = $entry[1]
        if ($entry[1]) { $out.IsUnsupported = ([datetime]$entry[1] -lt $AsOf) }
    }
    return $out
}

function Get-SqlAccountType {
    <# Pure: classifies a service logon account. Built-in and virtual accounts do not exist on a target server and cannot hold network permissions the same way. #>
    param([string]$Account)
    if ([string]::IsNullOrWhiteSpace($Account)) { return 'Unknown' }
    $a = $Account.Trim()
    if ($a -match '(?i)^(LocalSystem|NT AUTHORITY\\SYSTEM|\.\\LocalSystem)$') { return 'LocalSystem' }
    if ($a -match '(?i)NetworkService|NT AUTHORITY\\NETWORK ?SERVICE') { return 'NetworkService' }
    if ($a -match '(?i)LocalService|NT AUTHORITY\\LOCAL ?SERVICE') { return 'LocalService' }
    if ($a -match '(?i)^NT SERVICE\\') { return 'VirtualAccount' }
    if ($a -match '\$$') { return 'ManagedServiceAccount' }
    if ($a -match '^\.\\' -or $a -match ('(?i)^' + [regex]::Escape($env:COMPUTERNAME) + '\\')) { return 'LocalAccount' }
    if ($a -match '\\|@') { return 'DomainAccount' }
    return 'Unknown'
}

function Get-SqlServiceRole {
    <# Pure: role + instance for a SQL-related service name, or $null for anything else. #>
    param([string]$Name)
    $rules = @(
        @{ Rx='^(MSSQLSERVER)$|^MSSQL\$(.+)$';                Role='Database Engine' }
        @{ Rx='^(SQLSERVERAGENT)$|^SQLAgent\$(.+)$';          Role='SQL Agent' }
        @{ Rx='^(SQLBrowser)$';                               Role='SQL Browser' }
        @{ Rx='^(MSSQLFDLauncher)$|^MSSQLFDLauncher\$(.+)$';  Role='Full-Text' }
        @{ Rx='^(SQLWriter)$';                                Role='VSS Writer' }
        @{ Rx='^(MsDtsServer\d*)$';                           Role='Integration Services' }
        @{ Rx='^(ReportServer)$|^ReportServer\$(.+)$|^(SQLServerReportingServices|PowerBIReportServer)$'; Role='Reporting Services' }
        @{ Rx='^(MSSQLServerOLAPService)$|^MSOLAP\$(.+)$';    Role='Analysis Services' }
        @{ Rx='^(MSSQLLaunchpad)$|^MSSQLLaunchpad\$(.+)$';    Role='Launchpad' }
        @{ Rx='^(SQLTELEMETRY|SSISTELEMETRY\d*|SSASTELEMETRY)$|^SQLTELEMETRY\$(.+)$'; Role='Telemetry' }
    )
    foreach ($r in $rules) {
        $m = [regex]::Match([string]$Name, $r.Rx, 'IgnoreCase')
        if ($m.Success) {
            $inst = 'MSSQLSERVER'
            foreach ($g in @($m.Groups | Select-Object -Skip 1)) { if ($g.Success -and $g.Value -and $g.Value -notmatch '(?i)^(MSSQLSERVER|SQLSERVERAGENT|SQLBrowser|MSSQLFDLauncher|SQLWriter|MsDtsServer\d*|ReportServer|SQLServerReportingServices|PowerBIReportServer|MSSQLServerOLAPService|MSSQLLaunchpad|SQLTELEMETRY|SSISTELEMETRY\d*|SSASTELEMETRY)$') { $inst = $g.Value } }
            if ($r.Role -in @('SQL Browser','VSS Writer','Integration Services')) { $inst = '(shared)' }
            return [pscustomobject]@{ Role=$r.Role; Instance=$inst }
        }
    }
    return $null
}

# Evidence-based instance classification. Pattern = instance-name signal; Confirm = a service or
# installed application that independently corroborates it (name + corroboration = Confirmed).
$script:SqlClassificationRules = @(
    @{ Label='Backup Exec catalog';   Pattern='(?i)^BKUPEXEC';                  Confirm='(?i)Backup Exec' }
    @{ Label='Sage / Timberline';     Pattern='(?i)SAGE|TIMBERLINE|^STO\b';     Confirm='(?i)\bSage\b|Timberline' }
    @{ Label='Veeam';                 Pattern='(?i)VEEAM';                      Confirm='(?i)Veeam' }
    @{ Label='Sophos';                Pattern='(?i)SOPHOS';                     Confirm='(?i)Sophos' }
    @{ Label='SolarWinds';            Pattern='(?i)SOLARWINDS|ORION';           Confirm='(?i)SolarWinds' }
    @{ Label='VMware vCenter';        Pattern='(?i)VIM_SQLEXP|VCENTER';         Confirm='(?i)VMware vCenter' }
)

function Get-SqlInstanceClassification {
    <#
        Pure-ish (no system calls): Confirmed / Likely / Possible / Unknown classification of an instance,
        with the evidence that produced it. Never guesses from version numbers.
    #>

    param([string]$InstanceName, [string]$Edition, [bool]$IsWid, [object[]]$ServiceRows = @(), [object[]]$AppRows = @())
    $short = ([string]$InstanceName -split '\\')[-1] -replace '^MSSQL\$',''
    if ($IsWid) { return [pscustomobject]@{ Classification='Windows Internal Database'; Confidence='Confirmed'; Evidence='Service name MICROSOFT##WID' } }
    foreach ($r in $script:SqlClassificationRules) {
        if ($short -match $r.Pattern) {
            $corro = @($ServiceRows | Where-Object { $_.DisplayName -match $r.Confirm -or $_.Name -match $r.Confirm } | Select-Object -First 1)
            if (-not $corro) { $corro = @($AppRows | Where-Object { $_.DisplayName -match $r.Confirm } | Select-Object -First 1) }
            if ($corro) { return [pscustomobject]@{ Classification=$r.Label; Confidence='Confirmed'; Evidence=("instance name '{0}' + {1}" -f $short, [string]$corro[0].DisplayName) } }
            return [pscustomobject]@{ Classification=$r.Label; Confidence='Likely'; Evidence=("instance name '{0}'" -f $short) }
        }
    }
    if ($Edition -match '(?i)Express') { return [pscustomobject]@{ Classification='Express (application-bundled likely)'; Confidence='Likely'; Evidence=("edition '{0}'" -f $Edition) } }
    if ($short -match '(?i)^SQLEXPRESS') { return [pscustomobject]@{ Classification='Express (application-bundled likely)'; Confidence='Possible'; Evidence='instance name SQLEXPRESS (edition not read)' } }
    if ($short -and $short -notin @('MSSQLSERVER', $env:COMPUTERNAME)) { return [pscustomobject]@{ Classification='Application-specific named instance'; Confidence='Possible'; Evidence=("named instance '{0}'" -f $short) } }
    return [pscustomobject]@{ Classification='General-purpose / unknown'; Confidence='Unknown'; Evidence='default instance; no application signal' }
}

function Get-SqlRegistryInstanceDetail {
    <# Per-instance registry facts that need no SQL connection: ports, cluster membership, patch level. #>
    param([string]$InternalId)
    $base = "HKLM:\SOFTWARE\Microsoft\Microsoft SQL Server\$InternalId"
    $tcp = "$base\MSSQLServer\SuperSocketNetLib\Tcp"
    $port = [string](Get-RegistryValueSafe -Path "$tcp\IPAll" -Name 'TcpPort')
    $dyn  = [string](Get-RegistryValueSafe -Path "$tcp\IPAll" -Name 'TcpDynamicPorts')
    $en   = Get-RegistryValueSafe -Path $tcp -Name 'Enabled'
    $clusterName = Get-RegistryValueSafe -Path "$base\Cluster" -Name 'ClusterName'
    [pscustomobject]@{
        TcpPort=$port; TcpDynamicPorts=$dyn
        TcpEnabled=$(if ($null -eq $en) { $null } else { [bool]([int]$en -eq 1) })
        UsesDynamicPort=[bool]($dyn -and $dyn -ne '0' -and -not $port)
        IsClustered=[bool]$clusterName
        PatchLevel=[string](Get-RegistryValueSafe -Path "$base\Setup" -Name 'PatchLevel')
    }
}

function Invoke-DiscoveryCollection {
    param([object]$Context)
    $instances = [System.Collections.Generic.List[object]]::new()
    $databases = [System.Collections.Generic.List[object]]::new()
    $dbFiles = [System.Collections.Generic.List[object]]::new()
    $jobs = [System.Collections.Generic.List[object]]::new()
    $linked = [System.Collections.Generic.List[object]]::new()
    $logins = [System.Collections.Generic.List[object]]::new()
    $sqlconfig = [System.Collections.Generic.List[object]]::new()
    $other = [System.Collections.Generic.List[object]]::new()
    $dsns = [System.Collections.Generic.List[object]]::new()
    $drivers = [System.Collections.Generic.List[object]]::new()
    $sqlServices = [System.Collections.Generic.List[object]]::new()
    $components = [System.Collections.Generic.List[object]]::new()
    $attempt = ([bool]$Context.Parameters['AttemptSqlIntegratedAuth'])

    # Shared source data, read once.
    $svcRows = @(if ($Context.DataSets.Contains('Services')) { @($Context.DataSets['Services'].Rows) } else { @() })
    $appRows = @(if ($Context.DataSets.Contains('InstalledApplications')) { @($Context.DataSets['InstalledApplications'].Rows) } else { @() })
    $portRows = @(if ($Context.DataSets.Contains('ListeningPorts')) { @($Context.DataSets['ListeningPorts'].Rows) } else { @() })

    # Total logical processor count, for the Enterprise-edition per-core-licensing signal below.
    # Enterprise is licensed per physical/logical core on the host, so knowing the instance is
    # Enterprise without knowing the core count it is running on is half the scoping question.
    # SystemInventory runs before SQL in collectorModuleOrder, so Processors is already here.
    $hostLogicalProcessors = $null
    try {
        if ($Context.DataSets.Contains('Processors')) {
            $sum = (@($Context.DataSets['Processors'].Rows) | Measure-Object -Property NumberOfLogicalProcessors -Sum).Sum
            if ($sum) { $hostLogicalProcessors = [int]$sum }
        }
    } catch { }

    # ---- SQL-related Windows services (engine, agent, browser, full-text, VSS writer, ...) ----
    try {
        $svcSource = @(if ($svcRows.Count -gt 0) { $svcRows } else { @(Invoke-CimSafe -ClassName 'Win32_Service' | ForEach-Object { [pscustomobject]@{ Name=$_.Name; DisplayName=$_.DisplayName; Status=$_.State; StartMode=$_.StartMode; StartName=$_.StartName; ExecutablePath=[string]$_.PathName } }) })
        foreach ($s in $svcSource) {
            $role = Get-SqlServiceRole -Name ([string]$s.Name)
            if (-not $role) { continue }
            $sqlServices.Add([pscustomobject]@{
                Name=$s.Name; DisplayName=$s.DisplayName; Role=$role.Role; Instance=$role.Instance
                Status=[string]$s.Status; StartMode=[string]$s.StartMode; StartName=[string]$s.StartName
                AccountType=(Get-SqlAccountType -Account ([string]$s.StartName)); ExecutablePath=[string]$s.ExecutablePath
            })
        }
    } catch { Add-Limitation -Context $Context -Module 'SQL' -Message 'SQL service enumeration failed.' -Reason $_.Exception.Message | Out-Null }
    $svcByName = @{}; foreach ($s in $sqlServices) { $svcByName[[string]$s.Name] = $s }

    # ---- Installed SQL components (client tools, native clients, ODBC drivers, ...) ----
    try {
        foreach ($a in @($appRows | Where-Object { $_.DisplayName -match '(?i)SQL Server|SQL Native Client|ODBC Driver \d+ for SQL|OLE DB Driver.*SQL' })) {
            $components.Add([pscustomobject]@{ DisplayName=$a.DisplayName; DisplayVersion=$a.DisplayVersion; Publisher=$a.Publisher; Architecture=$a.ArchitectureHint })
        }
    } catch { }

    # ---- Detect SQL instances via registry ----
    $instMap = @{}
    try {
        $p = 'HKLM:\SOFTWARE\Microsoft\Microsoft SQL Server\Instance Names\SQL'
        if (Test-RegistryPathSafe $p) {
            $props = Get-ItemProperty -LiteralPath $p -ErrorAction SilentlyContinue
            foreach ($prop in $props.PSObject.Properties) {
                if ($prop.Name -in @('PSPath','PSParentPath','PSChildName','PSDrive','PSProvider')) { continue }
                $instMap[$prop.Name] = $prop.Value
            }
        }
    } catch { }

    # Enrichment shared by both detection paths: service state, agent state, version support,
    # account type, classification, observed listening ports.
    $enrich = {
        param($row, $shortName)
        $svc = $svcByName[[string]$row.ServiceName]
        $row.ServiceState = if ($svc) { $svc.Status } else { '' }
        $row.ServiceStartMode = if ($svc) { $svc.StartMode } else { '' }
        $row.ServiceAccountType = Get-SqlAccountType -Account ([string]$row.ServiceAccount)
        $agentName = if ($shortName -eq 'MSSQLSERVER') { 'SQLSERVERAGENT' } else { "SQLAgent`$$shortName" }
        $agent = $svcByName[$agentName]
        $row.AgentServiceName = if ($agent) { $agentName } else { '' }
        $row.AgentState = if ($agent) { $agent.Status } else { 'NotInstalled' }
        $row.AgentStartMode = if ($agent) { $agent.StartMode } else { '' }
        $row.AgentDisabled = [bool]($agent -and ($agent.StartMode -match '(?i)Disabled' -or ($agent.StartMode -match '(?i)Manual' -and $agent.Status -ne 'Running')))
        $vi = Get-SqlVersionInfo -Version ([string]$row.Version)
        $row.ProductName = $vi.ProductName; $row.MajorVersion = $vi.MajorVersion; $row.ExtendedSupportEnd = $vi.ExtendedSupportEnd
        # WID is serviced with Windows, not on the SQL Server lifecycle.
        $row.IsUnsupportedVersion = [bool]($vi.IsUnsupported -and -not $row.IsWindowsInternalDatabase)
        $cl = Get-SqlInstanceClassification -InstanceName ([string]$row.InstanceName) -Edition ([string]$row.Edition) -IsWid ([bool]$row.IsWindowsInternalDatabase) -ServiceRows $svcRows -AppRows $appRows
        $row.Classification = $cl.Classification; $row.ClassificationConfidence = $cl.Confidence; $row.ClassificationEvidence = $cl.Evidence
        $row.ListeningPortsObserved = ((@($portRows | Where-Object { $_.ServiceName -eq $row.ServiceName -and $_.Protocol -ne 'UDP' } | ForEach-Object { [string]$_.LocalPort }) | Select-Object -Unique) -join ', ')
    }

    $newRow = {
        param($instanceName, $serviceName, $ver, $edition, $acct, $internalId, $binRoot, $isExpress, $isWid)
        [pscustomobject]@{
            InstanceName=$instanceName; ServiceName=$serviceName; Version=$ver; Edition=$edition
            ServiceAccount=$acct; InternalId=$internalId; DeepQueryPerformed=$false; BinaryPath=$binRoot
            IsExpress=$isExpress; IsWindowsInternalDatabase=$isWid; XpCmdShellEnabled=$false
            HostLogicalProcessors=$hostLogicalProcessors
            ProductName=''; MajorVersion=$null; ProductLevel=''; PatchLevel=''; ExtendedSupportEnd=''; IsUnsupportedVersion=$false
            IsClustered=$false; TcpEnabled=$null; TcpPort=''; TcpDynamicPorts=''; UsesDynamicPort=$false; ListeningPortsObserved=''
            ServiceState=''; ServiceStartMode=''; ServiceAccountType=''
            AgentServiceName=''; AgentState=''; AgentStartMode=''; AgentDisabled=$false
            Classification=''; ClassificationConfidence=''; ClassificationEvidence=''
            ConnectionStatus='NotAttempted'; ConnectionError=''; DatabaseCount=$null; TotalDatabaseSizeGB=$null
        }
    }

    # Shared by the registry path and the service-based fallback: runs the integrated-auth
    # query when enabled, folds the results into the dataset lists, and returns whether it ran.
    $deepQuery = {
        param($row, $connTarget)
        if (-not $attempt) {
            Add-Unknown -Context $Context -Unknown ("SQL instance '{0}' detected; deep enumeration not attempted (use -AttemptSqlIntegratedAuth)." -f $connTarget) -WhyItMatters 'Database-level details unknown without querying.' -Module 'SQL' -RecommendedValidationQuestion 'Should integrated-auth SQL enumeration be enabled?' | Out-Null
            return $false
        }
        $res = Invoke-SqlIntegratedQuery -Instance $connTarget
        if (-not $res.Success) {
            $row.ConnectionStatus = 'Failed'; $row.ConnectionError = [string]$res.Error
            Add-Limitation -Context $Context -Module 'SQL' -Message ("Integrated-auth query to '{0}' failed." -f $connTarget) -Reason $res.Error | Out-Null
            Add-Unknown -Context $Context -Unknown ("SQL instance '{0}' detected but database enumeration failed." -f $connTarget) -WhyItMatters 'Database inventory, sizes, and backup status remain unknown.' -Module 'SQL' -RecommendedValidationQuestion 'Can a DBA provide the database inventory and backup status?' | Out-Null
            return $false
        }
        $row.ConnectionStatus = 'Connected'
        if (-not $row.Version) { $row.Version = $res.Version }
        if (-not $row.Edition) { $row.Edition = $res.Edition; if ($res.Edition -match '(?i)Express') { $row.IsExpress = $true } }
        $row.ProductLevel = $res.ProductLevel
        if ($res.IsClustered) { $row.IsClustered = $true }
        # Tag rows with their instance: with two instances (e.g. SQLEXPRESS + WID) every 'master' looks alike.
        # WID databases (SUSDB, RDCms, ...) are role-managed; "no SQL backup" there is not a DBA gap.
        if ($row.IsWindowsInternalDatabase) { foreach ($d in $res.Databases) { $d.NoFullBackup = $false } }
        foreach ($d in $res.Databases) { $d | Add-Member -NotePropertyName Instance -NotePropertyValue $row.InstanceName -Force; $databases.Add($d) }
        foreach ($f in $res.Files) { $f | Add-Member -NotePropertyName Instance -NotePropertyValue $row.InstanceName -Force; $dbFiles.Add($f) }
        foreach ($j in $res.Jobs) { $j | Add-Member -NotePropertyName Instance -NotePropertyValue $row.InstanceName -Force; $jobs.Add($j) }
        foreach ($l in $res.Linked) { $l | Add-Member -NotePropertyName Instance -NotePropertyValue $row.InstanceName -Force; $linked.Add($l) }
        foreach ($g in $res.Logins) { $g | Add-Member -NotePropertyName Instance -NotePropertyValue $row.InstanceName -Force; $logins.Add($g) }
        $row.XpCmdShellEnabled = [bool]@($res.Config | Where-Object { $_.Setting -eq 'xp_cmdshell' -and [int64]$_.ValueInUse -eq 1 }).Count
        foreach ($c in $res.Config) { $sqlconfig.Add([pscustomobject]@{ Instance=$connTarget; Setting=$c.Setting; ValueInUse=$c.ValueInUse }) }
        $row.DatabaseCount = @($res.Databases).Count
        $sz = (@($res.Databases) | Where-Object { $null -ne $_.SizeGB } | Measure-Object -Property SizeGB -Sum).Sum
        if ($null -ne $sz) { $row.TotalDatabaseSizeGB = [math]::Round([double]$sz, 2) }
        return $true
    }

    foreach ($instName in $instMap.Keys) {
        try {
            $internalId = $instMap[$instName]
            $svcName = if ($instName -eq 'MSSQLSERVER') { 'MSSQLSERVER' } else { "MSSQL`$$instName" }
            $connTarget = if ($instName -eq 'MSSQLSERVER') { $env:COMPUTERNAME } else { "$env:COMPUTERNAME\$instName" }
            $ver = ''; $edition = ''; $binRoot = ''
            try {
                $setup = "HKLM:\SOFTWARE\Microsoft\Microsoft SQL Server\$internalId\Setup"
                $ver = Get-RegistryValueSafe -Path $setup -Name 'Version'
                $edition = Get-RegistryValueSafe -Path $setup -Name 'Edition'
                # BinaryPath feeds the CriticalPaths dataset (RiskEngine.Build-CriticalPaths).
                $binRoot = Get-RegistryValueSafe -Path $setup -Name 'SQLBinRoot'
            } catch { }
            $acct = Get-SqlServiceAccount -Context $Context -ServiceName $svcName
            $row = & $newRow $connTarget $svcName $ver $edition $acct $internalId $binRoot ([bool]($svcName -match '(?i)SQLEXPRESS' -or $edition -match '(?i)Express')) $false
            try {
                $rd = Get-SqlRegistryInstanceDetail -InternalId $internalId
                $row.TcpPort = $rd.TcpPort; $row.TcpDynamicPorts = $rd.TcpDynamicPorts; $row.TcpEnabled = $rd.TcpEnabled
                $row.UsesDynamicPort = $rd.UsesDynamicPort; $row.IsClustered = $rd.IsClustered; $row.PatchLevel = $rd.PatchLevel
            } catch { }
            $row.DeepQueryPerformed = (& $deepQuery $row $connTarget)
            & $enrich $row $instName
            Add-DependencyEdge -Context $Context -SourceType 'SQL Instance' -SourceName $connTarget -DependencyType 'RunsAs' -Target $acct -Evidence 'SQL service account' -Confidence 'Confirmed' -SourceDataset 'SqlInstances' -ProjectImpact 'Cutover Complexity' -ValidationQuestion 'Who owns the SQL service account?' | Out-Null
            $instances.Add($row)
        } catch { }
    }

    # Service-based detection for anything the registry did not yield. Additive, not a
    # fallback-only path: WID (WSUS, AD FS, RDS broker) has no registry entry and must still
    # be reported on a box that also runs a real SQL instance.
    $knownSvc = @($instances | ForEach-Object { $_.ServiceName })
    try {
        foreach ($s in (Invoke-CimSafe -ClassName 'Win32_Service' -Filter "Name LIKE 'MSSQL%'")) {
            if ($s.Name -match '^MSSQL(SERVER|\$)' -and $s.Name -notin $knownSvc) {
                # Service-based fallback: derive the binary directory from the service image path.
                $fbBin = ''
                try {
                    if ($s.PathName) {
                        $m = [regex]::Match([string]$s.PathName, '^"?([^"]+\.exe)')
                        if ($m.Success) { $fbBin = Split-Path -Parent $m.Groups[1].Value }
                    }
                } catch { }
                # Windows Internal Database (WSUS, AD FS, ...) has no Instance Names registry key and
                # is reachable only over its named pipe.
                $isWid = [bool]($s.Name -match '(?i)MICROSOFT##WID|MICROSOFT\$WID')
                $fbTarget = if ($isWid) { 'np:\\.\pipe\MICROSOFT##WID\tsql\query' } elseif ($s.Name -eq 'MSSQLSERVER') { $env:COMPUTERNAME } else { "$env:COMPUTERNAME\" + ($s.Name -replace '^MSSQL\$','') }
                $fbRow = & $newRow $s.Name $s.Name '' '' $s.StartName '' $fbBin ([bool]($s.Name -match '(?i)SQLEXPRESS')) $isWid
                $fbRow.DeepQueryPerformed = (& $deepQuery $fbRow $fbTarget)
                & $enrich $fbRow ($(if ($s.Name -eq 'MSSQLSERVER') { 'MSSQLSERVER' } else { $s.Name -replace '^MSSQL\$','' }))
                $instances.Add($fbRow)
            }
        }
    } catch { }

    # ---- Server-level summary: the migration-readiness facts that span instances ----
    $summary = @()
    $real = @($instances | Where-Object { -not $_.IsWindowsInternalDatabase })
    if ($real.Count -gt 0) {
        $versions = @($real | Where-Object { $_.ProductName } | ForEach-Object { $_.ProductName } | Select-Object -Unique)
        $appInst = @($real | Where-Object { $_.Classification -notin @('General-purpose / unknown','') })
        $browser = @($sqlServices | Where-Object { $_.Role -eq 'SQL Browser' } | Select-Object -First 1)
        $writer = @($sqlServices | Where-Object { $_.Role -eq 'VSS Writer' } | Select-Object -First 1)
        $sized = @($real | Where-Object { $null -ne $_.TotalDatabaseSizeGB })
        $summary = @([pscustomobject]@{
            SqlInstanceCount=$real.Count
            ProductVersions=($versions -join ', ')
            MultipleSqlVersions=[bool]($versions.Count -gt 1)
            ApplicationInstanceCount=$appInst.Count
            MultipleApplicationInstances=[bool]($appInst.Count -gt 1)
            InstancesNotEnumerated=@($real | Where-Object { $_.ConnectionStatus -ne 'Connected' }).Count
            DatabasesEnumerated=[bool](@($real | Where-Object { $_.ConnectionStatus -eq 'Connected' }).Count -gt 0)
            TotalDatabaseSizeGB=$(if ($sized.Count -gt 0) { [math]::Round([double](($sized | Measure-Object -Property TotalDatabaseSizeGB -Sum).Sum), 2) } else { $null })
            AnyUnsupportedVersion=[bool](@($real | Where-Object { $_.IsUnsupportedVersion }).Count -gt 0)
            AnyAgentDisabled=[bool](@($real | Where-Object { $_.AgentDisabled }).Count -gt 0)
            AnyBuiltInServiceAccount=[bool](@($real | Where-Object { $_.ServiceAccountType -in @('LocalSystem','NetworkService','LocalService','VirtualAccount') }).Count -gt 0)
            AnyDomainServiceAccount=[bool](@($real | Where-Object { $_.ServiceAccountType -in @('DomainAccount','ManagedServiceAccount') }).Count -gt 0)
            BrowserState=$(if ($browser) { "{0}/{1}" -f $browser[0].Status, $browser[0].StartMode } else { 'NotInstalled' })
            VssWriterState=$(if ($writer) { "{0}/{1}" -f $writer[0].Status, $writer[0].StartMode } else { 'NotInstalled' })
            DeepQueryRequested=$attempt
        })
    }

    # ---- Other database engines ----
    try {
        $engines = @(
            @{ Engine='MySQL/MariaDB'; Pattern='(?i)mysql|mariadb' },
            @{ Engine='PostgreSQL'; Pattern='(?i)postgres' },
            @{ Engine='Oracle'; Pattern='(?i)oracle' },
            @{ Engine='Firebird'; Pattern='(?i)firebird' },
            @{ Engine='MongoDB'; Pattern='(?i)mongodb' },
            @{ Engine='Pervasive/Actian Zen'; Pattern='(?i)pervasive|actian|zen psql|btrieve' },
            @{ Engine='Informix'; Pattern='(?i)informix' }
        )
        foreach ($e in $engines) {
            $svcHit = @($svcRows | Where-Object { $_.DisplayName -match $e.Pattern -or $_.Name -match $e.Pattern })
            $appHit = @($appRows | Where-Object { $_.DisplayName -match $e.Pattern })
            if ($svcHit.Count -gt 0 -or $appHit.Count -gt 0) {
                $ev = @()
                if ($svcHit.Count) { $ev += ("service:" + $svcHit[0].Name) }
                if ($appHit.Count) { $ev += ("app:" + $appHit[0].DisplayName) }
                $other.Add([pscustomobject]@{ Engine=$e.Engine; Evidence=($ev -join '; '); Confidence='Likely' })
            }
        }
    } catch { }

    # ---- ODBC drivers (32/64-bit) ----
    foreach ($scope in @(
        @{ Path='HKLM:\SOFTWARE\ODBC\ODBCINST.INI'; Bitness='64' },
        @{ Path='HKLM:\SOFTWARE\WOW6432Node\ODBC\ODBCINST.INI'; Bitness='32' }
    )) {
        try {
            $listPath = Join-Path $scope.Path 'ODBC Drivers'
            if (-not (Test-RegistryPathSafe $listPath)) { continue }
            $list = Get-ItemProperty -LiteralPath $listPath -ErrorAction SilentlyContinue
            foreach ($prop in @($list.PSObject.Properties | Where-Object { $_.Name -notin @('PSPath','PSParentPath','PSChildName','PSDrive','PSProvider') })) {
                $dllPath = [string](Get-RegistryValueSafe -Path (Join-Path $scope.Path $prop.Name) -Name 'Driver')
                # 32-bit drivers are registered as System32 paths that WOW64 redirects to SysWOW64.
                $checkPath = if ($scope.Bitness -eq '32') { $dllPath -replace '(?i)\\system32\\', '\SysWOW64\' } else { $dllPath }
                $drivers.Add([pscustomobject]@{ Name=$prop.Name; Bitness=$scope.Bitness; DriverPath=$dllPath; Installed=[string]$prop.Value; FileExists=$(if ($dllPath) { [bool](Test-Path -LiteralPath $checkPath) } else { $null }) })
            }
        } catch { }
    }

    # ---- System ODBC DSNs (user DSNs come from UserProfiles, which reads every loaded user hive) ----
    foreach ($scope in @(
        @{ Path='HKLM:\SOFTWARE\ODBC\ODBC.INI'; Bitness='64'; ScopeName='System' },
        @{ Path='HKLM:\SOFTWARE\WOW6432Node\ODBC\ODBC.INI'; Bitness='32'; ScopeName='System' }
    )) {
        try {
            if (-not (Test-RegistryPathSafe $scope.Path)) { continue }
            foreach ($k in (Get-ChildItem -LiteralPath $scope.Path -ErrorAction SilentlyContinue)) {
                if ($k.PSChildName -eq 'ODBC Data Sources') { continue }
                try {
                    $p = Get-ItemProperty -LiteralPath $k.PSPath -ErrorAction SilentlyContinue
                    $rec = ConvertTo-OdbcDsnRecord -DsnName $k.PSChildName -Properties $p -Scope $scope.ScopeName -Bitness $scope.Bitness -Context $Context
                    $dsns.Add($rec)
                    if ($rec.Server -or $rec.DbqOrPath) { Add-DependencyEdge -Context $Context -SourceType 'ODBC DSN' -SourceName $k.PSChildName -DependencyType 'ConnectsTo' -Target ($(if ($rec.Server) { "{0}/{1}" -f $rec.Server, $rec.Database } else { $rec.DbqOrPath })) -Evidence ("ODBC DSN ({0}-bit, {1})" -f $scope.Bitness, $rec.Driver) -Confidence 'Confirmed' -SourceDataset 'OdbcDsns' -ProjectImpact 'Data Migration' -ValidationQuestion 'Does this DSN target change after migration, and does the target have the same driver and bitness?' | Out-Null }
                } catch { }
            }
        } catch { }
    }
    Set-OdbcCrossChecks -Dsns @($dsns) -Drivers @($drivers)

    return ,@{ SqlInstances=@($instances); SqlServices=@($sqlServices); SqlComponents=@($components); SqlServerSummary=@($summary); SqlDatabases=@($databases); SqlDatabaseFiles=@($dbFiles); SqlAgentJobs=@($jobs); SqlLinkedServers=@($linked); SqlLoginsSummary=@($logins); SqlConfiguration=@($sqlconfig); OtherDatabaseEngines=@($other); OdbcDsns=@($dsns); OdbcDrivers=@($drivers) }
}

function Invoke-SqlIntegratedQuery {
    param([string]$Instance)
    $out = [pscustomobject]@{ Success=$false; Error=''; Version=''; Edition=''; ProductLevel=''; IsClustered=$false; Databases=@(); Files=@(); Jobs=@(); Linked=@(); Logins=@(); Config=@() }
    $conn = $null
    try {
        $cs = "Data Source=$Instance;Integrated Security=SSPI;Connect Timeout=5;Application Name=Discover-WindowsServer"
        $conn = New-Object System.Data.SqlClient.SqlConnection $cs
        $conn.Open()
        # Version/edition: the registry read that normally supplies these is absent for WID.
        try {
            $cv = $conn.CreateCommand(); $cv.CommandTimeout = 10; $cv.CommandText = "SELECT CAST(SERVERPROPERTY('ProductVersion') AS nvarchar(64)) AS v, CAST(SERVERPROPERTY('Edition') AS nvarchar(128)) AS e, CAST(SERVERPROPERTY('ProductLevel') AS nvarchar(64)) AS l, CAST(SERVERPROPERTY('IsClustered') AS int) AS c"
            $rv = $cv.ExecuteReader(); if ($rv.Read()) { $out.Version = [string]$rv['v']; $out.Edition = [string]$rv['e']; $out.ProductLevel = [string]$rv['l']; try { $out.IsClustered = ([int]$rv['c'] -eq 1) } catch { } }; $rv.Close()
        } catch { }
        $dbs = [System.Collections.Generic.List[object]]::new()
        $q = "SELECT d.database_id, d.name, d.state_desc, d.recovery_model_desc, d.compatibility_level, d.create_date, suser_sname(d.owner_sid) AS owner FROM sys.databases d"
        $cmd = $conn.CreateCommand(); $cmd.CommandTimeout = 10; $cmd.CommandText = $q
        $rdr = $cmd.ExecuteReader()
        $ids = @{}; $dbId = @{}; $backupKnown = $false
        while ($rdr.Read()) {
            $row = [pscustomobject]@{ Name=$rdr['name']; State=$rdr['state_desc']; RecoveryModel=$rdr['recovery_model_desc']; CompatLevel=$rdr['compatibility_level']; CreateDate=(Normalize-DateTime $rdr['create_date']); Owner=$rdr['owner']; SizeGB=$null; LastBackup=$null; NoFullBackup=$false }
            $dbId[[string]$rdr['name']] = [int]$rdr['database_id']
            $ids[[string]$rdr['name']] = $row; $dbs.Add($row)
        }
        $rdr.Close()
        # Size and last full backup are separate queries so a permissions failure on one
        # (msdb is the usual culprit) cannot discard the database list.
        try {
            $cs2 = $conn.CreateCommand(); $cs2.CommandTimeout = 10; $cs2.CommandText = "SELECT DB_NAME(database_id) AS n, CAST(SUM(size) * 8.0 / 1048576 AS decimal(18,2)) AS gb FROM sys.master_files GROUP BY database_id"
            $r6 = $cs2.ExecuteReader(); while ($r6.Read()) { if ($ids.ContainsKey([string]$r6['n'])) { $ids[[string]$r6['n']].SizeGB = [double]$r6['gb'] } }; $r6.Close()
        } catch { }
        # Data/log file locations (where permitted) - what a target server's volume layout must match.
        try {
            $fl = [System.Collections.Generic.List[object]]::new()
            $cf = $conn.CreateCommand(); $cf.CommandTimeout = 10; $cf.CommandText = "SELECT DB_NAME(database_id) AS n, type_desc AS t, physical_name AS p, CAST(size * 8.0 / 1048576 AS decimal(18,2)) AS gb FROM sys.master_files"
            $r8 = $cf.ExecuteReader(); while ($r8.Read()) { $fl.Add([pscustomobject]@{ Database=[string]$r8['n']; FileType=[string]$r8['t']; PhysicalName=[string]$r8['p']; SizeGB=[double]$r8['gb'] }) }; $r8.Close()
            $out.Files = @($fl)
        } catch { }
        try {
            $cb = $conn.CreateCommand(); $cb.CommandTimeout = 10; $cb.CommandText = "SELECT database_name AS n, MAX(backup_finish_date) AS f FROM msdb.dbo.backupset WHERE type = 'D' GROUP BY database_name"
            $r7 = $cb.ExecuteReader(); while ($r7.Read()) { if ($ids.ContainsKey([string]$r7['n'])) { $ids[[string]$r7['n']].LastBackup = (Normalize-DateTime $r7['f']) } }; $r7.Close()
            $backupKnown = $true
        } catch { }
        # Only claim "never backed up" when the msdb read actually succeeded; ids <= 4 are system databases.
        if ($backupKnown) { foreach ($k in $ids.Keys) { if ($dbId[$k] -gt 4 -and -not $ids[$k].LastBackup) { $ids[$k].NoFullBackup = $true } } }
        $out.Databases = @($dbs)

        # Logins (names only, no hashes)
        try {
            $lg = [System.Collections.Generic.List[object]]::new()
            $cmd2 = $conn.CreateCommand(); $cmd2.CommandTimeout=10; $cmd2.CommandText = "SELECT name, type_desc FROM sys.server_principals WHERE type IN ('S','U','G') AND name NOT LIKE '##%'"
            $r2 = $cmd2.ExecuteReader(); while ($r2.Read()) { $lg.Add([pscustomobject]@{ LoginName=$r2['name']; Type=$r2['type_desc'] }) } ; $r2.Close()
            $out.Logins = @($lg)
        } catch { }
        # Linked servers
        try {
            $ls = [System.Collections.Generic.List[object]]::new()
            $cmd3 = $conn.CreateCommand(); $cmd3.CommandTimeout=10; $cmd3.CommandText = "SELECT name, product, data_source FROM sys.servers WHERE is_linked = 1"
            $r3 = $cmd3.ExecuteReader(); while ($r3.Read()) { $ls.Add([pscustomobject]@{ Name=$r3['name']; Product=$r3['product']; DataSource=$r3['data_source'] }) } ; $r3.Close()
            $out.Linked = @($ls)
        } catch { }
        # Agent jobs (names/enabled)
        try {
            $jb = [System.Collections.Generic.List[object]]::new()
            $cmd4 = $conn.CreateCommand(); $cmd4.CommandTimeout=10; $cmd4.CommandText = "SELECT name, enabled FROM msdb.dbo.sysjobs"
            $r4 = $cmd4.ExecuteReader(); while ($r4.Read()) { $jb.Add([pscustomobject]@{ JobName=$r4['name']; Enabled=$r4['enabled'] }) } ; $r4.Close()
            $out.Jobs = @($jb)
        } catch { }
        # Key configuration values (safe read from sys.configurations)
        try {
            $cf = [System.Collections.Generic.List[object]]::new()
            $cmd5 = $conn.CreateCommand(); $cmd5.CommandTimeout=10; $cmd5.CommandText = "SELECT name, CAST(value_in_use AS bigint) AS v FROM sys.configurations WHERE name IN ('max server memory (MB)','min server memory (MB)','max degree of parallelism','cost threshold for parallelism','clr enabled','xp_cmdshell','remote access','backup compression default')"
            $r5 = $cmd5.ExecuteReader(); while ($r5.Read()) { $cf.Add([pscustomobject]@{ Setting=$r5['name']; ValueInUse=$r5['v'] }) } ; $r5.Close()
            $out.Config = @($cf)
        } catch { }

        $out.Success = $true
    } catch {
        $out.Error = $_.Exception.Message
    } finally {
        if ($conn) { try { $conn.Close(); $conn.Dispose() } catch { } }
    }
    return $out
}

function ConvertTo-DiscoveryDatasets {
    param([object]$Context, $RawData)
    if (-not $RawData) { $RawData = @{} }
    $get = { param($k) if ($RawData[$k]) { @($RawData[$k]) } else { @() } }
    Add-DataSet -Context $Context -Name 'SqlInstances'        -Description 'Detected SQL Server instances (version support, ports, services, classification, connection status).' -Rows (& $get 'SqlInstances') -Visibility 'Internal' -SourceModule 'SQL' | Out-Null
    Add-DataSet -Context $Context -Name 'SqlServices'         -Description 'SQL-related Windows services (engine, agent, browser, full-text, VSS writer, SSIS/SSRS/SSAS).' -Rows (& $get 'SqlServices') -Visibility 'Internal' -SourceModule 'SQL' | Out-Null
    Add-DataSet -Context $Context -Name 'SqlComponents'       -Description 'Installed SQL Server components and client drivers.' -Rows (& $get 'SqlComponents') -Visibility 'Internal' -SourceModule 'SQL' | Out-Null
    Add-DataSet -Context $Context -Name 'SqlServerSummary'    -Description 'Server-level SQL migration-readiness summary across instances.' -Rows (& $get 'SqlServerSummary') -Visibility 'Internal' -SourceModule 'SQL' | Out-Null
    Add-DataSet -Context $Context -Name 'SqlDatabases'        -Description 'Databases (integrated-auth enumeration).'-Rows (& $get 'SqlDatabases')        -Visibility 'Internal' -SourceModule 'SQL' | Out-Null
    Add-DataSet -Context $Context -Name 'SqlDatabaseFiles'    -Description 'Database data/log file locations (integrated-auth enumeration).' -Rows (& $get 'SqlDatabaseFiles') -Visibility 'Internal' -SourceModule 'SQL' | Out-Null
    Add-DataSet -Context $Context -Name 'SqlAgentJobs'        -Description 'SQL Agent jobs.'                        -Rows (& $get 'SqlAgentJobs')        -Visibility 'Internal' -SourceModule 'SQL' | Out-Null
    Add-DataSet -Context $Context -Name 'SqlLinkedServers'    -Description 'SQL linked servers.'                    -Rows (& $get 'SqlLinkedServers')    -Visibility 'Internal' -SourceModule 'SQL' | Out-Null
    Add-DataSet -Context $Context -Name 'SqlLoginsSummary'    -Description 'SQL login names (no hashes).'           -Rows (& $get 'SqlLoginsSummary')    -Visibility 'Internal' -SourceModule 'SQL' | Out-Null
    Add-DataSet -Context $Context -Name 'SqlConfiguration'    -Description 'Key SQL configuration values (integrated-auth).' -Rows (& $get 'SqlConfiguration') -Visibility 'Internal' -SourceModule 'SQL' | Out-Null
    Add-DataSet -Context $Context -Name 'OtherDatabaseEngines'-Description 'Non-Microsoft database engines.'        -Rows (& $get 'OtherDatabaseEngines')-Visibility 'Internal' -SourceModule 'SQL' | Out-Null
    Add-DataSet -Context $Context -Name 'OdbcDsns'            -Description 'System ODBC DSNs (32/64-bit), credentials excluded.' -Rows (& $get 'OdbcDsns') -Visibility 'Internal' -SourceModule 'SQL' | Out-Null
    Add-DataSet -Context $Context -Name 'OdbcDrivers'         -Description 'Registered ODBC drivers (32/64-bit).'   -Rows (& $get 'OdbcDrivers')         -Visibility 'Internal' -SourceModule 'SQL' | Out-Null
}

function Get-DiscoveryFollowUpQuestions {
    param([object]$Context)
    try {
        if ($Context.DataSets.Contains('SqlInstances') -and @($Context.DataSets['SqlInstances'].Rows).Count -gt 0) {
            Add-FollowUpQuestion -Context $Context -Category 'Databases' -Module 'SQL' -Audience 'Both' -Question 'Which applications depend on each SQL instance, who is the DBA/owner, and is there a documented backup and recovery process?' | Out-Null
        }
        if ($Context.DataSets.Contains('OdbcDsns') -and @($Context.DataSets['OdbcDsns'].Rows | Where-Object { $_.DuplicateAcrossBitness -or $_.IsFileBased }).Count -gt 0) {
            Add-FollowUpQuestion -Context $Context -Category 'Databases' -Module 'SQL' -Audience 'Internal' -Question 'Some ODBC DSNs exist in both 32- and 64-bit views or point at file paths. Which applications use them, and will the target server provide the same driver, bitness and path?' | Out-Null
        }
    } catch { }
}

Export-ModuleMember -Function 'Get-DiscoveryModuleMetadata','Test-DiscoveryPrerequisites','Invoke-DiscoveryCollection','ConvertTo-DiscoveryDatasets','Get-DiscoveryFollowUpQuestions','Invoke-SqlIntegratedQuery','Get-SqlServiceAccount','Get-SqlVersionInfo','Get-SqlAccountType','Get-SqlServiceRole','Get-SqlInstanceClassification','Get-SqlRegistryInstanceDetail'