# RescuePC Protected Module - DO NOT EDIT
# Source code is maintained separately in the development repository.

Set-StrictMode -Version Latest
$ErrorActionPreference = "Stop"

$script:LOG_ROOT = Join-Path $env:ProgramData "RescuePC\logs"
$script:MAX_RUNS_TOTAL = 200
$script:MAX_LOG_AGE_DAYS = 30
$script:MAX_BYTES_TOTAL = 200MB
$script:MAX_OUTPUT_CHARS = 32768

function Test-Admin {
    $id = [Security.Principal.WindowsIdentity]::GetCurrent()
    $p = New-Object Security.Principal.WindowsPrincipal($id)
    return $p.IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator)
}

function New-RunId {
    return [Guid]::NewGuid().ToString("n").Substring(0, 12)
}

function Get-LogRoot {
    if (-not (Test-Path $script:LOG_ROOT)) {
        New-Item -ItemType Directory -Path $script:LOG_ROOT -Force | Out-Null
    }
    return $script:LOG_ROOT
}

function Write-JsonFile {
    param(
        [Parameter(Mandatory=$true)][string]$Path,
        [Parameter(Mandatory=$true)][object]$Object
    )
    $json = $Object | ConvertTo-Json -Depth 10 -Compress
    [System.IO.File]::WriteAllText($Path, $json, [System.Text.Encoding]::UTF8)
}

function Truncate-String {
    param(
        [Parameter(Mandatory=$true)][AllowNull()][string]$Text,
        [int]$MaxChars = $script:MAX_OUTPUT_CHARS
    )
    if ($null -eq $Text -or $Text -eq "") {
        return [pscustomobject]@{ Text = ""; Truncated = $false; OriginalLength = 0 }
    }
    $len = $Text.Length
    if ($len -le $MaxChars) {
        return [pscustomobject]@{ Text = $Text; Truncated = $false; OriginalLength = $len }
    }
    return [pscustomobject]@{
        Text = $Text.Substring(0, $MaxChars)
        Truncated = $true
        OriginalLength = $len
    }
}

function Get-DirectorySizeBytes {
    param([Parameter(Mandatory=$true)][string]$Path)
    if (-not (Test-Path $Path)) { return 0 }
    $sum = 0
    Get-ChildItem -Path $Path -Recurse -File -ErrorAction SilentlyContinue | ForEach-Object {
        $sum += $_.Length
    }
    return $sum
}

function Enforce-LogRetention {
    param(
        [Parameter(Mandatory=$true)][string]$LogRoot,
        [int]$MaxRunsTotal = $script:MAX_RUNS_TOTAL,
        [int]$MaxAgeDays = $script:MAX_LOG_AGE_DAYS,
        [int64]$MaxBytesTotal = $script:MAX_BYTES_TOTAL
    )

    if (-not (Test-Path $LogRoot)) { return }

    $allLogs = @(Get-ChildItem -Path $LogRoot -Recurse -File -Filter "*.json" -ErrorAction SilentlyContinue |
        Sort-Object LastWriteTimeUtc -Descending)

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

    $cutoff = (Get-Date).ToUniversalTime().AddDays(-1 * $MaxAgeDays)
    foreach ($log in $allLogs) {
        if ($log.LastWriteTimeUtc -lt $cutoff) {
            try { Remove-Item -Path $log.FullName -Force -ErrorAction SilentlyContinue } catch {}
        }
    }

    $allLogs = @(Get-ChildItem -Path $LogRoot -Recurse -File -Filter "*.json" -ErrorAction SilentlyContinue |
        Sort-Object LastWriteTimeUtc -Descending)

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

    if ($allLogs.Count -gt $MaxRunsTotal) {
        $toDelete = $allLogs | Select-Object -Skip $MaxRunsTotal
        foreach ($log in $toDelete) {
            try { Remove-Item -Path $log.FullName -Force -ErrorAction SilentlyContinue } catch {}
        }
    }

    $allLogs = @(Get-ChildItem -Path $LogRoot -Recurse -File -Filter "*.json" -ErrorAction SilentlyContinue |
        Sort-Object LastWriteTimeUtc)

    $totalBytes = ($allLogs | Measure-Object -Property Length -Sum).Sum
    if ($null -eq $totalBytes) { $totalBytes = 0 }

    foreach ($log in $allLogs) {
        if ($totalBytes -le $MaxBytesTotal) { break }
        $sz = $log.Length
        try { Remove-Item -Path $log.FullName -Force -ErrorAction SilentlyContinue } catch {}
        $totalBytes -= $sz
    }

    Get-ChildItem -Path $LogRoot -Directory -ErrorAction SilentlyContinue |
        Where-Object { @(Get-ChildItem $_.FullName -ErrorAction SilentlyContinue).Count -eq 0 } |
        ForEach-Object {
            try { Remove-Item $_.FullName -Force -ErrorAction SilentlyContinue } catch {}
        }
}

