param(
    [string]$Server = $env:ONEC_SQL_SERVER,
    [string]$Database = $env:ONEC_SQL_DATABASE,
    [string]$User = $env:ONEC_SQL_USER,
    [string]$Password = $env:ONEC_SQL_PASSWORD,
    [string]$ProjectionPath,
    [string]$OutputPath,
    [switch]$SkipTableParts
)

Set-StrictMode -Version Latest
$ErrorActionPreference = "Stop"

if (-not $Server) { throw "Server is required. Use -Server or ONEC_SQL_SERVER." }
if (-not $Database) { throw "Database is required. Use -Database or ONEC_SQL_DATABASE." }
if (-not $User) { throw "User is required. Use -User or ONEC_SQL_USER." }
if (-not $Password) { throw "Password is required. Use -Password or ONEC_SQL_PASSWORD." }
if (-not $ProjectionPath) { throw "ProjectionPath is required." }
if (-not $OutputPath) { throw "OutputPath is required." }

function Test-HasProperty {
    param(
        [object]$Object,
        [string]$Name
    )
    return $null -ne $Object -and ($Object.PSObject.Properties.Name -contains $Name)
}

function Convert-SqlValue {
    param([object]$Value)

    if ($null -eq $Value -or $Value -is [DBNull]) {
        return $null
    }
    if ($Value -is [byte[]]) {
        return [pscustomobject]@{
            kind = "binary"
            length = $Value.Length
            hex = ([BitConverter]::ToString($Value) -replace "-", "").ToLowerInvariant()
        }
    }
    if ($Value -is [DateTime]) {
        return $Value.ToString("o")
    }
    if ($Value -is [Guid]) {
        return $Value.ToString()
    }
    return $Value
}

function Quote-SqlIdentifier {
    param([string]$Name)

    return "[" + ($Name -replace "]", "]]") + "]"
}

function New-SelectSql {
    param(
        [string]$Table,
        [object[]]$Columns,
        [int]$Top
    )

    if (-not $Table) {
        return $null
    }

    $parts = New-Object System.Collections.Generic.List[string]
    foreach ($column in @($Columns)) {
        if (-not (Test-HasProperty $column "column")) { continue }
        if (-not $column.column) { continue }
        $alias = if ((Test-HasProperty $column "select_alias") -and $column.select_alias) { [string]$column.select_alias } else { [string]$column.column }
        $parts.Add("  $(Quote-SqlIdentifier ([string]$column.column)) AS $(Quote-SqlIdentifier $alias)")
    }

    $selectList = if ($parts.Count -gt 0) { [string]::Join(",`n", $parts.ToArray()) } else { "  *" }
    return "SELECT TOP ($Top)`n$selectList`nFROM $(Quote-SqlIdentifier $Table);"
}

function Get-ExistingColumns {
    param(
        [System.Data.SqlClient.SqlConnection]$Connection,
        [string]$Table
    )

    $set = [System.Collections.Generic.HashSet[string]]::new([System.StringComparer]::OrdinalIgnoreCase)
    if (-not $Table) {
        return $set
    }

    $command = $Connection.CreateCommand()
    $command.CommandText = "SELECT COLUMN_NAME FROM INFORMATION_SCHEMA.COLUMNS WHERE TABLE_NAME = @tableName;"
    $null = $command.Parameters.Add("@tableName", [System.Data.SqlDbType]::NVarChar, 256)
    $command.Parameters["@tableName"].Value = $Table
    $reader = $command.ExecuteReader()
    try {
        while ($reader.Read()) {
            $null = $set.Add([string]$reader.GetValue(0))
        }
    }
    finally {
        $reader.Close()
        $command.Dispose()
    }
    return $set
}

