InitHelpers.psm1

$PSDefaultParameterValues['*:Encoding'] = 'utf8'


function Get-CurrentScriptFullPath {
    <#
    .SYNOPSIS
    Returns the full path of the file that defines the current command.
    If that defining file is located under any $env:PSModulePath root, the function
    will attempt to return the first caller script path that is outside module roots.
 
    .DESCRIPTION
    The function searches for the "defining file" using the following priority:
      1. $PSCommandPath
      2. $MyInvocation.MyCommand.Path
      3. $MyInvocation.MyCommand.Definition
    If none of the above yields a path, it falls back to the current process executable path.
    If the resulting path is under a PSModulePath entry, the call stack (Get-PSCallStack) is scanned
    and the first script frame whose path is not under any PSModulePath root is returned (caller script).
    If no suitable caller is found, the module file path is returned (backward-compatible behavior).
 
    .PARAMETER ThrowIfMissing
    If specified, throw an exception when no path can be determined. Otherwise return $null.
 
    .INPUTS
    None.
 
    .OUTPUTS
    System.String - full file path, or $null if unable to determine.
 
    .EXAMPLE
    # Typical usage after importing a module installed into PSModulePath:
    Import-Module MyModule
    Write-Host (Get-CurrentScriptFullPath)
 
    .EXAMPLE
    # When the function is placed directly in a script and the script runs:
    # The function returns the script path.
 
    .NOTES
    PowerShell 5.1 compatible. Uses Resolve-Path + GetFullPath for normalization.
    #>

    param([switch]$ThrowIfMissing)

    function SafeFullPath($p) {
        if (-not $p) { return $null }
        try {
            $rp = Resolve-Path -LiteralPath $p -ErrorAction Stop
            return [IO.Path]::GetFullPath($rp.ProviderPath)
        } catch {
            try { return [IO.Path]::GetFullPath($p) } catch { return $null }
        }
    }

    # Candidate sources in priority order
    $candidates = @()
    if ($PSCommandPath) { $candidates += $PSCommandPath }
    if ($MyInvocation -and $MyInvocation.MyCommand -and $MyInvocation.MyCommand.Path) { $candidates += $MyInvocation.MyCommand.Path }
    if ($MyInvocation -and $MyInvocation.MyCommand -and $MyInvocation.MyCommand.Definition) { $candidates += $MyInvocation.MyCommand.Definition }

    $found = $null
    foreach ($c in $candidates) {
        $fp = SafeFullPath $c
        if ($fp) { $found = $fp; break }
    }

    if (-not $found) {
        try { $found = SafeFullPath ([System.Diagnostics.Process]::GetCurrentProcess().MainModule.FileName) } catch {}
    }

    if (-not $found) {
        if ($ThrowIfMissing) { throw "CAN'T DETERMINE CURRENT SCRIPT FULL PATH!" }
        return $null
    }

    # Build PSModulePath roots (normalized full paths with trailing directory separator)
    $sep = [IO.Path]::PathSeparator
    $dirSep = [IO.Path]::DirectorySeparatorChar
    $modRoots = @()
    foreach ($entry in ($env:PSModulePath -split $sep)) {
        $r = SafeFullPath $entry
        if ($r) {
            if ($r[-1] -ne $dirSep) { $r = $r + $dirSep }
            $modRoots += $r
        }
    }

    $isInModule = $false
    foreach ($r in $modRoots) {
        if ($found.StartsWith($r, [System.StringComparison]::OrdinalIgnoreCase)) { $isInModule = $true; break }
    }

    if ($isInModule) {
        if (Get-Command Get-PSCallStack -ErrorAction SilentlyContinue) {
            foreach ($frame in Get-PSCallStack) {
                $s = $frame.ScriptName
                if ($s -and (Test-Path $s)) {
                    $sFull = SafeFullPath $s
                    if (-not $sFull) { continue }
                    $inside = $false
                    foreach ($r in $modRoots) {
                        if ($sFull.StartsWith($r, [System.StringComparison]::OrdinalIgnoreCase)) { $inside = $true; break }
                    }
                    if (-not $inside) { return $sFull }   # first caller outside module roots
                }
            }
        }
        # If no suitable caller was found, fall back to $found (module file)
    }

    return $found
}