function Invoke-NativeCommand {
    param(
        [Parameter(Mandatory=$true, Position=0)]
        [string]$Command,

        [Parameter(Mandatory=$false)]
        [string]$Description = "",

        [Parameter(Mandatory=$false)]
        [int[]]$SuccessCodes = @(0),

        [Parameter(Mandatory=$false)]
        [int]$TimeoutSeconds = 300,

        [switch]$Silent
    )

    if (-not $Silent -and $Description) {
        Write-Verbose "Running: $Description"
    }

    try {
        $psi = New-Object System.Diagnostics.ProcessStartInfo
        $psi.FileName = "cmd.exe"
        $psi.Arguments = "/c $Command"
        $psi.RedirectStandardOutput = $true
        $psi.RedirectStandardError = $true
        $psi.UseShellExecute = $false
        $psi.CreateNoWindow = $true

        $process = New-Object System.Diagnostics.Process
        $process.StartInfo = $psi
        $null = $process.Start()

        $stdout = $process.StandardOutput.ReadToEnd()
        $stderr = $process.StandardError.ReadToEnd()

        if (-not $process.WaitForExit($TimeoutSeconds * 1000)) {
            try { $process.Kill() } catch {}
            Write-Warning "Command timed out after ${TimeoutSeconds}s: $Command"
            return $false
        }

        $exitCode = $process.ExitCode

        if ($exitCode -in $SuccessCodes) {
            if (-not $Silent) {
                Write-Verbose "Command SUCCESS (Exit Code: $exitCode): $Command"
            }
            return $true
        }
        else {
            if (-not $Silent) {
                Write-Verbose "Command FAILED (Exit Code: $exitCode): $Command"
                if ($stderr) {
                    Write-Verbose "StdErr: $stderr"
                }
            }
            return $false
        }
    }
    catch {
        Write-Warning "Exception running command '$Command': $($_.Exception.Message)"
        return $false
    }
}

function Invoke-LoggedCommand {
    param(
        [Parameter(Mandatory=$true)][string]$FilePath,
        [Parameter(Mandatory=$true)][string[]]$Arguments,
        [int]$TimeoutSeconds = 1800
    )

    $psi = New-Object System.Diagnostics.ProcessStartInfo
    $psi.FileName = $FilePath
    $psi.Arguments = ($Arguments -join " ")
    $psi.RedirectStandardOutput = $true
    $psi.RedirectStandardError = $true
    $psi.UseShellExecute = $false
    $psi.CreateNoWindow = $true

    $p = New-Object System.Diagnostics.Process
    $p.StartInfo = $psi

    $null = $p.Start()

    if (-not $p.WaitForExit($TimeoutSeconds * 1000)) {
        try { $p.Kill() } catch {}
        throw "Command timed out after ${TimeoutSeconds}s: $FilePath"
    }

    $p.WaitForExit()

    $rawOut = $p.StandardOutput.ReadToEnd()
    $rawErr = $p.StandardError.ReadToEnd()
    $out = Truncate-String -Text $rawOut -MaxChars $script:MAX_OUTPUT_CHARS
    $err = Truncate-String -Text $rawErr -MaxChars $script:MAX_OUTPUT_CHARS

    return [pscustomobject]@{
        ExitCode = $p.ExitCode
        StdOut = $out.Text
        StdErr = $err.Text
        StdOutTruncated = $out.Truncated
        StdErrTruncated = $err.Truncated
        StdOutOriginalLength = $out.OriginalLength
        StdErrOriginalLength = $err.OriginalLength
        Command = "$FilePath $($Arguments -join ' ')"
    }
}