function Get-CandidateTables {
    param(
        [System.Data.SqlClient.SqlConnection]$Connection,
        [string]$BaseTable
    )

    $tables = New-Object System.Collections.Generic.List[string]
    if (-not $BaseTable) {
        return [string[]]$tables.ToArray()
    }

    $command = $Connection.CreateCommand()
    $command.CommandText = "SELECT TABLE_NAME FROM INFORMATION_SCHEMA.TABLES WHERE TABLE_TYPE = 'BASE TABLE';"
    $reader = $command.ExecuteReader()
    try {
        $pattern = "^" + [regex]::Escape($BaseTable) + "(X\d+)?$"
        while ($reader.Read()) {
            $table = [string]$reader.GetValue(0)
            if ($table -match $pattern) {
                $tables.Add($table)
            }
        }
    }
    finally {
        $reader.Close()
        $command.Dispose()
    }

    if (-not $tables.Contains($BaseTable)) {
        $tables.Add($BaseTable)
    }
    return [string[]]($tables.ToArray() | Sort-Object {
        if ($_ -eq $BaseTable) { 0 } elseif ($_ -match "X(\d+)$") { [int]$Matches[1] } else { 9999 }
    })
}

function Get-TableRowCount {
    param(
        [System.Data.SqlClient.SqlConnection]$Connection,
        [string]$Table
    )

    $command = $Connection.CreateCommand()
    $command.CommandText = "SELECT COUNT(*) FROM $(Quote-SqlIdentifier $Table);"
    try {
        return [int]$command.ExecuteScalar()
    }
    finally {
        $command.Dispose()
    }
}

function Select-EffectiveTable {
    param(
        [System.Data.SqlClient.SqlConnection]$Connection,
        [string]$BaseTable,
        [object[]]$Columns,
        [string]$TableView = "effective"
    )

    if ($TableView -eq "base") {
        return [pscustomobject]@{
            base_table = $BaseTable
            selected_table = $BaseTable
            matched_columns = 0
            requested_columns = @($Columns).Count
            row_count = Get-TableRowCount -Connection $Connection -Table $BaseTable
            is_companion = $false
            selection_mode = "base"
        }
    }

    $best = $null
    foreach ($table in @(Get-CandidateTables -Connection $Connection -BaseTable $BaseTable)) {
        $existing = Get-ExistingColumns -Connection $Connection -Table $table
        $matched = 0
        foreach ($column in @($Columns)) {
            if ((Test-HasProperty $column "column") -and $column.column -and $existing.Contains([string]$column.column)) {
                $matched += 1
            }
        }
        $rowCount = Get-TableRowCount -Connection $Connection -Table $table
        $candidate = [pscustomobject]@{
            base_table = $BaseTable
            selected_table = $table
            matched_columns = $matched
            requested_columns = @($Columns).Count
            row_count = $rowCount
            is_companion = $table -ne $BaseTable
            selection_mode = $TableView
        }
        if (
            $null -eq $best -or
            $candidate.matched_columns -gt $best.matched_columns -or
            ($candidate.matched_columns -eq $best.matched_columns -and $candidate.row_count -gt $best.row_count)
        ) {
            $best = $candidate
        }
    }
    return $best
}

function Resolve-ProjectionColumns {
    param(
        [System.Data.SqlClient.SqlConnection]$Connection,
        [string]$Table,
        [object[]]$Columns,
        [string]$Scope,
        [System.Collections.Generic.List[object]]$Diagnostics
    )

    $existing = Get-ExistingColumns -Connection $Connection -Table $Table
    $result = New-Object System.Collections.Generic.List[object]
    foreach ($column in @($Columns)) {
        if (-not (Test-HasProperty $column "column") -or -not $column.column) {
            $result.Add($column)
            continue
        }
        if ($existing.Contains([string]$column.column)) {
            $result.Add($column)
            continue
        }
        $Diagnostics.Add([pscustomobject]@{
            scope = $Scope
            table = $Table
            column = [string]$column.column
            metadata_path = if (Test-HasProperty $column "metadata_path") { $column.metadata_path } else { $null }
        })
    }
    return [object[]]$result.ToArray()
}

