classes/ps-obfuscator.psm1

using namespace System.Management.Automation.Language


Class VarInfo {    
    [string]$Name    
    [string]$FullName    
    [string]$OriginalName    
    [string]$Scope    
    [bool]$IsScoped    
    [bool]$IsSplatted    
    [Ast]$Ast    
    [ScriptBlockAst]$ScriptBlockAst    
    [bool]$IsParameter    
    [string]$ObfuscatedName=""

    VarInfo (
        [string]$Name,
        [string]$FullName,
        [string]$OriginalName,
        [string]$Scope,
        [bool]$IsScoped,
        [bool]$IsSplatted,
        [bool]$IsParameter,
        [Ast]$Ast,
        [ScriptBlockAst]$ScriptBlockAst
    ) {
        $this.Name = $Name
        $this.FullName = $FullName
        $this.OriginalName = $OriginalName
        $this.Scope = $Scope
        $this.IsScoped = $IsScoped
        $this.IsSplatted = $IsSplatted
        $this.IsParameter = $IsParameter
        $this.Ast = $Ast
        $this.ScriptBlockAst = $ScriptBlockAst
        $this.ObfuscatedName = $OriginalName
    }
}
Class AstModel {
    [hashtable]$builtinVars
    [hashtable]$builtinFuncs
    [Ast]$ast
    [hashtable]$astMap

    AstModel([string]$Path) {
        $this.builtinVars = $this.GetBuiltinVariables()
        $this.builtinFuncs = $this.GetBuiltinFunctions()
        $this.ast = $this.FileToAst($Path)
        $this.astMap = $this.GetAstHierarchyMap($this.ast)
    }
    
    [Ast]FileToAst([string]$Path) {
        if (-not (Test-Path $Path)) { throw "File not found: $Path" }

        $script = Get-Content -Raw -LiteralPath $Path
        return $this.ScriptToAst($script)
    }

    [Ast]ScriptToAst([string]$script) {
        $errors = $null
        $scriptAst = [Parser]::ParseInput($script, [ref]$null, [ref]$errors)
        if ($errors) { throw "Parsing failed" }

        return $scriptAst
    }
                
    [System.Collections.Specialized.OrderedDictionary]GetAstHierarchyMap([Ast]$rootAst) {
        $map = [ordered]@{}

        $items = $rootAst.FindAll( { $true }, $true)
        foreach ($item in $items) {
            if (-not $item.Parent) { continue }
            $parent = $item.Parent
            if (-not $map.Contains($parent)) { $map[$parent] = [System.Collections.ArrayList]@() }
            [void]$map[$parent].Add($item)
        }

        return $map
    }
    
    [System.Collections.ArrayList]FindAstChildrenByType(        
        [System.Management.Automation.Language.Ast]$Ast,                         
        [Type]$ChildType = $null,        
        [string]$Select = "firstChildren",        
        [Type]$UntilType = $null

    ) {
        $result = [System.Collections.ArrayList]::new()

        function Recurse($current) {
            if (-not $this.astMap.Contains($current)) { return }
    
            foreach ($child in $this.astMap[$current]) {
                if ($UntilType -and $child -is $UntilType) { continue }
            
                if (-not $ChildType -or $child -is $ChildType) {
                    [void]$result.Add($child)
                    if ($Select -eq "firstChildren") { continue }
                }

                if ($Select -eq "directChildren") { continue }
                Recurse $child
            }
        }

        Recurse $Ast
        return $result
    }
    
    [VarInfo]GetAstVariableInfo(
        [VariableExpressionAst]$varExpressionAst,
        [ScriptBlockAst]$parentScriptBlockAst

    ) {

        if (-not ($varExpressionAst -is [VariableExpressionAst]) ) { throw "Expected VariableExpressionAst" }
        
        $root = -not $parentScriptBlockAst.Parent
    
        $params = $varExpressionAst.VariablePath

        if (-not $params.IsVariable -or $params.IsDriveQualified -or $this.BuiltinVars.ContainsKey($varExpressionAst.VariablePath.UserPath)) { return $null }

        $originalName = $params.UserPath 

        $scope = "local"
        $name = $originalName
        if (-not $params.IsUnscopedVariable ) {
            if ($params.IsGlobal) { $scope = "global" }
            elseif ($params.IsScript) { $scope = "script" }
            elseif ($params.IsPrivate) { $scope = "private" }

            $name = $originalName.Split(":", 2)[1]
        }
        elseif ($root) { $scope = 'script' }

        return [VarInfo]::new(
            $name, 
            "$($scope):$name", 
            $originalName, 
            $scope, 
            -not $params.IsUnscopedVariable, 
            $varExpressionAst.Splatted, 
            $varExpressionAst.Parent -is [ParameterAst], 
            $varExpressionAst, 
            $parentScriptBlockAst 

        )
    }

    [Ast]GetAstParentByType([Ast]$Ast, [Type]$Type) {
        $current = $Ast.Parent
        while ($current -and -not ($current -is $Type)) {
            $current = $current.Parent
        }

        return $current
    }

    [ScriptBlockAst]GetAstParentScriptBlock([Ast]$Ast) {
        return $this.GetAstParentByType($Ast, [ScriptBlockAst])
    }

    [ScriptBlockAst]GetAstRootScripBlock([Ast]$Ast) {
        if (-not $Ast) { return $null }
        if (-not $Ast.Parent) {
            if ($Ast -is [ScriptBlockAst]) { return $Ast }
            return $null
        }
        return $this.GetAstRootScripBlock($Ast.Parent)
    }

    [hashtable]GetBuiltinFunctions() {
        $runspace = [runspacefactory]::CreateRunspace()
        $runspace.Open()

        $ps = [powershell]::Create()
        $ps.Runspace = $runspace

        $funcs = $ps.AddScript('Get-Command -CommandType Function | Select-Object -ExpandProperty Name').Invoke()

        $ps.Dispose()
        $runspace.Close()
        $runspace.Dispose()

        $cleanFuncs = $funcs | ForEach-Object { $_ -replace '^[A-Z]:', '' }
        $ht = @{}
        foreach ($f in $cleanFuncs) { $ht[$f] = $true }

        return $ht
    }

    [hashtable]GetBuiltinVariables() {
        $runspace = [runspacefactory]::CreateRunspace()
        $runspace.Open()

        $ps = [powershell]::Create()
        $ps.Runspace = $runspace

        $vars = $ps.AddScript('Get-Variable | Select-Object -ExpandProperty Name').Invoke()

        $ps.Dispose()
        $runspace.Close()
        $runspace.Dispose()

        $ht = @{}
        foreach ($v in $vars) { $ht[$v] = $true }

        $ht['_'] = $true
        $ht['s'] = $true
        $ht['p'] = $true
        $ht['PSItem'] = $true
        $ht['sender'] = $true
        $ht['this'] = $true
        $ht['form'] = $true 
        $ht['PSCmdlet'] = $true
        $ht['LASTEXITCODE'] = $true
        $ht['Profile'] = $true
        $ht['matches'] = $true

        return $ht
    }
}
Class VarsNameGenerator {
    $usedMap = @{}
    $builtinNames = @{}
    $words = @(
        'Id', 'Object', 'Value', 'Table', 'Data', 'Item', 'Row', 'Cell', 'Index', 'Count', 'Filter',
        'Field', 'List', 'Array', 'Node', 'Key', 'Param', 'Entry', 'Type', 'Name', 'Source',
        'Target', 'Range', 'Size', 'Length', 'Result', 'Status', 'Option', 'Group', 'Parent', 'Child',
        'Column', 'Header', 'Record', 'Recordset', 'Buffer', 'Stream', 'Stack', 'Queue', 'Cache', 'Token',
        'Session', 'Context', 'Handle', 'Path', 'File', 'Folder', 'Driver', 'Engine', 'Query', 'Script',
        'Config', 'Method', 'Event', 'Action', 'Command', 'Process', 'Task', 'Thread', 'Lock', 'Flag',
        'Error', 'Message', 'Report', 'Log', 'Trace', 'Level', 'State', 'Mode', 'Format', 'Output',
        'Input', 'Source', 'Destination', 'Time', 'Date', 'Count', 'Limit', 'Offset', 'Filter', 'Pattern',
        'Value', 'Key', 'Index', 'Type', 'Status', 'Version', 'Option', 'Setting', 'Profile', 'Client'
    )

    VarsNameGenerator([hashtable]$builtinNames) {
        $this.builtinNames = $builtinNames
    }

    [string]GetObfuscatedName([string]$Mode) {
        if ($Mode -ieq "Natural") { return $this.GetNaturalName() }
        else { return $this.GetHardName() }
    }

    [string]GetNaturalName() {
        $new = ""

        while (-not $new -or $this.usedMap.ContainsKey($new)) {            
            $count = Get-Random -Minimum 3 -Maximum 5
            $chosen = Get-Random -InputObject $this.words -Count $count
            $new = ($chosen -join '')            
            if ($this.builtinNames.ContainsKey($new)) { $new = "" }
        }
        $this.usedMap[$new] = $true
        return $new
    }


    [string]GetHardName() {
        $chars = @()
        $lChars = [char[]]'abcdefghijklmnopqrstuvwxyz'
        $uChars = [char[]]'ABCDEFGHIJKLMNOPQRSTUVWXYZ'
        $nChars = [char[]]'0123456789'
        $chars += $lChars
        $chars += $uChars
        $chars += $nChars

        $new = ""

        while (-not $new -or $this.usedMap.ContainsKey($new)) {
            $len = Get-Random -Minimum 7 -Maximum 20
            
            $firstChar = Get-Random -InputObject ($lChars + $uChars)

            $rest = ""
            for ($i = 1; $i -lt $len; $i++) {
                $rest += (Get-Random -InputObject $chars)
            }

            $new = "$firstChar$rest"

            if ($this.builtinNames.ContainsKey($new)) { $new = "" }
        }

        $this.usedMap[$new] = $true
        return $new
    }
}
Class VarsMapper {
    [hashtable]$config
    [AstModel]$astModel
    [VarsNameGenerator]$nameGenerator
    

    VarsMapper (
        [hashtable]$config,
        [AstModel]$astModel
    ) {
        $this.config = $config
        $this.astModel = $AstModel
        $this.nameGenerator = [VarsNameGenerator]::new($this.astModel.BuiltinVars)
    }

    [hashtable]GetMap() {
        Write-Host " Generating variables map..." -ForegroundColor Green
        $varsMap = @{}
        $this.FillAssignmentsMap($this.astModel.ast, $this.astModel.ast, $varsMap)
        Write-Host " Variables map generated. Found variable assignments: $($varsMap.Keys.Count)" -ForegroundColor Green
        return $varsMap
    }

    [void]FillAssignmentsMap(
        [ScriptBlockAst]$SbAst,
        [ScriptBlockAst]$RootSbAst,
        [hashtable]$VarsMap
    ) {

        if (-not $SbAst -is [ScriptBlockAst]) { throw "Expected ScriptBlockAst" }
                
        $varsInfo = $this.GetAssignmentsInfo($SbAst, $RootSbAst, $VarsMap)
        $VarsMap[$SbAst] = $varsInfo

        [ScriptBlockAst[]]$childrenSb = $this.astModel.FindAstChildrenByType($SbAst, [ScriptBlockAst], "firstChildren", $null)
            
        foreach ($childSb in $childrenSb) {
            $this.FillAssignmentsMap($childSb, $RootSbAst, $VarsMap)
        }
    }
    
    [hashtable]GetAssignmentsInfo(
        [ScriptBlockAst]$SbAst,
        [ScriptBlockAst]$RootSbAst,
        [hashtable]$VarsMap
    ) {

        if (-not $sbAst -is [ScriptBlockAst]) { throw "Expected ScriptBlockAst" }

        $assignments = @()
        
        if ($sbAst.Parent -is [FunctionDefinitionAst]) {
            $funcAst = $sbAst.Parent
            if ($funcAst.Parameters) { $assignments += $funcAst.Parameters }
        }
                
        $paramAssignments = $sbAst.FindAll({ 
                param($node)
                $node -is [ParameterAst]
            }, $false)

        if ($paramAssignments -and $paramAssignments.Count -gt 0) { $assignments += $paramAssignments }
        
        $statementsAssignments = $sbAst.FindAll({ 
                param($node)
                ($node -is [AssignmentStatementAst]) -or ($node -is [ForEachStatementAst]) -or ($node -is [ForStatementAst])
            }, $false) 
        if ($statementsAssignments -and $statementsAssignments.Count -gt 0) { $assignments += $statementsAssignments }

        $varsInfo = @{}

        foreach ($assignment in $assignments) {
            $varAst = $null
            if ($assignment -is [ParameterAst]) { $varAst = $assignment.Name }
            elseif ($assignment -is [AssignmentStatementAst]) { $varAst = $assignment.Left }
            elseif ($assignment -is [ForEachStatementAst]) { $varAst = $assignment.Variable }
            elseif ($assignment -is [ForStatementAst]) { $varAst = $assignment.Initializer.Left }
            
            if ($varAst -is [ConvertExpressionAst]) { $varAst = $varAst.Child }
            
            if ($varAst -isnot [VariableExpressionAst]) { continue }

            $varInfo = $this.astModel.GetAstVariableInfo($varAst, $SbAst)
            
            if (-not $varInfo -or $varsInfo.ContainsKey($varInfo.FullName)) { continue }
            
            if ($this.config.VarsExclude -contains $varInfo.OriginalName) { continue }
            
            if ($SbAst -ne $RootSbAst -and $varInfo.IsScoped -and ($varInfo.Scope -eq "script" -or $varInfo.Scope -eq "global")) {
                if (-not $VarsMap.ContainsKey($RootSbAst)) { $VarsMap[$RootSbAst] = @{} }
                
                $varInfo.ObfuscatedName = ($this.nameGenerator.GetObfuscatedName($this.config.Mode))
                if (-not $VarsMap[$RootSbAst].ContainsKey($varInfo.FullName)) { 
                    Write-Warning "A $($varInfo.Scope)-scoped variable '$($varInfo.OriginalName)' wat defined inside a function, not in the script root block. It will persist outside the function and may cause side effects. Line: $($varAst.Extent.StartLineNumber), column: $($varAst.Extent.StartColumnNumber)."
                    $VarsMap[$RootSbAst][$varInfo.FullName] = $varInfo 
                }
                continue
            }
            
            if ($varInfo.IsParameter) { $varInfo.ObfuscatedName = $varInfo.OriginalName }
            else { $varInfo.ObfuscatedName = ($this.nameGenerator.GetObfuscatedName($this.config.Mode)) }
        

            $varsInfo[$varInfo.FullName] = $varInfo
        }

        return $varsInfo
    }
}
Class VarReplacer {
    [hashtable]$config
    [AstModel]$astModel

    VarReplacer (
        [hashtable]$Config,
        [AstModel]$AstModel
    ) {
        $this.config = $Config
        $this.astModel = $AstModel
    }

    [System.Collections.ArrayList]GetReplacements() {
        $varsMapper = [VarsMapper]::new($this.config, $this.astModel)
        $varsMap = $varsMapper.GetMap()

        $replacements = [System.Collections.ArrayList]::new()
        Write-Host " Generating variable replacements..." -ForegroundColor Green
        $this.FillVarReplacements($this.AstModel.ast, $varsMap, $replacements)
        Write-Host " Variable replacements generated. Total replacements: $($replacements.Count)" -ForegroundColor Green
        return $replacements
    }

    [void]FillVarReplacements(
        [ScriptBlockAst]$SbAst,
        [hashtable]$VarsMap,
        [System.Collections.ArrayList]$Replacements
    ) {

        if (-not $SbAst -is [ScriptBlockAst]) { throw "Expected ScriptBlockAst" }
                
        $this.SetVarReplacementsForSb($VarsMap, $SbAst, $Replacements)

        [ScriptBlockAst[]]$childrenSb = $this.astModel.FindAstChildrenByType($SbAst, [ScriptBlockAst], "firstChildren", $null)
            
        foreach ($childSb in $childrenSb) {
            $this.FillVarReplacements($childSb, $VarsMap, $Replacements)
        }
    }
    
    SetVarReplacementsForSb(
        [hashtable]$VarsMap,
        [ScriptBlockAst]$SbAst,
        [System.Collections.ArrayList]$Replacements
    ) {
        

        $varsAst = $sbAst.FindAll({ 
                param($node)
                $node -is [VariableExpressionAst]
            }, $false)

        foreach ($varAst in $varsAst) {
            $varInfo = $this.astModel.GetAstVariableInfo($varAst, $SbAst)
            if (-not $varInfo) { continue }
        
            [VarInfo]$assignedVarInfo = $this.FindAssignedVarInfo($VarsMap, $varInfo, $SbAst)
            if (-not $assignedVarInfo) { 
                if (-not $varInfo.IsScoped -and -not $varInfo.IsParameter -and -not  $this.astModel.BuiltinVarNames.Contains($varInfo.OriginalName)) {
                    Write-Warning "The '$($varInfo.OriginalName)' is not defined. It may cause side effects. Line: $($varAst.Extent.StartLineNumber), column: $($varAst.Extent.StartColumnNumber)."
                }
                continue 
            }
            
            if ($assignedVarInfo.IsParameter) { continue }

            $obfuscatedName = $assignedVarInfo.ObfuscatedName
            if ($varInfo.IsScoped) { $obfuscatedName = $varInfo.Scope + ":" + $obfuscatedName }

            [void]$Replacements.Add(@{ Start = $varAst.Extent.StartOffset + 1; Length = $varAst.Extent.EndOffset - $varAst.Extent.StartOffset - 1; Replacement = $obfuscatedName })
        }
    }
    
    [VarInfo]FindAssignedVarInfo(
        [hashtable]$VarsMap,
        [VarInfo]$VarInfo,
        [ScriptBlockAst]$SbAst
    ) {
        if (-not $VarInfo -or $VarInfo.IsParameter) { return $null }
        
        $assignVarsInfo = $VarsMap[$SbAst]
        
        if ($assignVarsInfo -and $assignVarsInfo.ContainsKey($VarInfo.FullName)) {
            return  $assignVarsInfo[$VarInfo.FullName]
        }
        
        $parentSb = $this.astModel.GetAstParentScriptBlock($SbAst)
        
        if (-not $parentSb -and $VarsMap.ContainsKey($SbAst)) { 
            $assignVarsInfo = $VarsMap[$SbAst]
            
            if ($VarInfo.Scope -eq "global" -or $VarInfo.Scope -eq "script") { 
                if ($assignVarsInfo.ContainsKey($VarInfo.FullName)) { return $assignVarsInfo[$VarInfo.FullName] }
                return $null
            }            
            elseif (-not $VarInfo.IsScoped -and $VarInfo.Scope -eq "local" ) {
                $scriptName = "script:" + $VarInfo.Name
                if ($assignVarsInfo.ContainsKey($scriptName)) { return $assignVarsInfo[$scriptName] }

                $globalName = "global:" + $VarInfo.Name
                if ($assignVarsInfo.ContainsKey($globalName)) { return $assignVarsInfo[$globalName] }
            }

            return $null 
        }

        return $this.FindAssignedVarInfo($VarsMap, $VarInfo, $parentSb)
    }
}
Class FuncNameGenerator {
    $usedMap = @{}
    $builtinNames = @{}
    $words = @(
        'Id', 'Object', 'Value', 'Table', 'Data', 'Item', 'Row', 'Cell', 'Index', 'Count', 'Filter',
        'Field', 'List', 'Array', 'Node', 'Key', 'Param', 'Entry', 'Type', 'Name', 'Source',
        'Target', 'Range', 'Size', 'Length', 'Result', 'Status', 'Option', 'Group', 'Parent', 'Child',
        'Column', 'Header', 'Record', 'Recordset', 'Buffer', 'Stream', 'Stack', 'Queue', 'Cache', 'Token',
        'Session', 'Context', 'Handle', 'Path', 'File', 'Folder', 'Driver', 'Engine', 'Query', 'Script',
        'Config', 'Method', 'Event', 'Action', 'Command', 'Process', 'Task', 'Thread', 'Lock', 'Flag',
        'Error', 'Message', 'Report', 'Log', 'Trace', 'Level', 'State', 'Mode', 'Format', 'Output',
        'Input', 'Source', 'Destination', 'Time', 'Date', 'Count', 'Limit', 'Offset', 'Filter', 'Pattern',
        'Value', 'Key', 'Index', 'Type', 'Status', 'Version', 'Option', 'Setting', 'Profile', 'Client'
    )

    FuncNameGenerator([hashtable]$builtinNames) {
        $this.builtinNames = $builtinNames
    }

    [string]GetObfuscatedName([string]$Mode) {
        if ($Mode -ieq "Natural") { return $this.GetNaturalName() }
        else { return $this.GetHardName() }
    }

    [string]GetNaturalName() {
        $Prefixes = @("Add", "Clear", "Close", "Copy", "Enter", "Exit", "Find", "Format", "Get", "Hide", "Join", "Lock", "Move", "New", "Open", "Pop", "Push", "Redo", "Remove", "Rename", "Reset", "Resize", "Search", "Select", "Set", "Show", "Skip", "Split", "Step", "Switch", "Undo", "Unlock", "Watch", "Read", "Receive", "Send", "Write", "Compare", "Compress", "Convert", "Group", "Initialize", "Mount", "Save", "Sync", "Update", "Resolve", "Test", "Confirm", "Invoke", "Start", "Stop", "Submit")
        $new = ""

        while (-not $new -or $this.usedMap.ContainsKey($new)) {
            $prefix = Get-Random -InputObject $Prefixes            
            $count = Get-Random -Minimum 3 -Maximum 5
            $chosen = Get-Random -InputObject $this.words -Count $count
            $body = ($chosen -join '')
    
            $new = "$prefix-$body"
                    
            if ($this.builtinNames.ContainsKey($new)) { $new = "" }
        }

        $this.usedMap[$new] = $true
        return $new
    }

    [string]GetHardName() {
        $chars = @()
        $lChars = [char[]]'abcdefghijklmnopqrstuvwxyz'
        $uChars = [char[]]'ABCDEFGHIJKLMNOPQRSTUVWXYZ'
        $nChars = [char[]]'0123456789'
        $chars += $lChars
        $chars += $uChars
        $chars += $nChars

        $new = ""

        while (-not $new -or $this.usedMap.ContainsKey($new)) {
            $len = Get-Random -Minimum 7 -Maximum 20
            
            $firstChar = Get-Random -InputObject $uChars

            $rest = ""
            for ($i = 1; $i -lt $len; $i++) {
                $rest += (Get-Random -InputObject $chars)
            }

            $body = "$firstChar$rest"
            $new = "Use-$body"

            if ($this.builtinNames.ContainsKey($new)) { $new = "" }
        }

        $this.usedMap[$new] = $true
        return $new
    }
}
Class FuncMapper {
    [hashtable]$config
    [AstModel]$astModel
    [FuncNameGenerator]$nameGenerator

    FuncMapper (
        [hashtable]$config,
        [AstModel]$astModel
    ) {
        $this.config = $config
        $this.astModel = $AstModel
        $this.nameGenerator = [FuncNameGenerator]::new($this.astModel.BuiltinVars)
    }
    
    [hashtable]GetMap() {
        Write-Host " Generating functions map..." -ForegroundColor Green
        $funcsMap = @{}
        $this.FillAssignmentsMap($this.astModel.ast, $funcsMap)
        Write-Host " Functions map generated. Found function definitions: $($funcsMap.Keys.Count)" -ForegroundColor Green
        return  $funcsMap
    }

    [void]FillAssignmentsMap(
        [ScriptBlockAst]$Ast,
        [hashtable]$FuncsMap
    ) {
        $exclude = $null
        if ($this.config.FuncsExclude.Count) { $exclude = $this.config.FuncsExclude }

        $funcAsts = $Ast.FindAll({ 
                param($n)                 
                $n -is [FunctionDefinitionAst] -and -not ($n.parent -is [FunctionMemberAst])
            }, $true)
        

        $funcNames = @($funcAsts | ForEach-Object { $_.Name } | Where-Object { $_ -and ($_ -notin $exclude) } | Sort-Object -Unique)

        if (-not $funcNames -or $funcNames.Count -eq 0) { return }

        $this.FillObfuscatedMap($funcNames, $FuncsMap)
    }

    [void]FillObfuscatedMap([string[]]$funcNames, [hashtable]$FuncsMap) {
        foreach ($name in $funcNames) {
            $new = $this.nameGenerator.GetObfuscatedName($this.config.Mode)
            $funcsMap[$name] = $new
        }
    }
}
Class FuncReplacer {
    [hashtable]$config
    [AstModel]$astModel

    FuncReplacer (
        [hashtable]$Config,
        [AstModel]$AstModel
    ) {
        $this.config = $Config
        $this.astModel = $AstModel
    }

    [System.Collections.ArrayList]GetReplacements() {
        $funcMapper = [FuncMapper]::new($this.config, $this.astModel)
        $funcMap = $funcMapper.GetMap()

        $replacements = [System.Collections.ArrayList]::new()
        Write-Host " Generating function replacements..." -ForegroundColor Green
        $this.FillReplacements($funcMap, $replacements)
        Write-Host " Function replacements generated. Total replacements: $($replacements.Count)" -ForegroundColor Green
        return $replacements
    }


    FillReplacements(
        [hashtable]$FuncsMap,
        [System.Collections.ArrayList]$Replacements
    ) {
        $ast =$this.astModel.ast

        $funcsAst = $ast.FindAll({ 
                param($node)
                ($node -is [CommandAst]) -or ($node -is [FunctionDefinitionAst])
            }, $true)

        foreach ($funcAst in $funcsAst) {
            $element = $null
            $funcName = ""
            $start = 0
            $length = 0            
            if ($funcAst -is [CommandAst]) { 
                $element = $funcAst.CommandElements[0]

                if (-not ($element -is [StringConstantExpressionAst]) -or $element.StringConstantType -ne 'BareWord') { continue }
                $funcName = $element.Value

                if ($funcName -eq "Get-Command") { 
                    $extent = $this.GetSpecialFunctionParameterExtent($funcAst)
                    if (-not $extent) { continue }
                    $funcName = $extent.Value
                    $start = $extent.Start
                    $length = $extent.Length                

                }
                else {
                    $start = $element.Extent.StartOffset
                    $length = $element.Extent.EndOffset - $start
                }
            }            
            elseif ($funcAst -is [FunctionDefinitionAst]) {
                $extent = $this.GetFunctionNameExtent($funcAst)
                if (-not $extent) { continue }
                $funcName = $extent.Value
                $start = $extent.Start
                $length = $extent.Length
            }

            if (-not $funcName) { continue }

            $obfuscatedName = ""
            if ($FuncsMap.ContainsKey($funcName)) { $obfuscatedName = $FuncsMap[$funcName] }
            else { continue }

            [void]$Replacements.Add(@{ Start = $start; Length = $length; Replacement = $obfuscatedName })
        }
    }

    
    [PSCustomObject]GetFunctionNameExtent([FunctionDefinitionAst]$funcAst) {
        $name = $funcAst.Name
        $extent = $funcAst.Extent
        $text = $extent.Text
        $localOffset = $text.IndexOf($name)

        if ($localOffset -lt 0) { return $null }

        $start = $extent.StartOffset + $localOffset
        $length = $name.Length

        return [PSCustomObject]@{
            Value  = $name
            Start  = $start
            Length = $length
        }
    }
    
    [PSCustomObject]GetSpecialFunctionParameterExtent([CommandAst]$funcAst) {
        $elements = $funcAst.CommandElements

        if (-not $elements -or $elements.Count -lt 2) { return $null }
        
        $paramNameFound = $true

        for ($i = 1; $i -lt $elements.Count; $i++) {
            $element = $elements[$i]
            if ($paramNameFound -and $element -is [StringConstantExpressionAst]) {
                return [PSCustomObject]@{
                    Value  = $element.Value
                    Start  = $element.Extent.StartOffset
                    Length = $element.Extent.EndOffset - $element.Extent.StartOffset
                }
            }

            $paramNameFound = $false
            if ($element -is [CommandParameterAst] -and $element.ParameterName -eq "Name") {
                $paramNameFound = $true
                continue
            }
        }
        return $null
    }
}
class PsObfuscator { 
    [hashtable]$config = @{}

