oh-my-stats.psm1

# oh-my-stats PowerShell Module
# Cross-platform system statistics display
# Author: Paweł Żentała
# License: MIT

# Load config
$script:ConfigPath = if ($env:XDG_CONFIG_HOME) {
    Join-Path $env:XDG_CONFIG_HOME "oh-my-stats/config.json"
} else {
    Join-Path $HOME ".config/oh-my-stats/config.json"
}

# CIM property projections. Querying a whole WMI class costs an order of magnitude more
# than asking for the handful of properties actually read below.
$script:OsProperties  = @('TotalVisibleMemorySize', 'FreePhysicalMemory', 'LastBootUpTime', 'Caption')
$script:CpuProperties = @('Name', 'NumberOfCores', 'NumberOfLogicalProcessors', 'MaxClockSpeed')

# Two layouts: the repo keeps config/ beside pwsh/, a published package keeps it
# inside the module folder.
$script:DefaultConfigPath = @(
    (Join-Path $PSScriptRoot "../config/default.json")
    (Join-Path $PSScriptRoot "config/default.json")
) | Where-Object { Test-Path $_ } | Select-Object -First 1
$script:Config = if (Test-Path $ConfigPath) {
    Get-Content $ConfigPath | ConvertFrom-Json
} else {
    Get-Content $DefaultConfigPath | ConvertFrom-Json
}

# Helper function: Draw progress bar
function Draw-ProgressBar {
    param(
        [int]$Percent,
        [int]$Width = 50,
        [string]$Color = "Green"
    )

    # Clamp percent to 0-100 range
    if ($Percent -lt 0) { $Percent = 0 }
    if ($Percent -gt 100) { $Percent = 100 }

    $filled = [math]::Floor($Percent * $Width / 100)
    $empty = $Width - $filled

    if ($Percent -ge 80) { $Color = "Red" }
    elseif ($Percent -ge 60) { $Color = "Yellow" }
    else { $Color = "Green" }

    $bar = "█" * $filled + "░" * $empty

    $colorCode = switch($Color) {
        "Green" { "`e[32m" }
        "Yellow" { "`e[33m" }
        "Red" { "`e[31m" }
    }

    return "${colorCode}${bar}`e[0m"
}

# Helper function: Convert UTF32 for large icons
function Get-Icon {
    param([string]$HexCode)

    $code = [int]$HexCode
    if ($code -gt 0xFFFF) {
        return [System.Char]::ConvertFromUtf32($code)
    } else {
        return [string][char]$code
    }
}

# Cache functions for performance optimization
function Test-CacheValid {
    param([string]$CacheFile)

    if (-not (Test-Path $CacheFile)) {
        return $false
    }

    try {
        $cacheAge = (Get-Date) - (Get-Item $CacheFile).LastWriteTime
        # Cache valid for 7 days
        return $cacheAge.TotalDays -lt 7
    } catch {
        return $false
    }
}

function Get-SystemInfoCache {
    $cacheDir = Join-Path $HOME ".cache/oh-my-stats"
    $cacheFile = Join-Path $cacheDir "system-info.json"

    if (Test-CacheValid $cacheFile) {
        try {
            $cached = Get-Content $cacheFile -Raw | ConvertFrom-Json
            Write-Verbose "Using cached system info (age: $((Get-Date) - (Get-Item $cacheFile).LastWriteTime))"
            return $cached
        } catch {
            Write-Verbose "Cache read failed: $_"
            return $null
        }
    }

    return $null
}

function Save-SystemInfoCache {
    param($SystemInfo)

    $cacheDir = Join-Path $HOME ".cache/oh-my-stats"
    $cacheFile = Join-Path $cacheDir "system-info.json"

    try {
        if (-not (Test-Path $cacheDir)) {
            New-Item -ItemType Directory -Path $cacheDir -Force | Out-Null
        }

        $SystemInfo | ConvertTo-Json -Depth 5 | Set-Content $cacheFile -Force
        Write-Verbose "Saved system info to cache"
    } catch {
        Write-Verbose "Failed to save cache: $_"
    }
}

<#
.SYNOPSIS
    CPU load in percent, from two raw performance-counter samples.