function New-ColumnMap {
    param([object[]]$Columns)

    $map = @{}
    foreach ($column in @($Columns)) {
        if (-not (Test-HasProperty $column "select_alias")) { continue }
        if (-not $column.select_alias) { continue }
        $map[[string]$column.select_alias] = $column
    }
    return $map
}

function New-Cell {
    param(
        [object]$ColumnInfo,
        [object]$Value
    )

    $cell = [ordered]@{
        alias = if (Test-HasProperty $ColumnInfo "select_alias") { $ColumnInfo.select_alias } else { $null }
        column = if (Test-HasProperty $ColumnInfo "column") { $ColumnInfo.column } else { $null }
        metadata_path = if (Test-HasProperty $ColumnInfo "metadata_path") { $ColumnInfo.metadata_path } else { $null }
        metadata_name = if (Test-HasProperty $ColumnInfo "metadata_name") { $ColumnInfo.metadata_name } else { $null }
        metadata_uuid = if (Test-HasProperty $ColumnInfo "metadata_uuid") { $ColumnInfo.metadata_uuid } else { $null }
        metadata_field = if (Test-HasProperty $ColumnInfo "metadata_field") { $ColumnInfo.metadata_field } else { $null }
        value_type = if (Test-HasProperty $ColumnInfo "value_type") { $ColumnInfo.value_type } else { $null }
        sql_type = if (Test-HasProperty $ColumnInfo "sql_type") { $ColumnInfo.sql_type } else { $null }
        value = Convert-SqlValue $Value
    }
    return [pscustomobject]$cell
}

function Invoke-ProjectionQuery {
    param(
        [System.Data.SqlClient.SqlConnection]$Connection,
        [string]$Sql,
        [object[]]$Columns
    )

    if (-not $Sql) {
        return [pscustomobject]@{
            row_count = 0
            columns = @($Columns)
            rows = @()
        }
    }

    $columnMap = New-ColumnMap $Columns
    $rows = New-Object System.Collections.Generic.List[object]
    $command = $Connection.CreateCommand()
    $command.CommandTimeout = 0
    $command.CommandText = $Sql

    $reader = $command.ExecuteReader()
    try {
        while ($reader.Read()) {
            $row = [ordered]@{}
            for ($i = 0; $i -lt $reader.FieldCount; $i++) {
                $alias = $reader.GetName($i)
                if ($columnMap.ContainsKey($alias)) {
                    $columnInfo = $columnMap[$alias]
                } else {
                    $columnInfo = [pscustomobject]@{
                        select_alias = $alias
                        column = $alias
                        metadata_path = $alias
                        metadata_name = $alias
                    }
                }

                $metadataPath = if (Test-HasProperty $columnInfo "metadata_path") { [string]$columnInfo.metadata_path } else { $alias }
                $physicalColumn = if (Test-HasProperty $columnInfo "column") { [string]$columnInfo.column } else { $alias }
                $key = "$metadataPath::$physicalColumn"
                $row[$key] = New-Cell $columnInfo $reader.GetValue($i)
            }
            $rows.Add([pscustomobject]$row)
        }
    }
    finally {
        $reader.Close()
        $command.Dispose()
    }

    $resultRows = $rows.ToArray()
    return [pscustomobject]@{
        row_count = [int]$resultRows.Count
        columns = [object[]]@($Columns)
        rows = [object[]]$resultRows
    }
}

$projection = Get-Content -LiteralPath $ProjectionPath -Raw -Encoding UTF8 | ConvertFrom-Json
$resolvedProjectionPath = (Resolve-Path -LiteralPath $ProjectionPath).ProviderPath
$connectionString = "Server=$Server;Database=$Database;User ID=$User;Password=$Password;Encrypt=False;TrustServerCertificate=True;Application Name=Codex 1C SQL Read Projection Executor;"
$connection = [System.Data.SqlClient.SqlConnection]::new($connectionString)