function New-RestorePointIfPossible {
    param(
        [Parameter(Mandatory=$true)][string]$Description
    )
    try {
        $srEnabled = $null -ne (Get-ComputerRestorePoint -ErrorAction SilentlyContinue)
        if (-not $srEnabled) {
            Enable-ComputerRestore -Drive "$env:SystemDrive\" -ErrorAction SilentlyContinue
        }
        Checkpoint-Computer -Description $Description -RestorePointType "MODIFY_SETTINGS" -ErrorAction Stop
        return $true
    } catch {
        return $false
    }
}

function Get-BootTimeSeconds {
    try {
        $e = Get-WinEvent -FilterHashtable @{
            LogName = "Microsoft-Windows-Diagnostics-Performance/Operational"
            Id = 100
        } -MaxEvents 1 -ErrorAction SilentlyContinue

        if (-not $e) { return $null }

        $xml = [xml]$e.ToXml()
        $bootMs = ($xml.Event.EventData.Data | Where-Object { $_.Name -eq "BootTime" })."#text"
        return [math]::Round(([double]$bootMs / 1000.0), 2)
    } catch {
        return $null
    }
}

function Get-DiskQueueLength {
    try {
        $counter = Get-Counter "\PhysicalDisk(_Total)\Avg. Disk Queue Length" -ErrorAction SilentlyContinue
        return [math]::Round($counter.CounterSamples[0].CookedValue, 2)
    } catch {
        return $null
    }
}

function Get-QuickHealthSnapshot {
    $snapshot = [ordered]@{
        TimestampUtc = (Get-Date).ToUniversalTime().ToString("o")
        CpuPercent = $null
        MemFreeMB = $null
        MemTotalMB = $null
        MemUsedPercent = $null
        DiskQueueLength = $null
        BootTimeSec = $null
    }

    try {
        $cpu = (Get-Counter "\Processor(_Total)\% Processor Time" -ErrorAction SilentlyContinue).CounterSamples[0].CookedValue
        $snapshot.CpuPercent = [math]::Round($cpu, 1)
    } catch {}

    try {
        $mem = Get-CimInstance Win32_OperatingSystem -ErrorAction SilentlyContinue
        $snapshot.MemFreeMB = [math]::Round(($mem.FreePhysicalMemory / 1024.0), 0)
        $snapshot.MemTotalMB = [math]::Round(($mem.TotalVisibleMemorySize / 1024.0), 0)
        $snapshot.MemUsedPercent = [math]::Round((1 - ($mem.FreePhysicalMemory / $mem.TotalVisibleMemorySize)) * 100, 1)
    } catch {}

    $snapshot.DiskQueueLength = Get-DiskQueueLength
    $snapshot.BootTimeSec = Get-BootTimeSeconds

    return [pscustomobject]$snapshot
}

function Get-RepairVerdict {
    param(
        [pscustomobject]$Before,
        [pscustomobject]$After,
        [string]$RepairType = "general"
    )

    if (-not $Before -or -not $After) {
        return "UNKNOWN"
    }

    $improvements = 0
    $degradations = 0

    if ($null -ne $Before.CpuPercent -and $null -ne $After.CpuPercent) {
        $cpuDelta = $After.CpuPercent - $Before.CpuPercent
        if ($cpuDelta -lt -5) { $improvements++ }
        elseif ($cpuDelta -gt 10) { $degradations++ }
    }

    if ($null -ne $Before.MemFreeMB -and $null -ne $After.MemFreeMB) {
        $memDelta = $After.MemFreeMB - $Before.MemFreeMB
        if ($memDelta -gt 100) { $improvements++ }
        elseif ($memDelta -lt -200) { $degradations++ }
    }

    if ($null -ne $Before.DiskQueueLength -and $null -ne $After.DiskQueueLength) {
        $diskDelta = $After.DiskQueueLength - $Before.DiskQueueLength
        if ($diskDelta -lt -0.5) { $improvements++ }
        elseif ($diskDelta -gt 1) { $degradations++ }
    }

    if ($degradations -gt 0 -and $degradations -gt $improvements) {
        return "DEGRADED"
    } elseif ($improvements -gt 0) {
        return "IMPROVED"
    } else {
        return "NO_CHANGE"
    }
}

