lib/core.ps1

# WinMole Core Utilities
# Speed-optimized helpers used across all WinMole commands

Set-StrictMode -Version Latest

# ── Version ──────────────────────────────────────────────────────────────────
$script:WINMOLE_VERSION = '0.1.3'

# ── PwshSpectreConsole ────────────────────────────────────────────────────────
if (-not (Get-Module PwshSpectreConsole)) {
    Import-Module PwshSpectreConsole -ErrorAction Stop
}

function script:Esc {
    param([string]$Text)
    if (-not $Text) { return '' }
    return [Spectre.Console.Markup]::Escape($Text)
}

function Get-OptionalPropertyValue {
    param(
        [object]$InputObject,
        [string]$Name,
        $Default = $null
    )
    if ($null -eq $InputObject) { return $Default }
    $property = $InputObject.PSObject.Properties[$Name]
    if ($null -eq $property) { return $Default }
    if ($null -eq $property.Value) { return $Default }
    return $property.Value
}

function Split-CommandLine {
    param([string]$CommandLine)
    if ([string]::IsNullOrWhiteSpace($CommandLine)) {
        return [pscustomobject]@{
            FilePath     = ''
            ArgumentList = ''
        }
    }

    if ($CommandLine -match '^"([^"]+)"\s*(.*)$') {
        return [pscustomobject]@{
            FilePath     = $Matches[1]
            ArgumentList = $Matches[2]
        }
    }

    if ($CommandLine -match '^(\S+)\s*(.*)$') {
        return [pscustomobject]@{
            FilePath     = $Matches[1]
            ArgumentList = $Matches[2]
        }
    }

    return [pscustomobject]@{
        FilePath     = $CommandLine
        ArgumentList = ''
    }
}

function Get-UninstallInvocation {
    param(
        [string]$UninstallString,
        [string]$QuietUninstallString
    )

    $commandLine = if ($QuietUninstallString) { $QuietUninstallString } else { $UninstallString }
    $invocation = Split-CommandLine -CommandLine $commandLine
    if (-not $invocation.FilePath) { return $null }

    if ($QuietUninstallString) { return $invocation }

    $fileName = [System.IO.Path]::GetFileName($invocation.FilePath)
    $args = [string]$invocation.ArgumentList

    switch -Regex ($fileName) {
        '^(?i)msiexec(?:\.exe)?$' {
            if ($args -match '(?i)(^|\s)/i(?=\s|[{"])') {
                $args = [regex]::Replace($args, '(?i)(^|\s)/i(?=\s|[{"])', '$1/X', 1)
            } elseif ($args -notmatch '(?i)(^|\s)/x(?=\s|[{"])') {
                $args = "/X $args".Trim()
            }

            if ($args -notmatch '(?i)(^|\s)/(qn|quiet)(?=\s|$)') {
                $args = "$args /qn /norestart".Trim()
            }
            break
        }
        '^(?i)winget(?:\.exe)?$' {
            if ($args -match '(?i)\buninstall\b') {
                $neededFlags = @(
                    '--silent'
                    '--disable-interactivity'
                    '--accept-source-agreements'
                    '--accept-package-agreements'
                )
                foreach ($flag in $neededFlags) {
                    if ($args -notmatch [regex]::Escape($flag)) {
                        $args += " $flag"
                    }
                }
                $args = $args.Trim()
            }
            break
        }
    }

    return [pscustomobject]@{
        FilePath     = $invocation.FilePath
        ArgumentList = $args
    }
}

# ── Output helpers ────────────────────────────────────────────────────────────
function Write-Success {
    param([string]$Message, [string]$Right = '')
    if ($Right) {
        $pad = [Math]::Max(0, 56 - $Message.Length)
        Write-SpectreHost " [green]✓[/] $(Esc $Message)$(' ' * $pad)[gold1]$(Esc $Right)[/]"
    } else {
        Write-SpectreHost " [green]✓[/] $(Esc $Message)"
    }
}

function Write-Info {
    param([string]$Message)
    Write-SpectreHost " [teal]→[/] $(Esc $Message)"
}

function Write-Warn {
    param([string]$Message)
    Write-SpectreHost " [yellow]⚠[/] $(Esc $Message)"
}

function Write-Err {
    param([string]$Message)
    Write-SpectreHost " [red]✗[/] $(Esc $Message)"
}

function Write-Header {
    param([string]$Title, [string]$Sub = '')
    Write-Host ""
    $line = " [bold deepskyblue1]$(Esc $Title)[/]"
    if ($Sub) { $line += " [grey]$(Esc $Sub)[/]" }
    Write-SpectreHost $line
    Write-Host ""
}

function Write-Divider {
    Write-SpectreHost "[grey]$('=' * 68)[/]"
}