function Get-CurrentScriptDirectory {
    <#
    .SYNOPSIS
    Returns the directory for the file returned by Get-CurrentScriptFullPath.
 
    .DESCRIPTION
    Calls Get-CurrentScriptFullPath and returns its parent directory (Split-Path -Parent).
    Behaves the same as Get-CurrentScriptFullPath regarding module vs caller resolution.
 
    .PARAMETER ThrowIfMissing
    If specified, throw an exception when no path can be determined. Otherwise return $null.
 
    .EXAMPLE
    Write-Host (Get-CurrentScriptDirectory)
 
    .NOTES
    PowerShell 5.1 compatible.
    #>

    param([switch]$ThrowIfMissing)

    $p = Get-CurrentScriptFullPath -ThrowIfMissing:$ThrowIfMissing
    if ($p) { return Split-Path -Path $p -Parent }
    return $null
}


function Get-ScriptPathInitialized {
<#
.SYNOPSIS
Initializes script path and related subfolder/file variables.
.DESCRIPTION
Sets script-wide variables:
  - $global:ps_script_path
  - $global:ps_script_file
Creates subfolder directories and file path variables based on input.
.PARAMETER SubFolders
Array of subfolder names to create under the script path.
.PARAMETER FilesWithExtensions
Array of file extensions to generate (default: log, toml).
.EXAMPLE
Get-ScriptPathInitialized -SubFolders @("logs","data") -FilesWithExtensions @("log","txt")
#>

    param(
        [string[]]$SubFolders,
        [string[]]$FilesWithExtensions = @("log")
    )
    $global:ps_script_path = Get-CurrentScriptDirectory
    $global:ps_script_file = Get-CurrentScriptFullPath
    if ($SubFolders -and $SubFolders.Count -gt 0) {
        foreach ($private:subfolder in $SubFolders) {
            $private:potential_path = Join-Path -Path $global:ps_script_path -ChildPath $private:subfolder
            $private:folder_var=(($private:subfolder.ToLower() -Replace "[\W|_]+","_") -replace "^(\d)","_`$1")+"_path"
            Set-Variable -Name $private:folder_var -Value $private:potential_path -Scope Global -Force
            New-Item -ItemType Directory -Path $private:potential_path -Force | Out-Null
        }
    }
    if ($FilesWithExtensions -and $FilesWithExtensions.Count -gt 0) {
        foreach ($private:ext in $FilesWithExtensions) {
            $private:ext=$private:ext.ToLower() -replace "[^a-z]+",""
            $private:file_var="{0}_file" -f $private:ext
            $private:potential_path = "{0}.{1}" -f (Join-Path -Path $global:ps_script_path -ChildPath ([System.IO.Path]::GetFileNameWithoutExtension($global:ps_script_file))), $private:ext
            Set-Variable -Name $private:file_var -Value $private:potential_path -Scope Global -Force
        }
    }

    return 
}


function Get-StringSha256Hash {
<#
.SYNOPSIS
Computes the SHA256 hash of a string.
.PARAMETER InputString
The string to compute the hash for (mandatory).
.EXAMPLE
Get-StringSha256Hash -InputString "Hello World"
#>

    [CmdletBinding()]
    param (
        [Parameter(Mandatory = $true)]
        [string]$InputString
    )

    $private:sha256 = [System.Security.Cryptography.SHA256Managed]::new()
    $private:bytes = [System.Text.Encoding]::UTF8.GetBytes($InputString)
    $private:hashBytes = $private:sha256.ComputeHash($private:bytes)

    $private:hash = -join ($private:hashBytes | ForEach-Object { $_.ToString("X2") })
    return $private:hash
}


