FITS.Psd1Utils.psm1

$MyInvocation.MyCommand.ScriptBlock.Module.OnRemove = {
    Write-Verbose "FITS.Psd1Utils: module removed."
}

# ─────────────────────────────────────────────────────────────────────────────
# Private helper — module scope, not re-created on every call
# ─────────────────────────────────────────────────────────────────────────────
function Format-PsdKey {
    param([string]$Key)
    if ($Key -match '\s') { return "`"$Key`"" }
    return $Key
}

# ─────────────────────────────────────────────────────────────────────────────
# ConvertTo-Psd1String
# ─────────────────────────────────────────────────────────────────────────────
function ConvertTo-Psd1String {
    <#
    .SYNOPSIS
        Serialises a PowerShell object to a PSD1-compatible string.

    .DESCRIPTION
        Recursively converts hashtables, ordered dictionaries, arrays, PSCustomObjects
        and scalar values to a formatted PSD1 string.
        Keys that contain whitespace are automatically quoted.
        Strings are double-quoted with proper backtick escaping.
        Array elements are comma-separated; the last element has no trailing comma.

    .PARAMETER InputObject
        The object to serialise.

    .PARAMETER Depth
        Current indentation depth. Used internally for recursion; leave at 0 for the root call.

    .PARAMETER KeyPadWidth
        Fixed padding width for hashtable keys. When 0 (default), the width is
        calculated automatically from the longest key in each block.

    .EXAMPLE
        $config = [ordered]@{ Name = 'Test'; Active = $true; Tags = @('a','b') }
        ConvertTo-Psd1String -InputObject $config

    .EXAMPLE
        $config | ConvertTo-Psd1String | Set-Content -Path config.psd1 -Encoding UTF8
    #>

    [CmdletBinding()]
    [OutputType([string])]
    param(
        [Parameter(Mandatory, Position = 0, ValueFromPipeline)]
        $InputObject,

        [Parameter()]
        [int] $Depth = 0,

        [Parameter()]
        [int] $KeyPadWidth = 0
    )

    process {
        $pad      = ' ' * $Depth
        $childPad = ' ' * ($Depth + 1)

        # ── Null ──────────────────────────────────────────────────────────────
        if ($null -eq $InputObject) { return '$null' }

        $type = $InputObject.GetType()

        # ── OrderedDictionary → [ordered]@{ } ───────────────────────────────
        if ($type.Name -eq 'OrderedDictionary') {
            $keyWidth = if ($KeyPadWidth -gt 0) { $KeyPadWidth }
                        else { ($InputObject.Keys | ForEach-Object { (Format-PsdKey $_).Length } |
                                Measure-Object -Maximum).Maximum }
            $lines = @('[ordered]@{')
            foreach ($key in $InputObject.Keys) {
                $fKey  = Format-PsdKey $key
                $val   = ConvertTo-Psd1String -InputObject $InputObject[$key] -Depth ($Depth + 1)
                $lines += "$childPad$($fKey.PadRight($keyWidth)) = $val"
            }
            $lines += "$pad}"
            return $lines -join "`n"
        }

        # ── Any other IDictionary → @{ } ────────────────────────────────────
        if ($type.GetInterfaces() -contains [System.Collections.IDictionary]) {
            $keyWidth = if ($KeyPadWidth -gt 0) { $KeyPadWidth }
                        else { ($InputObject.Keys | ForEach-Object { (Format-PsdKey $_).Length } |
                                Measure-Object -Maximum).Maximum }
            $lines = @('@{')
            foreach ($key in $InputObject.Keys) {
                $fKey  = Format-PsdKey $key
                $val   = ConvertTo-Psd1String -InputObject $InputObject[$key] -Depth ($Depth + 1)
                $lines += "$childPad$($fKey.PadRight($keyWidth)) = $val"
            }
            $lines += "$pad}"
            return $lines -join "`n"
        }

        # ── PSCustomObject → [ordered]@{ } ──────────────────────────────────
        if ($type.Name -eq 'PSCustomObject') {
            $props    = $InputObject | Get-Member -MemberType NoteProperty
            $keyWidth = if ($KeyPadWidth -gt 0) { $KeyPadWidth }
                        else { ($props.Name | ForEach-Object { (Format-PsdKey $_).Length } |
                                Measure-Object -Maximum).Maximum }
            $lines = @('[ordered]@{')
            foreach ($prop in $props) {
                $fKey  = Format-PsdKey $prop.Name
                $val   = ConvertTo-Psd1String -InputObject $InputObject.$($prop.Name) -Depth ($Depth + 1)
                $lines += "$childPad$($fKey.PadRight($keyWidth)) = $val"
            }
            $lines += "$pad}"
            return $lines -join "`n"
        }

        # ── Array / IEnumerable → @( ) with comma separation ────────────────
        if ($type.IsArray -or
           ($InputObject -is [System.Collections.IEnumerable] -and $InputObject -isnot [string])) {
            $items = @($InputObject)
            if ($items.Count -eq 0) { return '@()' }
            $lines = @('@(')
            for ($i = 0; $i -lt $items.Count; $i++) {
                $val   = ConvertTo-Psd1String -InputObject $items[$i] -Depth ($Depth + 1)
                $comma = if ($i -lt $items.Count - 1) { ',' } else { '' }
                $lines += "$childPad$val$comma"
            }
            $lines += "$pad)"
            return $lines -join "`n"
        }

        # ── Boolean ───────────────────────────────────────────────────────────
        if ($type.Name -eq 'Boolean') {
            return (if($InputObject){ '$true'} else {'$false'})
        }

        # ── Numeric primitives ────────────────────────────────────────────────
        if ($type.IsPrimitive -and $type.Name -notin @('Boolean', 'Char')) {
            return "$InputObject"
        }

        # ── Fallback: String — double-quoted with backtick escaping ───────────
        # Escaping order matters: backtick first, then quote, then dollar
        $escaped = "$InputObject" -replace '`',  '``' `
                                  -replace '"',  '`"' `
                                  -replace '\$', '`$'
        return "`"$escaped`""
    }
}

# ─────────────────────────────────────────────────────────────────────────────
# Test-Psd1Content
# ─────────────────────────────────────────────────────────────────────────────
function Test-Psd1Content {
    <#
    .SYNOPSIS
        Validates PSD1/PS1 data file content using AST analysis before execution.

    .DESCRIPTION
        Parses the content using PowerShell's built-in AST parser without executing it,
        then walks the syntax tree looking for constructs that have no place in a pure
        data file: commands, method calls, scriptblock expressions, subexpressions,
        function definitions, and type casts to non-allowlisted types.

        Two modes:
          Default (strict) — pure data only: hashtables, arrays, literals, safe casts.
          -AllowVariables — additionally permits variable assignments and references,
                             and the + operator. Required for composite-set files like
                             CharacterSets.ps1 from FITS.StringUtils.

    .PARAMETER Content
        The string content to validate.

    .PARAMETER AllowVariables
        Permits variable assignments ($x = ...) and binary + expressions.
        Use for data files that build composite structures from named variables.

    .PARAMETER AllowedTypeNames
        Type names permitted in cast expressions like [ordered]@{} or [char]0x20.
        Defaults to a safe set of primitive and collection types.

    .OUTPUTS
        [bool] — $true if the content is considered safe, $false otherwise.

    .EXAMPLE
        $content = Get-Content config.psd1 -Raw
        if (Test-Psd1Content -Content $content) {
            $data = & ([scriptblock]::Create($content))
        }

    .EXAMPLE
        # For CharacterSets.ps1 with variable assignments
        Test-Psd1Content -Content $content -AllowVariables
    #>

    [CmdletBinding()]
    [OutputType([bool])]
    param(
        [Parameter(Mandatory, Position = 0, ValueFromPipeline)]
        [string] $Content,

        [switch] $AllowVariables,

        [string[]] $AllowedTypeNames = @(
            'ordered',
            'char',
            'string',   'string[]',
            'int',      'int32',    'int64',
            'long',     'uint32',   'uint64',
            'double',   'decimal',  'float',  'single',
            'bool',     'boolean',
            'byte',     'sbyte'
        )
    )

    process {
        # ── Step 1: Parse without executing ───────────────────────────────────
        $parseErrors = $null
        $tokens      = $null
        $ast = [System.Management.Automation.Language.Parser]::ParseInput(
            $Content, [ref]$tokens, [ref]$parseErrors
        )

        if ($parseErrors.Count -gt 0) {
            foreach ($e in $parseErrors) {
                Write-Warning "Parse error at line $($e.Extent.StartLineNumber): $($e.Message)"
            }
            return $false
        }

        # ── Step 2: Walk AST — collect all dangerous nodes ─────────────────────
        $allowedTypeSet = [System.Collections.Generic.HashSet[string]]::new(
            [string[]]($AllowedTypeNames | ForEach-Object { $_.ToLower() }),
            [System.StringComparer]::OrdinalIgnoreCase
        )

        $violations = [System.Collections.Generic.List[string]]::new()

        $null = $ast.FindAll({
            param($node)

            # Cmdlet / external command calls
            if ($node -is [System.Management.Automation.Language.CommandAst]) {
                $violations.Add("Command call at line $($node.Extent.StartLineNumber): $($node.Extent.Text)")
                return $false
            }

            # Method invocations: $obj.Method() or [Type]::StaticMethod()
            if ($node -is [System.Management.Automation.Language.InvocationExpressionAst]) {
                $violations.Add("Method invocation at line $($node.Extent.StartLineNumber): $($node.Extent.Text)")
                return $false
            }

            # Static/instance member access: $obj.Property or [Type]::Member
            if ($node -is [System.Management.Automation.Language.MemberExpressionAst]) {
                $violations.Add("Member access at line $($node.Extent.StartLineNumber): $($node.Extent.Text)")
                return $false
            }

            # Embedded scriptblocks: { ... }
            if ($node -is [System.Management.Automation.Language.ScriptBlockExpressionAst]) {
                $violations.Add("Scriptblock expression at line $($node.Extent.StartLineNumber)")
                return $false
            }

            # Function / filter definitions
            if ($node -is [System.Management.Automation.Language.FunctionDefinitionAst]) {
                $violations.Add("Function definition at line $($node.Extent.StartLineNumber): $($node.Name)")
                return $false
            }

            # $( ) subexpressions
            if ($node -is [System.Management.Automation.Language.SubExpressionAst]) {
                $violations.Add("Subexpression at line $($node.Extent.StartLineNumber)")
                return $false
            }

            # Expandable strings with live variable or expression parts: "hello $world"
            if ($node -is [System.Management.Automation.Language.ExpandableStringExpressionAst]) {
                $liveParts = $node.NestedExpressions | Where-Object {
                    $_ -isnot [System.Management.Automation.Language.StringConstantExpressionAst] -and
                    $_ -isnot [System.Management.Automation.Language.ConstantExpressionAst]
                }
                if ($liveParts) {
                    $violations.Add("Variable expansion in string at line $($node.Extent.StartLineNumber): $($node.Extent.Text)")
                    return $false
                }
            }

            # Type casts — only allowlisted types permitted
            if ($node -is [System.Management.Automation.Language.ConvertExpressionAst]) {
                $typeName = $node.Type.TypeName.FullName
                if (-not $allowedTypeSet.Contains($typeName)) {
                    $violations.Add("Disallowed type cast [$typeName] at line $($node.Extent.StartLineNumber)")
                    return $false
                }
            }

            # Variable assignments — blocked in strict mode
            if (-not $AllowVariables -and
                $node -is [System.Management.Automation.Language.AssignmentStatementAst]) {
                $violations.Add("Variable assignment at line $($node.Extent.StartLineNumber): $($node.Extent.Text)")
                return $false
            }

            # Variable references other than $true/$false/$null — blocked in strict mode
            if (-not $AllowVariables -and
                $node -is [System.Management.Automation.Language.VariableExpressionAst]) {
                $varName = $node.VariablePath.UserPath.ToLower()
                if ($varName -notin @('true', 'false', 'null')) {
                    $violations.Add("Variable reference at line $($node.Extent.StartLineNumber): `$$($node.VariablePath.UserPath)")
                    return $false
                }
            }

            # Binary expressions other than + — blocked even with -AllowVariables
            if ($node -is [System.Management.Automation.Language.BinaryExpressionAst]) {
                if ($node.Operator -ne [System.Management.Automation.Language.TokenKind]::Plus) {
                    $violations.Add("Disallowed operator '$($node.Operator)' at line $($node.Extent.StartLineNumber)")
                    return $false
                }
            }

            return $false
        }, $true)

        if ($violations.Count -gt 0) {
            foreach ($v in $violations) { Write-Warning "Sanitization: $v" }
            return $false
        }

        return $true
    }
}