function Invoke-RescuePCRepair {
    param(
        [Parameter(Mandatory=$true)][string]$RepairId,
        [Parameter(Mandatory=$true)][ValidateRange(0,3)][int]$Tier,
        [Parameter(Mandatory=$true)][scriptblock]$Action,
        [scriptblock]$Precheck = { return $true },
        [scriptblock]$Postcheck = { return $true },
        [switch]$RequireAdmin,
        [switch]$CreateRestorePoint,
        [string]$Description = ""
    )

    $runId = New-RunId
    $logRoot = Get-LogRoot
    $repairDir = Join-Path $logRoot $RepairId

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

    $timestamp = (Get-Date).ToString("yyyyMMdd-HHmmss")
    $logFile = Join-Path $repairDir "$timestamp-$runId.json"

    $result = [ordered]@{
        RepairId = $RepairId
        RunId = $runId
        Tier = $Tier
        Description = $Description
        StartedUtc = (Get-Date).ToUniversalTime().ToString("o")
        FinishedUtc = $null
        DurationSeconds = $null
        RequiresAdmin = [bool]$RequireAdmin
        AdminDetected = (Test-Admin)
        RestorePointAttempted = $false
        RestorePointCreated = $false
        PrecheckPassed = $false
        PostcheckPassed = $false
        Before = $null
        After = $null
        Verdict = "UNKNOWN"
        Errors = @()
        Output = @()
    }

    $startTime = Get-Date

    try {
        if ($RequireAdmin -and -not $result.AdminDetected) {
            throw "Administrator rights required for Tier $Tier repair."
        }

        if ($Tier -ge 2 -or $CreateRestorePoint) {
            $result.RestorePointAttempted = $true
            Write-Host "  Creating restore point..." -ForegroundColor Gray
            $result.RestorePointCreated = (New-RestorePointIfPossible -Description "RescuePC: $RepairId")
            if ($result.RestorePointCreated) {
                Write-Host "  [OK] Restore point created" -ForegroundColor Green
            } else {
                Write-Host "  [WARN] Could not create restore point" -ForegroundColor Yellow
            }
        }

        Write-Host "  Running precheck..." -ForegroundColor Gray
        $pre = & $Precheck
        $result.PrecheckPassed = [bool]$pre

        if (-not $result.PrecheckPassed) {
            $result.Verdict = "SKIPPED_PRECHECK"
            Write-Host "  [SKIP] Precheck failed - repair not needed or not applicable" -ForegroundColor Yellow
            return [pscustomobject]$result
        }

        Write-Host "  Capturing before metrics..." -ForegroundColor Gray
        $result.Before = Get-QuickHealthSnapshot

        Write-Host "  Executing repair..." -ForegroundColor Cyan
        $actionOutput = & $Action
        if ($actionOutput) {
            $result.Output = @($actionOutput | Out-String)
        }

        Start-Sleep -Seconds 2

        Write-Host "  Capturing after metrics..." -ForegroundColor Gray
        $result.After = Get-QuickHealthSnapshot

        Write-Host "  Running postcheck..." -ForegroundColor Gray
        $post = & $Postcheck
        $result.PostcheckPassed = [bool]$post

        if (-not $result.PostcheckPassed) {
            $result.Verdict = "FAILED_POSTCHECK"
            Write-Host "  [WARN] Postcheck failed" -ForegroundColor Yellow
        } else {
            $result.Verdict = Get-RepairVerdict -Before $result.Before -After $result.After -RepairType $RepairId
        }

    } catch {
        $result.Errors += $_.Exception.Message
        $result.Verdict = "ERROR"
        Write-Host "  [ERROR] $($_.Exception.Message)" -ForegroundColor Red
    } finally {
        $endTime = Get-Date
        $result.FinishedUtc = $endTime.ToUniversalTime().ToString("o")
        $result.DurationSeconds = [math]::Round(($endTime - $startTime).TotalSeconds, 1)

        Write-JsonFile -Path $logFile -Object $result

        Enforce-LogRetention -LogRoot $logRoot
    }

    $verdictColor = switch ($result.Verdict) {
        "IMPROVED" { "Green" }
        "NO_CHANGE" { "Yellow" }
        "DEGRADED" { "Red" }
        "ERROR" { "Red" }
        default { "Gray" }
    }
    Write-Host ""
    Write-Host "  Verdict: $($result.Verdict)" -ForegroundColor $verdictColor
    Write-Host "  Duration: $($result.DurationSeconds)s" -ForegroundColor Gray
    Write-Host "  Log: $logFile" -ForegroundColor Gray

    return [pscustomobject]$result
}

$script:JOURNAL_DIR = Join-Path $env:ProgramData "RescuePC\logs\journals"
$script:ActiveJournal = $null