function Write-Log {
<#
.SYNOPSIS
Writes a log message to console and log file.
.PARAMETER Message
The message to log (mandatory).
.PARAMETER Level
Log level: Info, Warning, Error, Success, Debug (default: Info).
.PARAMETER MuteForDebug
Do not display debug message.
.PARAMETER NoNewLine
Do not add a newline after output.
.PARAMETER StartNewLine
Start the log message on a new line.
.EXAMPLE
Write-Log -message "Operation completed" -level Info
#>

    [CmdletBinding()]
    param(
        [Parameter(Mandatory = $true)]
        [string]$Message,
        [ValidateSet("Info", "Warning", "Error", "Success", "Debug")]
        [string]$Level = "Info",
        [switch]$MuteForDebug,
        [switch]$NoNewLine,
        [switch]$StartNewLine
    )
    
    $private:timestamp = Get-Date -Format "yyyy-MM-dd HH:mm:ss"
    $private:log_entry = "[$private:timestamp] [$Level] $Message"
    if ($StartNewLine) {
        $private:log_entry = "`n$private:log_entry"
        Write-Host ""
    }
    # Write to log file
    try {
        Add-Content -Path $global:log_file -Value $private:log_entry -ErrorAction Stop
    }
    catch {
        Write-Host "Failed to write to log file: $($_.Exception.Message)" -ForegroundColor Red
    }
    
    # Write to console with appropriate color
    $private:foreground_color = switch ($Level.ToLower()) {
        "info" { "White" }
        "warning" { "Yellow" }
        "error" { "Red" }
        "success" { "Green" }
        "debug" { "Cyan" }
        default { "White" }
    }
    if($Level -eq "Debug" -and $MuteForDebug) {
        return
    }
    Write-Host -NoNewline "[$private:timestamp]" -ForegroundColor "DarkGray"
    Write-Host " [$Level] $Message" -ForegroundColor $private:foreground_color -NoNewline
    if (-not $NoNewLine) {
        Write-Host ""   
    }
}


function Get-TempFileName() {
<#
.SYNOPSIS
Generates a temporary file name.
.PARAMETER ext
File extension (default: tmp).
.PARAMETER prefix
Optional file name prefix.
.PARAMETER suffix
Optional file name suffix.
.EXAMPLE
Get-TempFileName -ext "txt" -prefix "log" -suffix "2025"
#>

    [CmdletBinding()]
    param(
        [string]$ext = "tmp", 
        [string]$prefix = "", 
        [string]$suffix = "" 
    )
    return Join-Path -Path ([System.IO.Path]::GetTempPath()) -ChildPath ((@([System.IO.Path]::GetRandomFileName().Replace(".", ""), $prefix, $suffix, $ext) | Where-Object { -not [string]::IsNullOrEmpty($_) }) -join ".").TrimEnd(".")
}


function Get-TypeByName() {
<#
.SYNOPSIS
Gets the Type object by type name.
.PARAMETER TypeName
Full name of the type.
.PARAMETER Mute
Suppresses exception if type is not found.
.EXAMPLE
Get-TypeByName -type_name "System.Text.StringBuilder"
#>
    
    [CmdletBinding()]
    param(
        [Parameter(Mandatory = $true)][string]$TypeName,
        [switch]$Mute
    )
    $private:type_by_name = [Type]::GetType($TypeName)

    if (-not $private:type_by_name) {
        # fallback: search loaded assemblies for the type:
        $private:full_name = $TypeName.split(',')[0].Trim()
        $private:type_by_name = [AppDomain]::CurrentDomain.GetAssemblies() |
        ForEach-Object { $_.GetTypes() } |
        Where-Object { $_.FullName -eq $private:full_name } |
        Select-Object -First 1
    }
    if (-not $private:type_by_name -and -not $Mute) {
        Write-Log -Message ("Failed to Find Type '{0}' in Loaded Assemblies." -f $TypeName) -Level "Error"
        throw ("Failed to Find Type '{0}' in Loaded Assemblies." -f $TypeName)
    }
    return $private:type_by_name
}


function Get-InstanceByTypeName() {
<#
.SYNOPSIS
Creates an instance of a type by name.
.PARAMETER TypeName
Full name of the type.
.PARAMETER Mute
Suppresses exception if instance creation fails.
.EXAMPLE
Get-InstanceByTypeName -type_name "System.Text.StringBuilder"
#>

    [CmdletBinding()]
    param(
        [Parameter(Mandatory = $true)][string]$TypeName,
        [switch]$Mute
    )
    $private:type_by_name = Get-TypeByName -type_name $TypeName -mute:$Mute
    $private:instance = [Activator]::CreateInstance($private:type_by_name)
    if (-not $private:instance -and -not $Mute) {
        Write-Log -Message ("Failed to Create Instance of Type '{0}'." -f $TypeName) -Level "Error"
        throw ("Failed to Create Instance of Type '{0}'." -f $TypeName)
    }
    return $private:instance
}


