RelaxedIT.AzLog.psm1

function Import-RelaxedITAzLogAssembly
{
    [CmdletBinding()]
    param (
        [string]$PackageRoot = "C:\ProgramData\RelaxedIT\packages"
    )

    if ([System.Type]::GetType("Azure.Data.Tables.TableClient, Azure.Data.Tables") -ne $null)
    {
        return $true
    }

    # In PowerShell 7 / .NET 8, load dependencies in order prior to primary assembly
    $dependencyDlls = @(
        "System.Memory.Data\lib\net8.0\System.Memory.Data.dll",
        "System.ClientModel\lib\net8.0\System.ClientModel.dll",
        "Azure.Core\lib\net8.0\Azure.Core.dll",
        "Azure.Data.Tables\lib\net8.0\Azure.Data.Tables.dll"
    )

    foreach ($relPath in $dependencyDlls)
    {
        $fullPath = Join-Path $PackageRoot $relPath
        if (Test-Path -Path $fullPath)
        {
            Add-Type -Path $fullPath -ErrorAction SilentlyContinue
        }
    }

    # Explicitly check primary assembly requested path
    $primaryPath = "C:\ProgramData\RelaxedIT\packages\Azure.Data.Tables\lib\net8.0\Azure.Data.Tables.dll"
    if (Test-Path -Path $primaryPath)
    {
        Add-Type -Path $primaryPath -ErrorAction SilentlyContinue
    }

    # Fallback scan for net8.0 assemblies if relocated
    if ([System.Type]::GetType("Azure.Data.Tables.TableClient, Azure.Data.Tables") -eq $null -and (Test-Path -Path $PackageRoot))
    {
        $net8Dlls = Get-ChildItem -Path $PackageRoot -Filter "*.dll" -Recurse -File -ErrorAction SilentlyContinue |
            Where-Object { $_.FullName -match 'net8\.0' }
        foreach ($dll in $net8Dlls)
        {
            try { [System.Reflection.Assembly]::LoadFrom($dll.FullName) | Out-Null } catch {}
        }
    }

    return ([System.Type]::GetType("Azure.Data.Tables.TableClient, Azure.Data.Tables") -ne $null)
}

# Pre-load assembly when module is loaded
$null = Import-RelaxedITAzLogAssembly