    PsObfuscator ([string]$Path,
        [string]$OutPath,
        [string[]]$VarsExclude = @(),
        [string[]]$FuncsExclude = @(),        
        [string]$Mode = "Natural" 
    ) {
        $this.config = @{
            Path         = $Path
            OutPath      = $OutPath
            VarsExclude  = $VarsExclude
            FuncsExclude = $FuncsExclude
            Mode         = $Mode
        }
    }

    [void] Start() {
        Write-Host "Starting obfuscation for file: $($this.config.Path)" -ForegroundColor Green
        $astModel = [AstModel]::new($this.config.Path)

        $replacements = [System.Collections.ArrayList]::new()
        $varReplacer = [VarReplacer]::new($this.config, $astModel)
        Write-Host " Variables obfuscation..." -ForegroundColor Green
        $varReplacements = $varReplacer.GetReplacements()
        if ($varReplacements) { $replacements.AddRange($varReplacements) }

        $funcReplacer = [FuncReplacer]::new($this.config, $astModel)
        Write-Host " Functions obfuscation..." -ForegroundColor Green
        $funcReplacements = $funcReplacer.GetReplacements()
        if ($funcReplacements) { $replacements.AddRange($funcReplacements) }

        Write-Host " Making replacements in script..." -ForegroundColor Green
        $obfuscatedScript = $this.MakeScriptReplacements($astModel.ast.Extent.Text, $replacements)
        Write-Host " Replacements done. Total replacements: $($replacements.Count)" -ForegroundColor Green
        
        $outPath = $this.config.OutPath
        if (-not $outPath) {
            $base = [System.IO.Path]::GetDirectoryName((Resolve-Path -LiteralPath $this.config.Path).Path)
            $name = [System.IO.Path]::GetFileNameWithoutExtension($this.config.Path)
            $ext = [System.IO.Path]::GetExtension($this.config.Path)
            $outPath = Join-Path $base ("$name.obf$ext")
        }

        [System.IO.File]::WriteAllText($outPath, $obfuscatedScript, [System.Text.Encoding]::UTF8) 

        Write-Host "Obfuscated script saved to: $outPath" -ForegroundColor Green
    }

    [string]MakeScriptReplacements([string]$Script, [System.Collections.ArrayList]$Replacements) {
        if ($Replacements.Count -eq 0) {
            Write-Verbose "No replacements found."
            return ""
        }

        $sb = [System.Text.StringBuilder]::new($Script)
        $replacementsSorted = $Replacements | Sort-Object { $_['Start'] } -Descending
        foreach ($r in $replacementsSorted) {
            $sb.Remove($r.Start, $r.Length) | Out-Null
            $sb.Insert($r.Start, $r.Replacement) | Out-Null
        }
        return $sb.ToString()
    }
}