function Install-NuGet {
<#
.SYNOPSIS
Installs the NuGet command-line tool.
.DESCRIPTION
Downloads and adds nuget.exe to PATH if not already installed.
.EXAMPLE
Install-NuGet
#>
    
    if (-not (Get-Command nuget -ErrorAction SilentlyContinue)) {
        $private:nuget_path = Join-Path -Path (Join-Path -Path $env:LOCALAPPDATA -ChildPath "Programs") -ChildPath "NuGet"
        if (-not (Test-Path -Path $private:nuget_path)) {
            New-Item -ItemType Directory -Path $private:nuget_path -Force | Out-Null
        }
        $private:nuget_path = Join-Path -Path $private:nuget_path -ChildPath "nuget.exe"
        if (-not (Test-Path -Path $private:nuget_path)) {
            try {
                Invoke-WebRequest -Uri "https://dist.nuget.org/win-x86-commandline/latest/nuget.exe" -OutFile $private:nuget_path -UseBasicParsing -ErrorAction Stop
            }
            catch {
                Write-Log -Message ("Failed to download nuget.exe: {0}" -f $_.Exception.Message) -Level "Error"
                throw ("Failed to download nuget.exe: {0}" -f $_.Exception.Message)
            }
        }
        $env:PATH = "{0};{1}" -f ($env:PATH), ([System.IO.Path]::GetDirectoryName($private:nuget_path))
        [System.Environment]::SetEnvironmentVariable("PATH", $env:PATH, [System.EnvironmentVariableTarget]::Process)
        if (-not (Get-Command nuget -ErrorAction SilentlyContinue)) {
            Write-Log -Message "NuGet installation failed or nuget.exe not found in PATH." -Level "Error"
            throw "NuGet installation failed or nuget.exe not found in PATH."
        }
        Write-Log -Message ("NuGet installed at path: {0}" -f $private:nuget_path) -Level "Debug"
    }
    return $true
}


function Install-Library() {
<#
.SYNOPSIS
Installs a library using NuGet.
.PARAMETER LibName
Library name (mandatory).
.PARAMETER Version
Optional version to install.
.EXAMPLE
Install-Library -lib_name "Newtonsoft.Json" -version "13.0.1"
#>

    [CmdletBinding()]
    param(
        [Parameter(Mandatory = $true)][string]$LibName,
        [string]$Version = ""
    )
    if ([string]::IsNullOrEmpty($LibName)) {
        Write-Log -Message "Library name is required for Install-Library." -Level "Error"
        throw "Library name is required for Install-Library."
    }
    if (-not (test-Path -Path $global:libs_path)) {
        New-Item -ItemType Directory -Path $global:libs_path -Force | Out-Null
    }
    try {
        if ([string]::IsNullOrEmpty($Version)) {
            nuget install $LibName -OutputDirectory $global:libs_path -ExcludeVersion -NonInteractive -Source "https://api.nuget.org/v3/index.json" | Where-Object { -not [string]::IsNullOrEmpty($_) } | ForEach-Object { Write-Log -Message $_ -Level "Debug" }
        }
        else {
            nuget install $LibName -Version $Version -OutputDirectory $global:libs_path -NonInteractive -Source "https://api.nuget.org/v3/index.json" | Where-Object { -not [string]::IsNullOrEmpty($_) } | ForEach-Object { Write-Log -Message $_ -Level "Debug" }
        }
    }
    catch {
        Write-Log -Message ("Failed to Install Library '{0}': {1}" -f $LibName, $_.Exception.Message) -Level "Error"
        throw ("Failed to Install Library '{0}': {1}" -f $LibName, $_.Exception.Message)
    }
    return $true
}