function Start-RepairJournal {
    param(
        [Parameter(Mandatory=$true)][string]$RepairId,
        [string]$Description = ""
    )

    if (-not (Test-Path $script:JOURNAL_DIR)) {
        New-Item -ItemType Directory -Path $script:JOURNAL_DIR -Force | Out-Null
    }

    $timestamp = (Get-Date).ToString("yyyyMMdd-HHmmss")
    $journalPath = Join-Path $script:JOURNAL_DIR "journal_${RepairId}_${timestamp}.json"

    $journal = [ordered]@{
        RepairId    = $RepairId
        Description = $Description
        Status      = "started"
        StartedUtc  = (Get-Date).ToUniversalTime().ToString("o")
        CompletedUtc = $null
        Entries     = @()
    }

    Write-JsonFile -Path $journalPath -Object $journal
    $script:ActiveJournal = $journalPath
    return $journalPath
}

function Add-JournalEntry {
    param(
        [Parameter(Mandatory=$true)][string]$Operation,
        [Parameter(Mandatory=$true)][string]$Target,
        [string]$OldValue = "",
        [string]$NewValue = "",
        [string]$JournalPath = $script:ActiveJournal
    )

    if (-not $JournalPath -or -not (Test-Path $JournalPath)) {
        Write-Verbose "No active journal -- skipping entry for $Operation on $Target"
        return
    }

    try {
        $json = [System.IO.File]::ReadAllText($JournalPath, [System.Text.Encoding]::UTF8)
        $journal = $json | ConvertFrom-Json

        $entry = [ordered]@{
            TimestampUtc = (Get-Date).ToUniversalTime().ToString("o")
            Operation    = $Operation
            Target       = $Target
            OldValue     = $OldValue
            NewValue     = $NewValue
        }

        $entries = [System.Collections.ArrayList]@()
        if ($journal.Entries) {
            foreach ($e in $journal.Entries) { $null = $entries.Add($e) }
        }
        $null = $entries.Add([pscustomobject]$entry)
        $journal.Entries = $entries.ToArray()

        Write-JsonFile -Path $JournalPath -Object $journal
    }
    catch {
        Write-Verbose "Failed to write journal entry: $($_.Exception.Message)"
    }
}

function Complete-RepairJournal {
    param(
        [string]$JournalPath = $script:ActiveJournal
    )

    if (-not $JournalPath -or -not (Test-Path $JournalPath)) {
        return
    }

    try {
        $json = [System.IO.File]::ReadAllText($JournalPath, [System.Text.Encoding]::UTF8)
        $journal = $json | ConvertFrom-Json
        $journal.Status = "completed"
        $journal.CompletedUtc = (Get-Date).ToUniversalTime().ToString("o")
        Write-JsonFile -Path $JournalPath -Object $journal
    }
    catch {
        Write-Verbose "Failed to complete journal: $($_.Exception.Message)"
    }
    finally {
        $script:ActiveJournal = $null
    }
}

function Get-IncompleteJournals {

    if (-not (Test-Path $script:JOURNAL_DIR)) {
        return @()
    }

    $incomplete = @()
    $files = Get-ChildItem -Path $script:JOURNAL_DIR -Filter "journal_*.json" -ErrorAction SilentlyContinue
    foreach ($f in $files) {
        try {
            $json = [System.IO.File]::ReadAllText($f.FullName, [System.Text.Encoding]::UTF8)
            $journal = $json | ConvertFrom-Json
            if ($journal.Status -eq "started") {
                $incomplete += [pscustomobject]@{
                    Path        = $f.FullName
                    RepairId    = $journal.RepairId
                    Description = $journal.Description
                    StartedUtc  = $journal.StartedUtc
                    EntryCount  = @($journal.Entries).Count
                }
            }
        }
        catch {
        }
    }
    return $incomplete
}

Export-ModuleMember -Function @(
    "Invoke-NativeCommand",
    "Invoke-LoggedCommand",
    "Invoke-RescuePCRepair",
    "Get-QuickHealthSnapshot",
    "Get-RepairVerdict",
    "Test-Admin",
    "Enforce-LogRetention",
    "Truncate-String",
    "New-RestorePointIfPossible",
    "Start-RepairJournal",
    "Add-JournalEntry",
    "Complete-RepairJournal",
    "Get-IncompleteJournals"
)