function Write-Summary {
    param([string]$Label, [string]$Value)
    Write-Divider
    Write-SpectreHost " [bold]$(Esc $Label)[/] [green]$(Esc $Value)[/]"
    Write-Divider
    Write-Host ""
}

# ── Size formatting ───────────────────────────────────────────────────────────
function Format-Bytes {
    param([long]$Bytes)
    if ($Bytes -ge 1TB) { return '{0:F1}TB' -f ($Bytes / 1TB) }
    if ($Bytes -ge 1GB) { return '{0:F1}GB' -f ($Bytes / 1GB) }
    if ($Bytes -ge 1MB) { return '{0:F1}MB' -f ($Bytes / 1MB) }
    if ($Bytes -ge 1KB) { return '{0:F1}KB' -f ($Bytes / 1KB) }
    return "${Bytes}B"
}

# ── Fast directory-size calculation using .NET enumeration ────────────────────
function Get-DirectorySize {
    param([string]$Path)
    $total = [long]0
    try {
        $opts = [System.IO.EnumerationOptions]::new()
        $opts.IgnoreInaccessible = $true
        $opts.RecurseSubdirectories = $true
        foreach ($f in [System.IO.Directory]::EnumerateFiles($Path, '*', $opts)) {
            try { $total += (New-Object System.IO.FileInfo($f)).Length } catch {}
        }
    } catch {}
    return $total
}

# ── Fast parallel directory sizes using runspaces ─────────────────────────────
function Get-DirectorySizes {
    param([string[]]$Paths)
    $results = @{}
    if ($Paths.Count -eq 0) { return $results }

    $pool = [runspacefactory]::CreateRunspacePool(
        1, [Math]::Min($Paths.Count, [Environment]::ProcessorCount * 2))
    $pool.Open()

    $sizeBlock = {
        param($p)
        $total = [long]0
        try {
            $opts = [System.IO.EnumerationOptions]::new()
            $opts.IgnoreInaccessible = $true
            $opts.RecurseSubdirectories = $true
            foreach ($f in [System.IO.Directory]::EnumerateFiles($p, '*', $opts)) {
                try { $total += [System.IO.FileInfo]::new($f).Length } catch {}
            }
        } catch {}
        [pscustomobject]@{ Path = $p; Size = $total }
    }

    $handles = foreach ($path in $Paths) {
        $ps = [System.Management.Automation.PowerShell]::Create()
        $ps.RunspacePool = $pool
        [void]$ps.AddScript($sizeBlock).AddArgument($path)
        [pscustomobject]@{ PS = $ps; Handle = $ps.BeginInvoke() }
    }

    foreach ($h in $handles) {
        try {
            $r = $h.PS.EndInvoke($h.Handle)
            if ($r) { $results[$r.Path] = $r.Size }
        } catch {}
        $h.PS.Dispose()
    }
    $pool.Close()
    $pool.Dispose()
    return $results
}

# ── Safe file removal ─────────────────────────────────────────────────────────
function Remove-ItemSafe {
    param([string]$Path, [switch]$Recurse, [switch]$DryRun)
    if (-not (Test-Path $Path)) { return 0L }
    $size = [long]0
    try {
        if ($Recurse) {
            $size = Get-DirectorySize -Path $Path
        } else {
            $size = (New-Object System.IO.FileInfo($Path)).Length
        }
        if (-not $DryRun) {
            Remove-Item -LiteralPath $Path -Recurse:$Recurse -Force -ErrorAction Stop
        }
    } catch {
        # Deletion failed — don't count as freed
        return 0L
    }
    return $size
}

# ── Progress bar ──────────────────────────────────────────────────────────────
function Get-ProgressBar {
    param([double]$Pct, [int]$Width = 20, [string]$Color = '')
    $filled = [int]([Math]::Round($Pct / 100.0 * $Width))
    $filled = [Math]::Max(0, [Math]::Min($filled, $Width))
    $empty  = $Width - $filled
    $bar    = '█' * $filled + '░' * $empty
    if ($Color) { return "[$Color]$bar[/]" }
    return $bar
}

# ── Mini spark bar (5-block) ──────────────────────────────────────────────────
function Get-SparkBar {
    param([double]$Pct)
    $blocks = @('▯','▮▯▯▯▯','▮▮▯▯▯','▮▮▮▯▯','▮▮▮▮▯','▮▮▮▮▮')
    $idx = [int]([Math]::Round($Pct / 100.0 * ($blocks.Count - 1)))
    $idx = [Math]::Max(0, [Math]::Min($idx, $blocks.Count - 1))
    return $blocks[$idx]
}

# ── Confirmation prompt ───────────────────────────────────────────────────────
function Confirm-Action {
    param([string]$Message = 'Proceed?')
    Write-Host ""
    try {
        return Read-SpectreConfirm -Prompt $Message -DefaultAnswer n
    } catch {
        return $false
    }
}