function Install-Assembly() {
<#
.SYNOPSIS
Loads assembly DLLs from the library path.
.PARAMETER LibName
Optional library name filter.
.PARAMETER version
Library version folder (default: net4).
.EXAMPLE
Install-Assembly -lib_name "Newtonsoft.Json"
#>

    [CmdletBinding()]
    param(
        [string]$LibName = $null,
        [string]$Version = "net4"
    )
    if (-not (test-Path -Path $global:libs_path)) {
        New-Item -ItemType Directory -Path $global:libs_path -Force | Out-Null
    }
    $private:lib_dlls = (Get-ChildItem -Path $global:libs_path -Filter "*.dll" -Recurse | Where-Object { $_.FullName -match ('\\lib\\{0}' -f $Version) -and ($null -eq $LibName -or [System.IO.Path]::GetFileNameWithoutExtension($_.Name) -match $LibName) }).FullName
    if ($private:lib_dlls) {
        foreach ($private:lib_dll in $private:lib_dlls) {
            [Reflection.Assembly]::LoadFile($private:lib_dll) | out-null
            Write-Log -Message ("Loaded Assembly: {0}" -f ([System.IO.Path]::GetFileNameWithoutExtension($private:lib_dll))) -Level "Debug"
        }
    }
    else {
        if (Install-Library -lib_name $LibName) {
            Install-Assembly -lib_name $LibName -version $Version
            return
        }
        Write-Log -Message ("No Assemblies Found in Path: {0}" -f $global:libs_path) -Level "Error"
        throw ("No Assemblies Found in Path: {0}" -f $global:libs_path)
    }
}


function Get-ModulesInstalled {
<#
.SYNOPSIS
Gets or installs PowerShell modules.
.PARAMETER name
Array of module names.
.PARAMETER force
Force reinstall even if module exists.
.PARAMETER detailed
Output detailed logging.
.EXAMPLE
Get-ModulesInstalled -name @("PSReadLine") -force -detailed
#>

    [CmdletBinding()]
    Param(
        [Parameter(Position = 0, ValueFromPipeline = $true, ValueFromPipelineByPropertyName = $true)]
        [string[]]$name,
        [switch]$force,
        [switch]$detailed
    )
    Begin {
        $private:results = @()
    }
    Process {
        if ($null -eq $name) {
            return
        }
        foreach ($private:module_name in $name) {
            $private:error_msg = $null
            if ($detailed) {
                Write-Host "Processing module '$private:module_name'..."
            }
            $private:existing_module = Get-Module -Name $private:module_name -ListAvailable -ErrorAction SilentlyContinue
            if ($null -eq $private:existing_module) {
                # Module not installed
                if ($detailed) {
                    Write-Log -Message "Module '$private:module_name' is not installed. Installing..." -Level "Debug"
                }
                try {
                    $null = Install-Module -Name $private:module_name -Scope CurrentUser -Force:$force -ErrorAction Stop
                    $private:status = 'Installed'
                    if ($detailed) {
                        Write-Log -Message "Module '$private:module_name' installed successfully." -Level "Success"
                    }
                }
                catch {
                    $private:status = 'Failed'
                    $private:error_msg = $_.Exception.Message
                    Write-Log -Message "Failed to install module '$private:module_name': $private:error_msg" -Level "Error"
                    throw "Failed to install module '$private:module_name': $private:error_msg"
                }
            }
            elseif ($force) {
                # Module is installed but Force is specified
                if ($detailed) {
                    Write-Log -Message "Module '$private:module_name' is already installed. Reinstalling (Force)..." -Level "Debug"
                }
                try {
                    $null = Install-Module -Name $private:module_name -Scope CurrentUser -Force -ErrorAction Stop
                    $private:status = 'Reinstalled'
                    if ($detailed) {
                        Write-Log -Message "Module '$private:module_name' reinstalled successfully." -Level "Success"
                    }
                }
                catch {
                    $private:status = 'Failed'
                    $private:error_msg = $_.Exception.Message
                    Write-Log -Message "Failed to reinstall module '$private:module_name': $private:error_msg" -Level "Error"
                    throw "Failed to reinstall module '$private:module_name': $private:error_msg"
                }
            }
            else {
                # Module is already installed and no Force flag
                $private:status = 'AlreadyInstalled'
                if ($detailed) {
                    Write-Log -Message "Module '$private:module_name' is already installed." -Level "Debug"
                }
            }
            $private:results += [PsCustomObject]@{
                Name   = $private:module_name
                Status = $private:status
                Error  = $private:error_msg
            }
        }
    }
    End {
        return $private:results
    }
}