internal/functions/schema/ConvertTo-SldgSchemaModel.ps1

function ConvertTo-SldgSchemaModel {
    <#
    .SYNOPSIS
        Converts raw database metadata into a normalized internal schema model.
    #>

    [CmdletBinding()]
    param (
        [Parameter(Mandatory)]
        $Tables,

        [Parameter(Mandatory)]
        $Columns,

        [Parameter(Mandatory)]
        $ForeignKeys,

        [Parameter(Mandatory)]
        $UniqueConstraints,

        $CheckConstraints,

        $ViewHints,

        [string[]]$SchemaFilter,

        [string[]]$TableFilter,

        [string]$Database
    )

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

    # Pre-index metadata by table key for O(1) lookups instead of O(n) per-table scans
    # Use @() to safely enumerate: works for both DataTable and DataRow array inputs
    $columnIndex = @{}
    foreach ($colRow in @($Columns)) {
        $key = "$([string]$colRow.TABLE_SCHEMA).$([string]$colRow.TABLE_NAME)"
        if (-not $columnIndex.ContainsKey($key)) { $columnIndex[$key] = [System.Collections.Generic.List[object]]::new() }
        $columnIndex[$key].Add($colRow)
    }
    $ucIndex = @{}
    foreach ($ucRow in @($UniqueConstraints)) {
        $key = "$([string]$ucRow.SchemaName).$([string]$ucRow.TableName).$([string]$ucRow.ColumnName)"
        if (-not $ucIndex.ContainsKey($key)) { $ucIndex[$key] = [System.Collections.Generic.List[object]]::new() }
        $ucIndex[$key].Add($ucRow)
    }
    $fkByParent = @{}
    foreach ($fkRow in @($ForeignKeys)) {
        $key = "$([string]$fkRow.ParentSchema).$([string]$fkRow.ParentTable)"
        if (-not $fkByParent.ContainsKey($key)) { $fkByParent[$key] = [System.Collections.Generic.List[object]]::new() }
        $fkByParent[$key].Add($fkRow)
    }
    $ccIndex = @{}
    if ($CheckConstraints) {
        foreach ($ccRow in @($CheckConstraints)) {
            $key = "$([string]$ccRow.SchemaName).$([string]$ccRow.TableName).$([string]$ccRow.ColumnName)"
            if (-not $ccIndex.ContainsKey($key)) { $ccIndex[$key] = [System.Collections.Generic.List[object]]::new() }
            $ccIndex[$key].Add($ccRow)
        }
    }
    $viewHintIndex = @{}
    # Pre-analyze view definitions to detect JSON/XML usage per table.column
    # Maps "schema.table.column" -> @{ ViewDefinitions = [...]; DetectedFormat = 'Json'|'Xml'|$null }
    $viewColumnHints = @{}
    if ($ViewHints) {
        foreach ($vhRow in @($ViewHints)) {
            $tableKey = "$([string]$vhRow.TableSchema).$([string]$vhRow.TableName)"
            $viewDef = [string]$vhRow.ViewDefinition
            if (-not $viewDef) { continue }

            # Collect all view definitions per table
            if (-not $viewHintIndex.ContainsKey($tableKey)) { $viewHintIndex[$tableKey] = [System.Collections.Generic.List[string]]::new() }
            $viewHintIndex[$tableKey].Add($viewDef)

            # Detect JSON parsing functions: JSON_VALUE(x.col, ...), OPENJSON(x.col), JSON_QUERY(x.col, ...), ISJSON(x.col)
            # Use regex timeout to prevent ReDoS on pathological view definitions
            $regexTimeout = [timespan]::FromSeconds(2)
            try {
                $jsonRegex = [regex]::new('(?:JSON_VALUE|JSON_QUERY|OPENJSON|ISJSON)\s*\(\s*(?:\w+\.)?\[?(\w+)\]?', 'IgnoreCase', $regexTimeout)
                $jsonMatches = $jsonRegex.Matches($viewDef)
                foreach ($m in $jsonMatches) {
                    $detectedCol = $m.Groups[1].Value
                    $hintKey = "$tableKey.$detectedCol"
                    if (-not $viewColumnHints.ContainsKey($hintKey)) {
                        $viewColumnHints[$hintKey] = @{ ViewDefinitions = [System.Collections.Generic.List[string]]::new(); DetectedFormat = 'Json' }
                    }
                    if ($viewColumnHints[$hintKey].DetectedFormat -ne 'Json') { $viewColumnHints[$hintKey].DetectedFormat = 'Json' }
                    if (-not $viewColumnHints[$hintKey].ViewDefinitions.Contains($viewDef)) {
                        $viewColumnHints[$hintKey].ViewDefinitions.Add($viewDef)
                    }
                }

                # Detect XML parsing: col.value(...), col.query(...), col.nodes(...), col.exist(...)
                $xmlRegex = [regex]::new('(?:\w+\.)?\[?(\w+)\]?\s*\.\s*(?:value|query|nodes|exist|modify)\s*\(', 'IgnoreCase', $regexTimeout)
                $xmlMatches = $xmlRegex.Matches($viewDef)
                foreach ($m in $xmlMatches) {
                    $detectedCol = $m.Groups[1].Value
                    $hintKey = "$tableKey.$detectedCol"
                    if (-not $viewColumnHints.ContainsKey($hintKey)) {
                        $viewColumnHints[$hintKey] = @{ ViewDefinitions = [System.Collections.Generic.List[string]]::new(); DetectedFormat = 'Xml' }
                    }
                    if ($viewColumnHints[$hintKey].DetectedFormat -ne 'Xml') { $viewColumnHints[$hintKey].DetectedFormat = 'Xml' }
                    if (-not $viewColumnHints[$hintKey].ViewDefinitions.Contains($viewDef)) {
                        $viewColumnHints[$hintKey].ViewDefinitions.Add($viewDef)
                    }
                }

                # Detect CAST/CONVERT to xml: CAST(col AS xml), CONVERT(xml, col)
                $castXmlRegex = [regex]::new('CAST\s*\(\s*(?:\w+\.)?\[?(\w+)\]?\s+AS\s+xml\s*\)', 'IgnoreCase', $regexTimeout)
                $castXmlMatches = $castXmlRegex.Matches($viewDef)
                foreach ($m in $castXmlMatches) {
                    $detectedCol = $m.Groups[1].Value
                    $hintKey = "$tableKey.$detectedCol"
                    if (-not $viewColumnHints.ContainsKey($hintKey)) {
                        $viewColumnHints[$hintKey] = @{ ViewDefinitions = [System.Collections.Generic.List[string]]::new(); DetectedFormat = 'Xml' }
                    }
                    if (-not $viewColumnHints[$hintKey].ViewDefinitions.Contains($viewDef)) {
                        $viewColumnHints[$hintKey].ViewDefinitions.Add($viewDef)
                    }
                }
            }
            catch [System.Text.RegularExpressions.RegexMatchTimeoutException] {
                Write-PSFMessage -Level Warning -Message ($script:strings.'Schema.ViewRegexTimeout' -f $tableKey)
            }
        }
    }

    foreach ($tableRow in @($Tables)) {
        $schemaName = [string]$tableRow.TABLE_SCHEMA
        $tableName = [string]$tableRow.TABLE_NAME

        # Apply filters in PowerShell (no SQL injection risk)
        if ($SchemaFilter -and $schemaName -notin $SchemaFilter) { continue }
        if ($TableFilter -and $tableName -notin $TableFilter) { continue }

        # Build column list for this table
        $tableColumns = [System.Collections.Generic.List[object]]::new()
        $tableKey = "$schemaName.$tableName"
        $tableColRows = if ($columnIndex.ContainsKey($tableKey)) { $columnIndex[$tableKey] } else { @() }
        foreach ($colRow in $tableColRows) {

            $colName = [string]$colRow.COLUMN_NAME

            # Check PK / Unique via pre-built index
            $isPK = $false
            $isUnique = $false
            $ucKey = "$schemaName.$tableName.$colName"
            if ($ucIndex.ContainsKey($ucKey)) {
                foreach ($ucRow in $ucIndex[$ucKey]) {
                    if ($ucRow.IsPrimaryKey -eq $true -or $ucRow.IsPrimaryKey -eq 1) { $isPK = $true }
                    if ($ucRow.IsUnique -eq $true -or $ucRow.IsUnique -eq 1) { $isUnique = $true }
                }
            }

            # Check FK reference via pre-built index
            $fkRef = $null
            $tableFkRows = if ($fkByParent.ContainsKey($tableKey)) { $fkByParent[$tableKey] } else { @() }
            foreach ($fkRow in $tableFkRows) {
                if ([string]$fkRow.ParentColumn -eq $colName) {
                    $fkRef = [SqlLabDataGenerator.ForeignKeyRef]@{
                        ForeignKeyName   = [string]$fkRow.ForeignKeyName
                        ReferencedSchema = [string]$fkRow.ReferencedSchema
                        ReferencedTable  = [string]$fkRow.ReferencedTable
                        ReferencedColumn = [string]$fkRow.ReferencedColumn
                    }
                    break
                }
            }

            # Check constraints via pre-built index
            $checks = @()
            $ccKey = "$schemaName.$tableName.$colName"
            if ($ccIndex.ContainsKey($ccKey)) {
                foreach ($ccRow in $ccIndex[$ccKey]) {
                    $checks += [string]$ccRow.ConstraintDefinition
                }
            }

            # Detect structured data columns via view analysis and data type
            $schemaHint = $null
            $viewDetectedFormat = $null
            $colDataType = ([string]$colRow.DATA_TYPE).ToLower()
            $hintKey = "$tableKey.$colName"

            # 1. Check if any view actively parses this column as JSON/XML
            if ($viewColumnHints.ContainsKey($hintKey)) {
                $hint = $viewColumnHints[$hintKey]
                $viewDetectedFormat = $hint.DetectedFormat
                $schemaHint = ($hint.ViewDefinitions | Select-Object -First 3) -join "`n---`n"
            }
            # 2. For xml-typed or nvarchar(max) columns, also attach any view that mentions the column
            elseif ($colDataType -eq 'xml' -or ($colDataType -in @('nvarchar', 'varchar', 'ntext', 'text') -and ($colRow.CHARACTER_MAXIMUM_LENGTH -is [DBNull] -or $colRow.CHARACTER_MAXIMUM_LENGTH -eq -1))) {
                if ($viewHintIndex.ContainsKey($tableKey)) {
                    $escapedCol = [regex]::Escape($colName)
                    $relevantViews = $viewHintIndex[$tableKey] | Where-Object { $_ -match $escapedCol }
                    if ($relevantViews) {
                        $schemaHint = ($relevantViews | Select-Object -First 3) -join "`n---`n"
                    }
                }
            }

            $column = [SqlLabDataGenerator.ColumnInfo]@{
                ColumnName         = $colName
                DataType           = [string]$colRow.DATA_TYPE
                MaxLength          = if ($colRow.CHARACTER_MAXIMUM_LENGTH -is [DBNull]) { $null } else { $colRow.CHARACTER_MAXIMUM_LENGTH }
                NumericPrecision   = if ($colRow.NUMERIC_PRECISION -is [DBNull]) { $null } else { $colRow.NUMERIC_PRECISION }
                NumericScale       = if ($colRow.NUMERIC_SCALE -is [DBNull]) { $null } else { $colRow.NUMERIC_SCALE }
                IsNullable         = $colRow.IS_NULLABLE -eq 'YES'
                DefaultValue       = if ($colRow.COLUMN_DEFAULT -is [DBNull]) { $null } else { [string]$colRow.COLUMN_DEFAULT }
                OrdinalPosition    = [int]$colRow.ORDINAL_POSITION
                IsIdentity         = [bool]($colRow.IsIdentity -eq 1)
                IsComputed         = [bool]($colRow.IsComputed -eq 1)
                IsPrimaryKey       = $isPK
                IsUnique           = $isUnique
                ForeignKey         = $fkRef
                CheckConstraints   = $checks
                SchemaHint         = $schemaHint
                ViewDetectedFormat = $viewDetectedFormat
                SemanticType       = $null
                Classification     = $null
                GenerationRule     = $null
            }
            $tableColumns.Add($column)
        }

        # Build FK list for this table from pre-built index
        $tableFKs = [System.Collections.Generic.List[object]]::new()
        $fkRowsForTable = if ($fkByParent.ContainsKey($tableKey)) { $fkByParent[$tableKey] } else { @() }
        foreach ($fkRow in $fkRowsForTable) {
            $tableFKs.Add([SqlLabDataGenerator.ForeignKeyInfo]@{
                        ForeignKeyName   = [string]$fkRow.ForeignKeyName
                        ParentSchema     = [string]$fkRow.ParentSchema
                        ParentTable      = [string]$fkRow.ParentTable
                        ParentColumn     = [string]$fkRow.ParentColumn
                        ReferencedSchema = [string]$fkRow.ReferencedSchema
                        ReferencedTable  = [string]$fkRow.ReferencedTable
                        ReferencedColumn = [string]$fkRow.ReferencedColumn
                    })
        }

        $table = [SqlLabDataGenerator.TableInfo]@{
            SchemaName  = $schemaName
            TableName   = $tableName
            FullName    = "$schemaName.$tableName"
            Columns     = $tableColumns.ToArray()
            ForeignKeys = $tableFKs.ToArray()
            ColumnCount = $tableColumns.Count
        }
        $tableList.Add($table)
    }

    [SqlLabDataGenerator.SchemaModel]@{
        Database     = $Database
        Tables       = $tableList.ToArray()
        TableCount   = $tableList.Count
        DiscoveredAt = Get-Date
    }
}