# ── Whitelist support ─────────────────────────────────────────────────────────
$_home = if ($env:USERPROFILE) { $env:USERPROFILE } else { $env:HOME }
$script:WINMOLE_CONFIG = Join-Path $_home '.config\winmole'
$script:WHITELIST_FILE  = Join-Path $script:WINMOLE_CONFIG 'whitelist.txt'

function Get-Whitelist {
    if (-not (Test-Path $script:WHITELIST_FILE)) { return @() }
    return Get-Content $script:WHITELIST_FILE -ErrorAction SilentlyContinue |
        Where-Object { $_ -and -not $_.StartsWith('#') }
}

function Test-Whitelisted {
    param([string]$Path, [string[]]$Whitelist)
    foreach ($w in $Whitelist) {
        if ($Path -like $w) { return $true }
    }
    return $false
}

# ── Admin check ───────────────────────────────────────────────────────────────
function Test-IsAdmin {
    try {
        if (-not [System.Runtime.InteropServices.RuntimeInformation]::IsOSPlatform(
                [System.Runtime.InteropServices.OSPlatform]::Windows)) {
            return $false
        }
        $id = [System.Security.Principal.WindowsIdentity]::GetCurrent()
        $p  = [System.Security.Principal.WindowsPrincipal]$id
        return $p.IsInRole([System.Security.Principal.WindowsBuiltInRole]::Administrator)
    } catch { return $false }
}

# ── System info cache ─────────────────────────────────────────────────────────
$script:_sysInfoCache = $null

function Get-SysInfoSummary {
    if ($script:_sysInfoCache) { return $script:_sysInfoCache }
    try {
        $os   = Get-CimInstance -ClassName Win32_OperatingSystem -ErrorAction SilentlyContinue
        $cs   = Get-CimInstance -ClassName Win32_ComputerSystem  -ErrorAction SilentlyContinue
        $proc = Get-CimInstance -ClassName Win32_Processor        -ErrorAction SilentlyContinue | Select-Object -First 1
        $disk = Get-CimInstance -ClassName Win32_LogicalDisk -Filter "DriveType=3" -ErrorAction SilentlyContinue |
                    Select-Object -First 1
        $totalRam = [Math]::Round($cs.TotalPhysicalMemory / 1GB, 0)
        $usedRam  = [Math]::Round(($cs.TotalPhysicalMemory - $os.FreePhysicalMemory * 1KB) / 1GB, 1)
        $diskUsed = [Math]::Round(($disk.Size - $disk.FreeSpace) / 1GB, 0)
        $diskTot  = [Math]::Round($disk.Size / 1GB, 0)
        $uptime   = Format-Uptime -Seconds ([long](New-TimeSpan -Start $os.LastBootUpTime -End (Get-Date)).TotalSeconds)
        $script:_sysInfoCache = [pscustomobject]@{
            MachineName = $env:COMPUTERNAME
            CPU         = $proc.Name -replace '\s{2,}',' '
            RamTotalGB  = $totalRam
            RamUsedGB   = $usedRam
            DiskUsedGB  = $diskUsed
            DiskTotalGB = $diskTot
            OSCaption   = $os.Caption
            Uptime      = $uptime
        }
    } catch {
        $script:_sysInfoCache = [pscustomobject]@{
            MachineName = $env:COMPUTERNAME
            CPU = 'Unknown'; RamTotalGB = 0; RamUsedGB = 0
            DiskUsedGB = 0; DiskTotalGB = 0
            OSCaption = 'Windows'; Uptime = '?'
        }
    }
    return $script:_sysInfoCache
}

function Format-Uptime {
    param([long]$Seconds)
    $d = [int]($Seconds / 86400)
    $h = [int](($Seconds % 86400) / 3600)
    $m = [int](($Seconds % 3600) / 60)
    if ($d -gt 0) { return "${d}d ${h}h" }
    if ($h -gt 0) { return "${h}h ${m}m" }
    return "${m}m"
}

# ── Log setup ─────────────────────────────────────────────────────────────────
$_homeLog = if ($env:USERPROFILE) { $env:USERPROFILE } else { $env:HOME }
$script:LOG_DIR  = Join-Path $_homeLog '.cache\winmole\logs'
$script:LOG_FILE = Join-Path $script:LOG_DIR 'operations.log'

function Write-OpLog {
    param([string]$Message)
    if ($env:WINMOLE_NO_OPLOG -eq '1') { return }
    try {
        if (-not (Test-Path $script:LOG_DIR)) {
            [void][System.IO.Directory]::CreateDirectory($script:LOG_DIR)
        }
        $ts = Get-Date -Format 'yyyy-MM-dd HH:mm:ss'
        "$ts $Message" | Add-Content -LiteralPath $script:LOG_FILE -ErrorAction SilentlyContinue
    } catch {}
}