try {
    $connection.Open()
    $top = if (Test-HasProperty $projection "top") { [int]$projection.top } else { 10 }
    $tableView = if ((Test-HasProperty $projection "view") -and $projection.view -eq "base") { "base" } else { "effective" }
    $prunedColumns = [System.Collections.Generic.List[object]]::new()
    $effectiveTables = [System.Collections.Generic.List[object]]::new()
    $mainChoice = Select-EffectiveTable `
        -Connection $connection `
        -BaseTable $projection.main_table `
        -Columns @($projection.main_columns) `
        -TableView $tableView
    $effectiveTables.Add($mainChoice)
    $mainColumns = Resolve-ProjectionColumns `
        -Connection $connection `
        -Table $mainChoice.selected_table `
        -Columns @($projection.main_columns) `
        -Scope "main" `
        -Diagnostics $prunedColumns
    $mainSql = New-SelectSql -Table $mainChoice.selected_table -Columns @($mainColumns) -Top $top

    $main = Invoke-ProjectionQuery `
        -Connection $connection `
        -Sql $mainSql `
        -Columns @($mainColumns)

    $tableParts = New-Object System.Collections.Generic.List[object]
    if (-not $SkipTableParts) {
        foreach ($part in @($projection.table_parts)) {
            $partScope = "table_part:$($part.name)"
            $partChoice = Select-EffectiveTable `
                -Connection $connection `
                -BaseTable $part.table `
                -Columns @($part.columns) `
                -TableView $tableView
            $effectiveTables.Add($partChoice)
            $partColumns = Resolve-ProjectionColumns `
                -Connection $connection `
                -Table $partChoice.selected_table `
                -Columns @($part.columns) `
                -Scope $partScope `
                -Diagnostics $prunedColumns
            $partSql = New-SelectSql -Table $partChoice.selected_table -Columns @($partColumns) -Top $top
            $queryResult = Invoke-ProjectionQuery `
                -Connection $connection `
                -Sql $partSql `
                -Columns @($partColumns)
            $tableParts.Add([pscustomobject]@{
                name = $part.name
                uuid = $part.uuid
                table = $partChoice.selected_table
                base_table = $part.table
                row_count = $queryResult.row_count
                columns = $queryResult.columns
                rows = $queryResult.rows
            })
        }
    }

    $tablePartArray = [object[]]$tableParts.ToArray()
    $result = [ordered]@{
        schema = "onec_sql_read_result.v1"
        projection_schema = $projection.schema
        projection_path = $resolvedProjectionPath
        server = $Server
        database = $Database
        kind = $projection.kind
        identity = $projection.identity
        view = if (Test-HasProperty $projection "view") { $projection.view } else { $null }
        extension = if (Test-HasProperty $projection "extension") { $projection.extension } else { $null }
        main = [pscustomobject]@{
            table = $mainChoice.selected_table
            base_table = $projection.main_table
            row_count = $main.row_count
            columns = $main.columns
            rows = $main.rows
        }
        table_parts = $tablePartArray
        diagnostics = [pscustomobject]@{
            projection = if (Test-HasProperty $projection "diagnostics") { $projection.diagnostics } else { $null }
            pruned_missing_columns = [object[]]$prunedColumns.ToArray()
            effective_tables = [object[]]$effectiveTables.ToArray()
        }
    }

    $outputDirectory = Split-Path -Parent $OutputPath
    if ($outputDirectory) {
        New-Item -ItemType Directory -Force -Path $outputDirectory | Out-Null
    }
    $result | ConvertTo-Json -Depth 100 | Set-Content -LiteralPath $OutputPath -Encoding UTF8

    [pscustomobject]@{
        output = (Resolve-Path -LiteralPath $OutputPath).ProviderPath
        main_rows = $main.row_count
        table_parts = $tablePartArray.Count
        table_part_rows = [object[]]@($tablePartArray | ForEach-Object { [pscustomobject]@{ name = $_.name; table = $_.table; rows = $_.row_count } })
    } | ConvertTo-Json -Depth 10
}
finally {
    $connection.Close()
    $connection.Dispose()
}