function Send-RelaxedITAzLogPing
{
    [CmdletBinding()]
    param (
        [int]$interval = 300,
        [string]$config = "C:\ProgramData\RelaxedIT\azlog.json",
        [string]$action = "",
        [string]$sasToken = "# initial",
        [string]$accountKey = ""
    )

    if (!(Test-Path -Path $config))
    {
        $base = (Get-Module RelaxedIT.AzLog).ModuleBase
        if (-not $base)
        {
            $base = $PSScriptRoot
        }
        Test-AndCreatePath -Path (Get-BasePath -Path $config)
        Copy-Item -Path (Join-Path $base "azlog.json") -Destination $config
        Write-RelaxedIT "[Initial]: copy default config: ""$config"""
    }

    $configobj = Get-RelaxedITConfig -config $config

    if ($sasToken -ne "# initial")
    {
        $configobj.sasToken = $sasToken
        $configobj | ConvertTo-Json | Set-Content -Path $config -Encoding utf8BOM
    }

    if (-not [string]::IsNullOrWhiteSpace($accountKey))
    {
        $configobj | Add-Member -NotePropertyName "accountKey" -NotePropertyValue $accountKey -Force
        $configobj | ConvertTo-Json | Set-Content -Path $config -Encoding utf8BOM
    }

    $cfg = Get-RelaxedITConfig -config $config
    Set-EnvVar -name "RelaxedIT.AzLog.sasToken" -value $cfg.sasToken
    Set-EnvVar -name "RelaxedIT.AzLog.accountKey" -value $cfg.accountKey
    Set-EnvVar -name "RelaxedIT.AzLog.storageAccountName" -value $cfg.storageAccountName
    Set-EnvVar -name "RelaxedIT.AzLog.tableName" -value $cfg.tableName

    $tableName = Get-EnvVar -name "RelaxedIT.AzLog.tableName"
    $storageAccountName = Get-EnvVar -name "RelaxedIT.AzLog.storageAccountName"
    $currentSas = Get-EnvVar -name "RelaxedIT.AzLog.sasToken"
    $currentKey = Get-EnvVar -name "RelaxedIT.AzLog.accountKey"

    $hasSas = (-not [string]::IsNullOrWhiteSpace($currentSas)) -and (-not $currentSas.StartsWith("#"))
    $hasKey = (-not [string]::IsNullOrWhiteSpace($currentKey)) -and (-not $currentKey.StartsWith("#"))

    if (-not $hasSas -and -not $hasKey)
    {
        Write-RelaxedIT -logtext "[WRN] RelaxedIT.AzLog.Run: CONFIG: No valid sasToken or accountKey configured for table ""$tableName""!"
        return
    }

    if (-not $storageAccountName)
    {
        Write-RelaxedIT -logtext "[ERR] RelaxedIT.AzLog.Run: Missing storage account name in config: $config" -ForegroundColor Red
        return
    }

    # Ensure Azure.Data.Tables assembly is loaded
    if (-not (Import-RelaxedITAzLogAssembly))
    {
        Write-RelaxedIT -logtext "[ERR] RelaxedIT.AzLog.Run: Azure.Data.Tables.dll could not be loaded from C:\ProgramData\RelaxedIT\packages. Run Install-RelaxedITAzLogPackage." -ForegroundColor Red
        return
    }

    # Collect OS & Hardware inventory using PowerShell 7 compatible CIM cmdlets
    try
    {
        $winNtReg = Get-ItemProperty -Path 'HKLM:\SOFTWARE\Microsoft\Windows NT\CurrentVersion' -ErrorAction SilentlyContinue
        $displayVersion = $winNtReg.DisplayVersion
        $currentBuildNumber = $winNtReg.CurrentBuildNumber

        $productName = (Get-CimInstance -ClassName Win32_OperatingSystem -ErrorAction SilentlyContinue).Caption
        $biosVersion = (Get-CimInstance -ClassName Win32_BIOS -ErrorAction SilentlyContinue).SMBIOSBIOSVersion

        $cs = Get-CimInstance -ClassName Win32_ComputerSystem -ErrorAction SilentlyContinue
        $manufacturer = $cs.Manufacturer
        $model = $cs.Model

        $relaxedver = Test-RelaxedIT -ErrorAction SilentlyContinue

        $cpu_info = Get-CimInstance -ClassName Win32_Processor -ErrorAction SilentlyContinue |
            Select-Object -Property Name, NumberOfCores, NumberOfLogicalProcessors

        $ram_info = Get-CimInstance -ClassName Win32_PhysicalMemory -ErrorAction SilentlyContinue |
            Measure-Object -Property Capacity -Sum
        $ramGB = if ($ram_info.Sum) { [math]::round($ram_info.Sum / 1GB, 2) } else { 0 }

        # Optional driver updates check
        $pendingdrivers = ""
        try
        {
            if (Get-Command Get-WindowsUpdate -ErrorAction SilentlyContinue)
            {
                $drivers = Get-WindowsUpdate -Category "Drivers" -ErrorAction SilentlyContinue
                if ($drivers)
                {
                    $pendingdrivers = ($drivers.Title | Sort-Object -Unique) -join "; "
                }
            }
        }
        catch {}

        # Outdated third-party packages check
        $outdated = $null
        try
        {
            if (Get-Command RelaxedIT.3rdParty.chocolist -ErrorAction SilentlyContinue)
            {
                $outdated = RelaxedIT.3rdParty.chocolist -ErrorAction SilentlyContinue
            }
        }
        catch {}
    }
    catch
    {
        Write-RelaxedIT -logtext ("# GetOSInventory (" + ($MyInvocation.ScriptName.Split("\")[-1]) + ") """ + $MyInvocation.MyCommand.Name + """: " + $MyInvocation.PSCommandPath + ": " + $_.Exception.Message + $_.Exception.ItemName) -ForegroundColor Red
        Write-RelaxedIT -logtext ($_ | Format-List * -Force | Out-String) -ForegroundColor Red
    }

    # Initialize Azure Table Client
    try
    {
        $tableClient = $null

        if ($hasKey)
        {
            # SharedKeyCredential (Account Name + Account Key)
            $endpoint = [Uri]"https://$storageAccountName.table.core.windows.net"
            $credentials = [Azure.Data.Tables.TableSharedKeyCredential]::new($storageAccountName, $currentKey)
            $tableClient = [Azure.Data.Tables.TableClient]::new($endpoint, $tableName, $credentials)
        }
        elseif ($hasSas)
        {
            # SAS Credential
            $cleanSas = $currentSas.TrimStart('?')
            $tableUri = [Uri]"https://$storageAccountName.table.core.windows.net/$tableName"
            $credentials = [Azure.AzureSasCredential]::new($cleanSas)
            $tableClient = [Azure.Data.Tables.TableClient]::new($tableUri, $credentials)
        }

        if (-not $tableClient)
        {
            throw "Failed to initialize Azure.Data.Tables.TableClient."
        }

        # Automatically create table if it does not exist
        try
        {
            $null = $tableClient.CreateTableIfNotExists()
        }
        catch
        {
            # SAS tokens without table management permissions may throw; continue to entity upsert
            Write-RelaxedIT -logtext "[DBG] RelaxedIT.AzLog.Run: CreateTableIfNotExists notice: $($_.Exception.Message)"
        }

        # Build TableEntity using PowerShell 7 hashtable
        $partitionKey = "ping"
        $rowKey       = $env:COMPUTERNAME

        $entityData = @{
            PartitionKey       = $partitionKey
            RowKey             = $rowKey
            ServerName         = $env:COMPUTERNAME
            action             = $action
            displayVersion     = $displayVersion
            productName        = $productName
            currentBuildNumber = $currentBuildNumber
            biosVersion        = $biosVersion
            manufacturer       = $manufacturer
            model              = $model
            ramGB              = $ramGB
            cpu                = ($cpu_info | ConvertTo-Json -Compress)
            version            = $relaxedver
            pendingdrivers     = $pendingdrivers
            SoftwareOutdated   = $outdated
            PingTimeUTC        = (Get-LogDateFileString)
            Timestamp          = [DateTimeOffset]::UtcNow
        }

        $entity = [Azure.Data.Tables.TableEntity]::new($entityData)

        # Upsert entity (Insert or Merge in a single atomic call)
        Write-RelaxedIT -logtext "Upsert: [Azure.Data.Tables] ""$model"" $action " -NoNewline

        $response = $tableClient.UpsertEntity($entity, [Azure.Data.Tables.TableUpdateMode]::Merge)
        $etag = $response.Headers.ETag

        if ($response.Status -in 200, 201, 204)
        {
            Write-RelaxedIT -logtext "OK (Status: $($response.Status); ETag: $etag)" -noWriteDate -ForegroundColor Green
        }
        else
        {
            Write-RelaxedIT -logtext "[ERR] Status: $($response.Status) | ETag: $etag" -noWriteDate -ForegroundColor Red
        }

        Write-RelaxedIT -LogText ($entity | Out-String) -ForegroundColor Yellow
        return $response
    }
    catch
    {
        Write-RelaxedIT -logtext ("#(" + ($MyInvocation.ScriptName.Split("\")[-1]) + ") """ + $MyInvocation.MyCommand.Name + """: " + $MyInvocation.PSCommandPath + ": " + $_.Exception.Message + $_.Exception.ItemName) -ForegroundColor Red
        Write-RelaxedIT -logtext ($_ | Format-List * -Force | Out-String) -ForegroundColor Red
    }
}

function Install-RelaxedITAzLogPackage
{
    [CmdletBinding()]
    param (
        [string]$PackageDestination = "C:\ProgramData\RelaxedIT\packages"
    )

    Test-AndCreatePath -Path $PackageDestination

    if (Get-Command choco -ErrorAction SilentlyContinue)
    {
        choco update nuget.commandline -y
    }

    if (Get-Command nuget -ErrorAction SilentlyContinue)
    {
        nuget install Azure.Data.Tables -OutputDirectory $PackageDestination -ExcludeVersion -Framework net8.0
    }
    else
    {
        Write-RelaxedIT -logtext "[WRN] nuget command not found. Ensure nuget.commandline is installed." -ForegroundColor Yellow
    }

    $null = Import-RelaxedITAzLogAssembly -PackageRoot $PackageDestination
}

function Add-RelaxedITAzLogToken
{
    [CmdletBinding()]
    param(
        [string]$config = "C:\ProgramData\RelaxedIT\azlog.json",
        [int]$years = 5,
        [string]$accountKey = ""
    )

    if (!(Test-Path -Path $config))
    {
        $base = (Get-Module RelaxedIT.AzLog).ModuleBase
        if (-not $base)
        {
            $base = $PSScriptRoot
        }
        Test-AndCreatePath -Path (Get-BasePath -Path $config)
        Copy-Item -Path (Join-Path $base "azlog.json") -Destination $config
        Write-RelaxedIT "[Initial]: copy default config: '$config'"
    }

    $configobj = Get-RelaxedITConfig -config $config

    $storageAccountName = $configobj.storageAccountName
    if (-not $storageAccountName)
    {
        $storageAccountName = Read-Host "Enter storage account name"
        if (-not $storageAccountName) { Write-RelaxedIT -logtext "No storage account name provided. Aborting." -ForegroundColor Red; return }
        $configobj.storageAccountName = $storageAccountName
    }

    $tableName = $configobj.tableName
    if (-not $tableName)
    {
        $tableName = Read-Host "Enter table name (default: table01)"
        if (-not $tableName) { $tableName = "table01" }
        $configobj.tableName = $tableName
    }

    if (-not [string]::IsNullOrWhiteSpace($accountKey))
    {
        $configobj | Add-Member -NotePropertyName "accountKey" -NotePropertyValue $accountKey -Force
        $configobj | ConvertTo-Json | Set-Content -Path $config -Encoding utf8BOM
        Write-RelaxedIT -logtext "Account key saved to $config" -ForegroundColor Green
        return $accountKey
    }

    $expiry = (Get-Date).AddYears($years).ToUniversalTime().ToString("yyyy-MM-ddTHH:mmZ")

    $azCmd = Get-Command az -ErrorAction SilentlyContinue
    if (-not $azCmd)
    {
        Write-RelaxedIT -logtext "[ERR] Azure CLI 'az' not found. Install Azure CLI and login ('az login') or configure accountKey/sasToken manually." -ForegroundColor Red
        return
    }

    # Try table-level SAS first (requires az extension / permissions)
    $sas = $null
    try
    {
        $sas = & az storage table generate-sas --name $tableName --account-name $storageAccountName --expiry $expiry --permissions rau --https-only --auth-mode login -o tsv 2>$null
    }
    catch {}

    if (-not $sas -or $sas -eq "")
    {
        try
        {
            # Fallback to account-level SAS for table service
            $sas = & az storage account generate-sas --account-name $storageAccountName --expiry $expiry --permissions rwdlacup --services t --resource-types sco --https-only -o tsv 2>$null
        }
        catch {}
    }

    if (-not $sas -or $sas -eq "")
    {
        Write-RelaxedIT -logtext "[ERR] Could not generate SAS via Azure CLI. Ensure 'az login' and proper RBAC or provide SAS/accountKey manually." -ForegroundColor Red
        return
    }

    if ($sas.StartsWith("?")) { $sas = $sas.Substring(1) }

    $configobj.sasToken = $sas
    $configobj | ConvertTo-Json | Set-Content -Path $config -Encoding utf8BOM

    Write-RelaxedIT -logtext "SAS token generated and saved to $config" -ForegroundColor Green
    return $sas
}

# Export Aliases for backwards compatibility with dot-separated names
Set-Alias -Name 'RelaxedIT.AzLog.Run.Ping' -Value 'Send-RelaxedITAzLogPing'
Set-Alias -Name 'RelaxedIT.AzLog.AddToken' -Value 'Add-RelaxedITAzLogToken'
Set-Alias -Name 'RelaxedIT.AzLog.InstalRequiredPackages' -Value 'Install-RelaxedITAzLogPackage'
Set-Alias -Name 'RelaxedIT.AzLog.InstallRequiredPackages' -Value 'Install-RelaxedITAzLogPackage'

Export-ModuleMember -Function 'Send-RelaxedITAzLogPing', 'Add-RelaxedITAzLogToken', 'Install-RelaxedITAzLogPackage', 'Import-RelaxedITAzLogAssembly' -Alias 'RelaxedIT.AzLog.Run.Ping', 'RelaxedIT.AzLog.AddToken', 'RelaxedIT.AzLog.InstalRequiredPackages', 'RelaxedIT.AzLog.InstallRequiredPackages'