.DESCRIPTION
    Win32_PerfRawData_PerfOS_Processor answers in ~27ms, but its PercentProcessorTime is a
    raw idle-time counter, so a percentage needs two samples and the time between them.
    The two cheaper-looking alternatives are both worse:
      * Win32_Processor.LoadPercentage makes the WMI provider sample the CPU itself and
        costs ~1050ms, over half the runtime of Show-SystemStats.
      * Get-Counter depends on the performance counter API, which is broken on some
        machines (it throws 'Internal performance counter API call failed', error c0000bb8)
        and then costs a failed call before any fallback runs.
    Returns $null when the counter cannot be read, so the caller can tell "no reading" from
    a genuine 0%.
#>

function Get-CpuLoadPercent {
    [CmdletBinding()]
    # 50ms is a long enough window for the counter delta to be meaningful, and it is pure
    # latency on every shell start - the profile waits for it.
    param([int]$SampleIntervalMs = 50)

    $query = {
        Get-CimInstance Win32_PerfRawData_PerfOS_Processor -Filter "Name='_Total'" `
            -Property PercentProcessorTime, Timestamp_Sys100NS -ErrorAction Stop
    }

    try {
        $first = & $query
        Start-Sleep -Milliseconds $SampleIntervalMs
        $second = & $query
    } catch {
        Write-Verbose "Raw performance counters unavailable: $_"
        return $null
    }

    $idleDelta = $second.PercentProcessorTime - $first.PercentProcessorTime
    $timeDelta = $second.Timestamp_Sys100NS - $first.Timestamp_Sys100NS
    if ($timeDelta -le 0) { return $null }

    $busy = 100 - (100 * $idleDelta / $timeDelta)
    [math]::Round([math]::Max(0, [math]::Min(100, $busy)), 0)
}

# Main function: Show system statistics
function Show-SystemStats {
    [CmdletBinding()]
    param(
        [switch]$Compact,
        [switch]$NoModuleStatus,
        [string]$ConfigPath,
        [switch]$RefreshCache
    )

    # Load custom config if provided
    if ($ConfigPath -and (Test-Path $ConfigPath)) {
        $Config = Get-Content $ConfigPath | ConvertFrom-Json
    } else {
        $Config = $script:Config
    }

    # Icon mode: config default, overridden by oh-my-pwsh global if set
    $useIcons = [bool]($Config.display.nerdFonts)
    if (Get-Variable -Name OhMyPwsh_UseNerdFonts -Scope Global -ErrorAction SilentlyContinue) {
        $useIcons = [bool]$global:OhMyPwsh_UseNerdFonts
    }

    try {
        Clear-Host
    } catch {
        Write-Host "`n`n"
    }

    # Try to load cached static data (unless refresh requested)
    $cachedData = $null
    if (-not $RefreshCache) {
        $cachedData = Get-SystemInfoCache
    }

    # Get or load static system information
    if ($cachedData) {
        # Use cached static data
        $cpuName = $cachedData.CPU.Name
        $cpuShort = $cachedData.CPU.ShortName
        $cpuCores = $cachedData.CPU.Cores
        $cpuThreads = $cachedData.CPU.Threads
        $cpuSpeed = $cachedData.CPU.Speed
        $ramTotal = $cachedData.RAM.Total
        $memSpeed = $cachedData.RAM.Speed
        $diskTotal = $cachedData.Disk.Total
        $osShort = $cachedData.OS.Short
        $winVer = $cachedData.OS.Version
        $winEdition = $cachedData.OS.Edition
        $osIcon = $cachedData.OS.Icon

        # Still need OS object for dynamic RAM calculation
        try {
            $os = Get-CimInstance Win32_OperatingSystem -Property $script:OsProperties -ErrorAction Stop
        } catch {
            Write-Error "Cannot access system information. Please check if WMI service is running or run PowerShell as Administrator."
            return
        }
    } else {
        # Query all static data (cache miss or refresh)
        try {
            $os = Get-CimInstance Win32_OperatingSystem -Property $script:OsProperties -ErrorAction Stop
        } catch {
            Write-Error "Cannot access system information. Please check if WMI service is running or run PowerShell as Administrator."
            return
        }

        try {
            $cpu = Get-CimInstance Win32_Processor -Property $script:CpuProperties -ErrorAction Stop | Select-Object -First 1
        } catch {
            Write-Error "Cannot access CPU information: $_"
            return
        }

        try {
            $mem = Get-CimInstance Win32_PhysicalMemory -Property Speed -ErrorAction Stop
        } catch {
            Write-Verbose "Cannot get RAM speed information, using default"
            $mem = $null
        }
    }

    # CPU load
    $cpuLoad = Get-CpuLoadPercent
    if ($null -eq $cpuLoad) {
        # Last resort: ~1050ms, but it is the only reading left.
        try {
            $cpuLoad = [math]::Round((Get-CimInstance Win32_Processor -Property LoadPercentage -ErrorAction Stop).LoadPercentage, 0)
        } catch {
            Write-Verbose "Cannot get CPU load, using 0%"
            $cpuLoad = 0
        }
    }

    # RAM calculations (dynamic data always queried)
    $ramUsed = [math]::Round(($os.TotalVisibleMemorySize - $os.FreePhysicalMemory) / 1024 / 1024, 1)
    if (-not $cachedData) {
        $ramTotal = [math]::Round($os.TotalVisibleMemorySize / 1024 / 1024, 1)
    }
    $ramPercent = [math]::Round(($ramUsed / $ramTotal) * 100, 0)

    # Memory speed (only if cache miss)
    if (-not $cachedData) {
        $memSpeed = $mem | Select-Object -First 1 | Select-Object -ExpandProperty Speed
        if (-not $memSpeed) { $memSpeed = "DDR4" } else { $memSpeed = "${memSpeed}MHz" }
    }

    # Disk calculations (always query for dynamic data)
    # DriveInfo, not Get-PSDrive: the provider call walks every PowerShell drive and costs
    # ~70ms, the .NET type reads the same two numbers from the filesystem in about none.
    $disk = try { [System.IO.DriveInfo]::new('C') } catch { $null }
    if ($disk -and $disk.IsReady) {
        $diskFree = $disk.TotalFreeSpace
        $diskSize = $disk.TotalSize
        $diskUsed = [math]::Round(($diskSize - $diskFree) / 1GB, 0)
        if (-not $cachedData) {
            $diskTotal = [math]::Round($diskSize / 1GB, 0)
        }
        $diskPercent = [math]::Round(($diskSize - $diskFree) / $diskSize * 100, 0)
    } else {
        $diskUsed = 0
        if (-not $cachedData) {
            $diskTotal = 0
        }
        $diskPercent = 0
    }

    # Process and uptime (dynamic data always queried)
    # One snapshot, counted twice - each Get-Process enumerates all ~600 processes.
    $allProcesses = Get-Process -ErrorAction SilentlyContinue
    $processCount = $allProcesses.Count
    try {
        $uptime = (Get-Date) - $os.LastBootUpTime
    } catch {
        Write-Verbose "Cannot get uptime, using 0"
        $uptime = New-TimeSpan -Days 0 -Hours 0
    }
    $terminalCount = @($allProcesses | Where-Object { $_.ProcessName -in @('pwsh', 'powershell', 'WindowsTerminal') }).Count

    # Static data processing (only on cache miss)
    if (-not $cachedData) {
        # CPU details
        if ($cpu) {
            $cpuName = $cpu.Name
            $cpuCores = $cpu.NumberOfCores
            $cpuThreads = $cpu.NumberOfLogicalProcessors
            $cpuSpeed = [math]::Round($cpu.MaxClockSpeed / 1000, 1)
        } else {
            $cpuName = "Unknown CPU"
            $cpuCores = 0
            $cpuThreads = 0
            $cpuSpeed = 0
        }

        # Format CPU name
        if ($cpuName -match "Intel.*Core.*i(\d)-(\d{4,5}\w*)") {
            # Intel Core i3/i5/i7/i9 (e.g., i7-8750H, i5-12400F)
            $cpuShort = "i$($matches[1])-$($matches[2])"
        } elseif ($cpuName -match "AMD Ryzen (\d+) (\d{4}\w*)") {
            # AMD Ryzen (e.g., Ryzen 5 5600X, Ryzen 7 5800X3D)
            $cpuShort = "Ryzen $($matches[1]) $($matches[2])"
        } elseif ($cpuName -match "AMD Ryzen (\d+) PRO (\d{4}\w*)") {
            # AMD Ryzen PRO
            $cpuShort = "Ryzen $($matches[1]) PRO $($matches[2])"
        } elseif ($cpuName -match "Intel.*Xeon.*(\w+)-(\d{4}\w*)") {
            # Intel Xeon (e.g., Xeon E-2288G)
            $cpuShort = "Xeon $($matches[1])-$($matches[2])"
        } elseif ($cpuName -match "Apple (\w+)") {
            # Apple Silicon (e.g., M1, M2, M3)
            $cpuShort = "Apple $($matches[1])"
        } else {
            # Fallback: clean up common patterns
            $cpuShort = $cpuName -replace "Intel\(R\) Core\(TM\) ", "" `
                                  -replace "AMD ", "" `
                                  -replace "Processor", "" `
                                  -replace "\(R\)", "" `
                                  -replace "\(TM\)", "" `
                                  -replace "\s+", " "
            $cpuShort = $cpuShort.Trim()
            # Limit length if too long
            if ($cpuShort.Length -gt 30) {
                $cpuShort = $cpuShort.Substring(0, 27) + "..."
            }
        }

        # OS version detection
        if ($IsWindows -or $PSVersionTable.PSVersion.Major -lt 6) {
            try {
                $winBuild = (Get-ItemProperty "HKLM:\SOFTWARE\Microsoft\Windows NT\CurrentVersion" -ErrorAction Stop).CurrentBuild
                $winVer = (Get-ItemProperty "HKLM:\SOFTWARE\Microsoft\Windows NT\CurrentVersion" -ErrorAction Stop).DisplayVersion
            } catch {
                Write-Verbose "Cannot access registry, using fallback Windows detection"
                $winBuild = "Unknown"
                $winVer = $null
            }

            # Detect Windows version by build number
            if ($winBuild -ne "Unknown") {
                $winMajorVersion = if ([int]$winBuild -ge 22000) { "Windows 11" } else { "Windows 10" }
            } else {
                $winMajorVersion = "Windows"
            }

            $winEdition = $os.Caption
            if ($winEdition -match "Home") { $winEdition = "$winMajorVersion Home" }
            elseif ($winEdition -match "Pro") { $winEdition = "$winMajorVersion Pro" }
            elseif ($winEdition -match "Enterprise") { $winEdition = "$winMajorVersion Enterprise" }
            elseif ($winEdition -match "Education") { $winEdition = "$winMajorVersion Education" }
            else { $winEdition = $winMajorVersion }

            # Use DisplayVersion if available, otherwise use build-based version
            if (-not $winVer -and $winBuild -ne "Unknown") {
                if ([int]$winBuild -ge 22631) { $winVer = "23H2" }
                elseif ([int]$winBuild -ge 22621) { $winVer = "22H2" }
                elseif ([int]$winBuild -ge 22000) { $winVer = "21H2" }
                elseif ([int]$winBuild -ge 19045) { $winVer = "22H2" }
                elseif ([int]$winBuild -ge 19044) { $winVer = "21H2" }
                elseif ([int]$winBuild -ge 19043) { $winVer = "21H1" }
                elseif ([int]$winBuild -ge 19042) { $winVer = "20H2" }
                else { $winVer = "Legacy" }
            } elseif ($winBuild -eq "Unknown") {
                $winVer = ""
            }

            $osShort = $winEdition -replace "Windows 11", "Win11" -replace "Windows 10", "Win10"
            $osIcon = $Config.icons.windows
        } elseif ($IsMacOS) {
            $osShort = "macOS"
            $osIcon = "0xF179"
        } elseif ($IsLinux) {
            $osShort = "Linux"
            $osIcon = "0xF17C"
        } else {
            $osShort = "Unknown"
            $osIcon = "0xF233"
        }

        # Save static data to cache
        $staticData = @{
            CPU = @{
                Name = $cpuName
                ShortName = $cpuShort
                Cores = $cpuCores
                Threads = $cpuThreads
                Speed = $cpuSpeed
            }
            RAM = @{
                Total = $ramTotal
                Speed = $memSpeed
            }
            Disk = @{
                Total = $diskTotal
            }
            OS = @{
                Short = $osShort
                Version = $winVer
                Edition = $winEdition
                Icon = $osIcon
            }
        }
        Save-SystemInfoCache -SystemInfo $staticData
    }

    # Precompute icon set: Nerd Font hex codes or Unicode fallbacks
    if ($useIcons) {
        $osIconResolved = Get-Icon $osIcon
        $icons = @{
            user       = Get-Icon $Config.icons.user
            computer   = Get-Icon $Config.icons.computer
            os         = $osIconResolved
            powershell = Get-Icon $Config.icons.powershell
            cpu        = Get-Icon $Config.icons.cpu
            cpuChip    = Get-Icon $Config.icons.cpuChip
            ram        = Get-Icon $Config.icons.ram
            ramFreq    = Get-Icon $Config.icons.ramFreq
            disk       = Get-Icon $Config.icons.disk
            folder     = Get-Icon $Config.icons.folder
            terminal   = Get-Icon $Config.icons.terminal
            process    = Get-Icon $Config.icons.process
            uptime     = Get-Icon $Config.icons.uptime
        }
    } else {
        $osIconUnicode = if ($IsMacOS) { $Config.unicodeIcons.macos } elseif ($IsLinux) { $Config.unicodeIcons.linux } else { $Config.unicodeIcons.windows }
        $icons = @{
            user       = $Config.unicodeIcons.user
            computer   = $Config.unicodeIcons.computer
            os         = $osIconUnicode
            powershell = $Config.unicodeIcons.powershell
            cpu        = $Config.unicodeIcons.cpu
            cpuChip    = $Config.unicodeIcons.cpuChip
            ram        = $Config.unicodeIcons.ram
            ramFreq    = $Config.unicodeIcons.ramFreq
            disk       = $Config.unicodeIcons.disk
            folder     = $Config.unicodeIcons.folder
            terminal   = $Config.unicodeIcons.terminal
            process    = $Config.unicodeIcons.process
            uptime     = $Config.unicodeIcons.uptime
        }
    }

    # Display header
    Write-Host ""
    Write-Host " " -NoNewline
    if ($icons.user) { Write-Host "$($icons.user) " -NoNewline -ForegroundColor $Config.colors.user }
    Write-Host "$env:USERNAME " -NoNewline -ForegroundColor $Config.colors.user
    Write-Host "@ " -NoNewline -ForegroundColor DarkGray
    if ($icons.computer) { Write-Host "$($icons.computer) " -NoNewline -ForegroundColor $Config.colors.computer }
    Write-Host "$env:COMPUTERNAME " -NoNewline -ForegroundColor $Config.colors.computer
    Write-Host " │ " -NoNewline -ForegroundColor DarkGray
    if ($icons.os) { Write-Host "$($icons.os) " -NoNewline -ForegroundColor $Config.colors.os }
    Write-Host "$osShort x64 $winVer " -NoNewline -ForegroundColor $Config.colors.os
    Write-Host " │ " -NoNewline -ForegroundColor DarkGray
    if ($icons.powershell) { Write-Host "$($icons.powershell) " -NoNewline -ForegroundColor $Config.colors.shell }
    Write-Host "PowerShell v$($PSVersionTable.PSVersion)" -ForegroundColor $Config.colors.shell
    Write-Host ""

    # CPU display
    if ($Config.modules.cpu) {
        Write-Host " " -NoNewline
        if ($icons.cpu) { Write-Host "$($icons.cpu) " -NoNewline -ForegroundColor $Config.colors.cpu }
        Write-Host "CPU " -NoNewline -ForegroundColor $Config.colors.cpu
        Write-Host "$(Draw-ProgressBar -Percent $cpuLoad -Width $Config.display.progressBarWidth) " -NoNewline
        $cpuColor = if ($cpuLoad -ge 80) { "Red" } elseif ($cpuLoad -ge 60) { "Yellow" } else { "Green" }
        Write-Host "$($cpuLoad.ToString().PadLeft(2))% " -NoNewline -ForegroundColor $cpuColor
        $cpuDetail = if ($icons.cpuChip) { "$($icons.cpuChip) $cpuShort" } else { $cpuShort }
        Write-Host "[$cpuDetail @ ${cpuSpeed}GHz ${cpuCores}c/${cpuThreads}t]" -ForegroundColor $Config.colors.details
    }

    # RAM display
    if ($Config.modules.ram) {
        Write-Host " " -NoNewline
        if ($icons.ram) { Write-Host "$($icons.ram) " -NoNewline -ForegroundColor $Config.colors.ram }
        Write-Host "RAM " -NoNewline -ForegroundColor $Config.colors.ram
        Write-Host "$(Draw-ProgressBar -Percent $ramPercent -Width $Config.display.progressBarWidth) " -NoNewline
        $ramColor = if ($ramPercent -ge 80) { "Red" } elseif ($ramPercent -ge 60) { "Yellow" } else { "Green" }
        Write-Host "$($ramPercent.ToString().PadLeft(2))% " -NoNewline -ForegroundColor $ramColor
        Write-Host "(${ramUsed}GB/${ramTotal}GB) " -NoNewline -ForegroundColor $Config.colors.info
        $freqDetail = if ($icons.ramFreq) { "$($icons.ramFreq)$memSpeed" } else { $memSpeed }
        Write-Host "[$freqDetail]" -ForegroundColor $Config.colors.details
    }

    # Disk display
    if ($Config.modules.disk -and $disk) {
        Write-Host " " -NoNewline
        if ($icons.disk) { Write-Host "$($icons.disk) " -NoNewline -ForegroundColor $Config.colors.disk }
        Write-Host "HDD " -NoNewline -ForegroundColor $Config.colors.disk
        Write-Host "$(Draw-ProgressBar -Percent $diskPercent -Width $Config.display.progressBarWidth) " -NoNewline
        $diskColor = if ($diskPercent -ge 80) { "Red" } elseif ($diskPercent -ge 60) { "Yellow" } else { "Green" }
        Write-Host "$($diskPercent.ToString().PadLeft(2))% " -NoNewline -ForegroundColor $diskColor
        Write-Host "(${diskUsed}GB/${diskTotal}GB) " -NoNewline -ForegroundColor $Config.colors.info
        $folderDetail = if ($icons.folder) { "$($icons.folder) C:\" } else { "C:\" }
        Write-Host "[$folderDetail]" -ForegroundColor $Config.colors.details
    }

    # Stats bar
    if ($Config.modules.terminals -or $Config.modules.processes -or $Config.modules.uptime) {
        Write-Host ""
        Write-Host " [ " -NoNewline -ForegroundColor DarkGray

        if ($Config.modules.terminals) {
            if ($icons.terminal) { Write-Host "$($icons.terminal) " -NoNewline -ForegroundColor Cyan }
            Write-Host "$terminalCount terminals" -NoNewline -ForegroundColor Cyan
        }

        if ($Config.modules.processes) {
            Write-Host " │ " -NoNewline -ForegroundColor DarkGray
            if ($icons.process) { Write-Host "$($icons.process) " -NoNewline -ForegroundColor Green }
            Write-Host "$processCount processes" -NoNewline -ForegroundColor Green
        }

        if ($Config.modules.uptime) {
            Write-Host " │ " -NoNewline -ForegroundColor DarkGray
            if ($icons.uptime) { Write-Host "$($icons.uptime) " -NoNewline -ForegroundColor Yellow }
            Write-Host "$($uptime.Days)d $($uptime.Hours)h uptime" -NoNewline -ForegroundColor Yellow
        }

        Write-Host " ]" -ForegroundColor DarkGray
    }

    Write-Host ""
}

# Export functions
Export-ModuleMember -Function Show-SystemStats, Draw-ProgressBar, Get-Icon