# ─────────────────────────────────────────────────────────────────────────────
# Read-Psd1File
# ─────────────────────────────────────────────────────────────────────────────
function Read-Psd1File {
    <#
    .SYNOPSIS
        Reads and optionally sanitizes a PSD1/PS1 data file, then executes it.

    .DESCRIPTION
        Convenience wrapper that combines Test-Psd1Content with the
        [scriptblock]::Create() execution pattern.
        By default, the content is sanitized before execution.
        Use -SkipSanitization only in fully trusted environments.

    .PARAMETER Path
        Path to the PSD1 or PS1 data file.

    .PARAMETER AllowVariables
        Passed through to Test-Psd1Content. Permits variable assignments and +.
        Required for files like CharacterSets.ps1 that build composite arrays.

    .PARAMETER SkipSanitization
        Bypasses Test-Psd1Content entirely. Not recommended.

    .EXAMPLE
        $config = Read-Psd1File -Path "$PSScriptRoot\Config\DFLConfig.psd1"

    .EXAMPLE
        $sets = Read-Psd1File -Path "$PSScriptRoot\Data\CharacterSets.ps1" -AllowVariables
    #>

    [CmdletBinding()]
    param(
        [Parameter(Mandatory, Position = 0)]
        [ValidateScript({ Test-Path $_ -PathType Leaf })]
        [string] $Path,

        [switch] $AllowVariables,
        [switch] $SkipSanitization
    )

    $content = Get-Content -LiteralPath $Path -Encoding UTF8 -Raw

    if (-not $SkipSanitization) {
        $sanitizeParams = @{ Content = $content }
        if ($AllowVariables) { $sanitizeParams['AllowVariables'] = $true }

        if (-not (Test-Psd1Content @sanitizeParams)) {
            throw "Content of '$Path' failed sanitization check. Execution aborted."
        }
        Write-Verbose "Sanitization passed: $Path"
    }

    return & ([scriptblock]::Create($content))
}