# wkdoctor -- wkappbot-sdk health check orchestrator
# Usage: wkdoctor [-Json] [-DefenderFix] [-EmergencyKill] [-Build]
# Drop custom checks as *.ps1 into wkappbot.hq/doctor/ for plugin extension
param([switch]$Json, [switch]$EmergencyKill, [switch]$DefenderFix, [switch]$Build)

$scriptRoot = Split-Path -Parent $MyInvocation.MyCommand.Path
$binDir     = $scriptRoot
$repoRoot   = Split-Path -Parent $scriptRoot
$pass = 0; $fail = 0; $warn = 0
$items = [System.Collections.Generic.List[PSCustomObject]]::new()

function Add-Check {
    param([string]$Name, [string]$Status, [string]$Detail)
    $items.Add([PSCustomObject]@{ Name = $Name; Status = $Status; Detail = $Detail })
    if ($Status -eq 'ok')       { $script:pass++ }
    elseif ($Status -eq 'fail') { $script:fail++ }
    else                        { $script:warn++ }
}

function Emit {
    param([string]$Status, [string]$Name, [string]$Detail)
    if ($Json) { return }
    $sym   = if ($Status -eq 'ok') { '[+]' } elseif ($Status -eq 'fail') { '[x]' } else { '[!]' }
    $color = if ($Status -eq 'ok') { 'Green' } elseif ($Status -eq 'fail') { 'Red' } else { 'Yellow' }
    $line = "$sym $Name"
    if ($Detail) { $line += " -- $Detail" }
    Write-Host $line -ForegroundColor $color
}

function Get-DoctorNote {
    $warnItems = @($items | Where-Object { $_.Status -eq 'warn' })
    if ($fail -gt 0) {
        return 'One or more checks failed.'
    }
    if ($warnItems.Count -eq 0) {
        return 'All checks passed.'
    }

    $warnNames = @($warnItems | ForEach-Object { $_.Name })
    if ($warnNames.Count -gt 0 -and (@($warnNames -join ' ') -match 'codex')) {
        return 'System is healthy, but codex link warnings remain.'
    }

    return "System is healthy, but $($warnItems.Count) warning(s) remain."
}

# -Build: USER-SIDE bootstrap build of the public Launcher (wkappbot.exe), with a
# lock-FREE single-flight guard. This is the SDK user's build path -- a public user may
# not have the private core source, so we build the LAUNCHER only and merely WARN if
# wkappbot-core.exe is absent (mirrors the legacy wkappbot.cmd :rebuild). Distinct from
# the private dev staging pipeline (wkbuild/wktest-smoke/promote). NEVER starts Eye.
#
# Single-flight by PROCESS LIST (no lock file/dir): a concurrent build is just another
# live "wkdoctor.ps1 ... -Build" powershell process. We reuse wkdoctor's EXISTING
# process-query machinery (Get-CimInstance Win32_Process -- same source as Get-EkAncestry
# and the WMI-culprit query) with a targeted, LIVE query. Live (not the 9s-TTL cached
# wktasklist snapshot) is deliberate: concurrent builders are visible immediately, so there
# is no snapshot-staleness window and no cross-repo wktasklist dependency. A dead doctor
# vanishes from the list => no stale state by design. Election is deterministic by LOWEST
# PID among all live -Build doctors (tie-safe even if two start in the same instant): the
# minimum-PID process builds; every other immediately fast-fails (exit 2), so a pile-up of
# waiting processes can never form.
if ($Build) {
    $launcherProj = Join-Path $repoRoot 'csharp\src\WKAppBot.Launcher\WKAppBot.Launcher.csproj'
    $wkExe        = Join-Path $binDir 'wkappbot.exe'
    $wkCore       = Join-Path $binDir 'wkappbot-core.exe'

    # No-op fast path: launcher already present => health note, nothing to build.
    if (Test-Path $wkExe) {
        Write-Host "[wkdoctor] wkappbot.exe present -- no build needed." -ForegroundColor Green
        if (-not (Test-Path $wkCore)) {
            Write-Host "[wkdoctor] note: wkappbot-core.exe absent -- launcher-only commands work; core-dependent commands fail." -ForegroundColor Yellow
        }
        exit 0
    }

    # SINGLE-FLIGHT election via the live process list (NO lock file).
    $selfPid  = $PID
    $builders = @()
    try {
        $builders = @(Get-CimInstance Win32_Process -Filter "Name='powershell.exe' OR Name='pwsh.exe'" -ErrorAction Stop |
            Where-Object { $_.CommandLine -and ($_.CommandLine -match 'wkdoctor\.ps1') -and ($_.CommandLine -match '-Build') } |
            ForEach-Object { [int]$_.ProcessId })
    } catch {
        $builders = @()   # fail-safe: query failed => treat self as sole builder and proceed.
    }
    if ($builders.Count -eq 0) { $builders = @($selfPid) }
    $electedPid = ($builders | Sort-Object | Select-Object -First 1)
    if ($electedPid -ne $selfPid) {
        Write-Host "[wkdoctor] build in progress (pid=$electedPid) -- retry shortly." -ForegroundColor Yellow
        exit 2
    }

    # Elected builder: build the launcher (self-contained, trimmed single exe).
    if (-not (Test-Path $launcherProj)) {
        Write-Host "[wkdoctor] ERROR: Launcher project not found: $launcherProj" -ForegroundColor Red
        exit 1
    }
    $dotnet = 'C:\Program Files\dotnet\dotnet.exe'
    if (-not (Test-Path $dotnet)) { $dotnet = 'dotnet' }
    Write-Host "[wkdoctor] wkappbot.exe missing -- building launcher (pid=$selfPid)..." -ForegroundColor Cyan
    & $dotnet publish $launcherProj -c Release --verbosity minimal
    if ($LASTEXITCODE -ne 0) {
        Write-Host "[wkdoctor] build FAILED (dotnet exit $LASTEXITCODE)" -ForegroundColor Red
        exit 1
    }
    if (-not (Test-Path $wkExe)) {
        Write-Host "[wkdoctor] build reported success but wkappbot.exe still missing." -ForegroundColor Red
        exit 1
    }
    Write-Host "[wkdoctor] build OK -- wkappbot.exe deployed." -ForegroundColor Green
    if (-not (Test-Path $wkCore)) {
        Write-Host "[wkdoctor] note: wkappbot-core.exe absent -- launcher-only commands work; core-dependent commands fail." -ForegroundColor Yellow
    }
    exit 0
}
# -DefenderFix: apply the durable Defender exclusions that stop the MsMpEng dev-folder
# scan storm (the root cause of the RAM/CPU pressure the watchdog only band-aids).
# Routed through wkdoctor on purpose: wkdoctor is a wk-tool so the harness pace-gate
# exempts it, which lets this run even when the session is over budget (a raw
# Start-Process / Add-MpPreference is non-wk and gets pace-blocked). Add-MpPreference
# still needs admin, so self-elevate once (one UAC) if not already elevated. Idempotent.
if ($DefenderFix) {
    # 0. DYNAMIC ASSET CALCULATION
    $wsRoot = Split-Path -Parent (Split-Path -Parent $binDir)
    $paths = @($wsRoot, "$HOME\.claude", "$env:LOCALAPPDATA\Temp\claude", "$env:TEMP\wktasklist-snapshot.json")
    $procs = @("powershell.exe", "cmd.exe", "claude.exe", "python.exe", "pythonw.exe", "node.exe", "codex.exe", "wkappbot-core.exe", "khmini.exe", "nkmini.exe", "heroglobal.exe")

    # 1. CHECK CURRENT STATUS (Quiet Check)
    $mp = Get-MpPreference -ErrorAction SilentlyContinue
    if ($mp) {
        $exPaths = @($mp.ExclusionPath) | ForEach-Object { $_.TrimEnd('\').ToLower() }
        $exProcs = @($mp.ExclusionProcess) | ForEach-Object { if($_){$_.ToLower()} }
        
        $missingPaths = @($paths | Where-Object { $_.TrimEnd('\').ToLower() -notin $exPaths })
        $missingProcs = @($procs | Where-Object { $_.ToLower() -notin $exProcs })
        
        if ($missingPaths.Count -eq 0 -and $missingProcs.Count -eq 0 -and $mp.ScanAvgCPULoadFactor -le 10) {
            Write-Host "[defender-fix] Already optimized. Nothing to do." -ForegroundColor Green
            exit 0
        }
    }

    # 2. PROCEED WITH FIX (Admin Eye Elevation)
    Write-Host "[defender-fix] Optimization required (missing paths or processes)." -ForegroundColor Yellow
    $selfPath = $MyInvocation.MyCommand.Path
    $isAdmin = ([Security.Principal.WindowsPrincipal][Security.Principal.WindowsIdentity]::GetCurrent()).IsInRole([Security.Principal.WindowsBuiltinRole]::Administrator)
    if (-not $isAdmin) {
        try {
            Write-Host "[defender-fix] Launching Admin Eye for persistent elevation..." -ForegroundColor Gray
            & wkappbot --sudo exec powershell "-NoProfile -ExecutionPolicy Bypass -File `"$selfPath`" -DefenderFix"
        } catch {
            Write-Host "[defender-fix] elevation declined/failed." -ForegroundColor Red
        }
        exit 0
    }

    Write-Host "[defender-fix] resetting and applying strategic exclusions..." -ForegroundColor Cyan
    $oldPaths = (Get-MpPreference).ExclusionPath
    $oldProcs = (Get-MpPreference).ExclusionProcess
    foreach ($p in $oldPaths) { if ($p -match "GitHub|claude|Temp|WKAppBot") { Remove-MpPreference -ExclusionPath $p -ErrorAction SilentlyContinue } }
    foreach ($p in $oldProcs) { if ($p -match "powershell|cmd|claude|python|node|codex|wkappbot|khmini|nkmini") { Remove-MpPreference -ExclusionProcess $p -ErrorAction SilentlyContinue } }

    Add-MpPreference -ExclusionPath $paths -ErrorAction SilentlyContinue
    Add-MpPreference -ExclusionProcess $procs -ErrorAction SilentlyContinue
    Set-MpPreference -ScanAvgCPULoadFactor 10 -ErrorAction SilentlyContinue

    Write-Host "[defender-fix] DONE: Sanctuary ($wsRoot) & AI Tools are now exempt." -ForegroundColor Green
    exit 0
}
# --emergency-kill: priority-order greedy reaper with harness:keep protection + kill-audit.
# Trigger=60% RAM (set in guards.ps1 hook), recover=30% RAM.
# NEVER kills claude.exe / wkappbot-core / wkchat / wka11y / user apps.
# harness:keep <regex> in project config protects matched processes (warn <85% RAM; override >=85%).
if ($EmergencyKill) {
    # 0a. MANDATORY top two lines (user 2026-06-06): always surface the WMI provider host + its parent
    #     command line FIRST. WmiPrvSE runaway is the recurring lag culprit, and killing it alone never
    #     helps -- the Winmgmt service host (the parent shown here) respawns it -- so the operator must
    #     see which parent/service is driving the storm before anything else scrolls.
    try {
        $wmiTop = Get-CimInstance Win32_Process -Filter "Name='WmiPrvSE.exe'" -ErrorAction Stop |
                  Sort-Object WorkingSetSize -Descending | Select-Object -First 1
        if ($wmiTop) {
            $wmiPar = Get-CimInstance Win32_Process -Filter "ProcessId=$($wmiTop.ParentProcessId)" -ErrorAction SilentlyContinue
            $wmiCmd = if ($wmiTop.CommandLine) { $wmiTop.CommandLine } else { '(no cmdline)' }
            $parNm  = if ($wmiPar) { $wmiPar.Name } else { '?' }
            $parCmd = if ($wmiPar -and $wmiPar.CommandLine) { $wmiPar.CommandLine } else { '(no cmdline)' }
            Write-Host ("[wmi]  WmiPrvSE pid={0} ppid={1} cmd={2}" -f $wmiTop.ProcessId, $wmiTop.ParentProcessId, $wmiCmd) -ForegroundColor Cyan
            Write-Host ("[wmi]  parent  {0} pid={1} cmd={2}" -f $parNm, $wmiTop.ParentProcessId, $parCmd) -ForegroundColor Cyan
        } else {
            Write-Host "[wmi]  WmiPrvSE: none running" -ForegroundColor Cyan
            Write-Host "[wmi]  parent : (n/a)" -ForegroundColor Cyan
        }
    } catch {
        Write-Host "[wmi]  WmiPrvSE query failed: $($_.Exception.Message)" -ForegroundColor Cyan
        Write-Host "[wmi]  parent : (n/a)" -ForegroundColor Cyan
    }

    # 0b. Clear any AppBot screensaver / black overlay first (it can mask the real lag source). Silent.
    try { & wkappbot screensaver close *>$null } catch {}

    # 0c. DEFENDER EMERGENCY MEASURES -- suppress the Defender storm. All subprocess output is silenced
    #     (*>$null) so the LAUNCH/SKILL-TIP/SKILL-NEWS chatter never floods the emergency console (noise).
    $binDir_ = Split-Path -Parent $MyInvocation.MyCommand.Path
    $wsRoot_ = Split-Path -Parent (Split-Path -Parent $binDir_)
    & wkappbot --sudo exec powershell "-NoProfile -ExecutionPolicy Bypass -Command Set-MpPreference -DisableRealtimeMonitoring `$true" *>$null
    & wkappbot --sudo exec powershell "-NoProfile -ExecutionPolicy Bypass -File `"$($MyInvocation.MyCommand.Path)`" -DefenderFix" *>$null

    function Get-ResourceStatus {
        try {
            $cs = Get-CimInstance -ClassName Win32_OperatingSystem -ErrorAction Stop
            $ram = [int](100 - ($cs.FreePhysicalMemory * 100 / $cs.TotalVisibleMemorySize))
            $cpu = [int](Get-CimInstance -ClassName Win32_PerfFormattedData_PerfOS_Processor -Filter "Name='_Total'").PercentProcessorTime
            return @{ ram = $ram; cpu = $cpu }
        } catch { return @{ ram = 0; cpu = 0 } }
    }

    function Get-EkAncestry {
        param([int]$ProcId)
        $chain = [System.Collections.Generic.List[PSCustomObject]]::new()
        $seen  = [System.Collections.Generic.HashSet[int]]::new()
        $cur   = $ProcId
        while ($cur -gt 0 -and $seen.Add($cur)) {
            try {
                $ci = Get-CimInstance Win32_Process -Filter "ProcessId=$cur" -ErrorAction Stop
                $chain.Add([PSCustomObject]@{ pid = $ci.ProcessId; name = $ci.Name; cmdline = if ($ci.CommandLine) { $ci.CommandLine } else { "" } })
                $cur = [int]$ci.ParentProcessId
            } catch { break }
        }
        return @($chain)
    }

    function Get-HarnessKeepPatterns {
        $patterns = [System.Collections.Generic.List[string]]::new()
        $cn = "CLAUDE.md"
        foreach ($f in @((Join-Path $PWD $cn), (Join-Path $env:USERPROFILE ".claude\$cn"))) {
            if (Test-Path $f) {
                Get-Content $f | ForEach-Object {
                    if ($_ -match "^## harness:keep (.+)$") { $patterns.Add($Matches[1].Trim()) }
                }
            }
        }
        return @($patterns)
    }

    $keepPatterns = Get-HarnessKeepPatterns
    $auditDir  = Join-Path $env:USERPROFILE ".claude\wkharness"
    $auditPath = Join-Path $auditDir "emergency-kill-audit.jsonl"
    if (-not (Test-Path $auditDir)) { New-Item -ItemType Directory -Path $auditDir -Force | Out-Null }

    $taskListPath = Join-Path $binDir "wktasklist.ps1"
    if (-not (Test-Path $taskListPath)) { $taskListPath = "D:\GitHub\WKAppBot\bin\wktasklist.ps1" }

    Write-Host "[ek] check trigger (CPU/RAM > 60%)..." -ForegroundColor Gray
    $status = Get-ResourceStatus
    if ($status.cpu -lt 60 -and $status.ram -lt 60) {
        Write-Host "[ek] system healthy (CPU:$($status.cpu)% RAM:$($status.ram)%). bypass." -ForegroundColor Green
        exit 0
    }

    Write-Host "[ek] TRIGGERED: CPU:$($status.cpu)% RAM:$($status.ram)%. fetching targets..." -ForegroundColor Red
    $pool = & $taskListPath -Json -Force | ConvertFrom-Json | Sort-Object Score -Descending

    # Invariant #0 (user 2026-06-06): never reap the reaper's OWN live session tree
    # (this powershell -> claude session -> launching shells). At extreme load the keep-override
    # below WILL reap AI child processes (python/codex/dotnet/node) -- which is wanted -- but the
    # orchestrating claude.exe must survive or the user's session dies. This set is always spared.
    $selfChainPids = [System.Collections.Generic.HashSet[int]]::new()
    [void]$selfChainPids.Add($PID)
    foreach ($a in (Get-EkAncestry $PID)) { [void]$selfChainPids.Add([int]$a.pid) }

    $killed = 0; $keptCount = 0
    foreach ($proc in $pool) {
        $st = Get-ResourceStatus
        if ($st.cpu -lt 40 -and $st.ram -lt 40) { break } # Recovery target: 40%

        if ($proc.Score -lt 10) { continue } # Don't kill safe/healthy processes

        # Invariant #0: the live session tree is sacrosanct at ANY load level.
        if ($selfChainPids.Contains([int]$proc.PID)) { $keptCount++; continue }

        $kept = $false
        foreach ($pat in $keepPatterns) { if ($proc.Name -match $pat -or $proc.CommandLine -match $pat) { $kept = $true; break } }
        # < 85%: spare the protected AI work set. >= 85% (extreme load): override and reap AI
        # children too (user: "AI 자식 프로세스도 부하 극심하면 다 킬") -- the session tree above stays safe.
        if ($kept -and $st.ram -lt 85 -and $st.cpu -lt 85) { $keptCount++; continue }

        # Capture ancestry BEFORE the kill -- a dead process has no queryable CommandLine.
        $ancestry  = Get-EkAncestry $proc.PID
        $selfCmd   = if ($ancestry.Count -ge 1 -and $ancestry[0].cmdline) { $ancestry[0].cmdline } else { "(cmdline unavailable)" }
        $parentName = if ($ancestry.Count -ge 2) { $ancestry[1].name } else { "?" }
        $parentPid  = if ($ancestry.Count -ge 2) { $ancestry[1].pid }  else { 0 }
        $parentCmd  = if ($ancestry.Count -ge 2 -and $ancestry[1].cmdline) { $ancestry[1].cmdline } else { "(no parent / cmdline unavailable)" }
        try {
            & wkappbot taskkill --force $proc.PID 2>&1 | Out-Null
            $killed++
            $chainStr = ($ancestry | ForEach-Object { $_.name }) -join " <- "
            Write-Host "[ek] killed $($proc.Name) pid=$($proc.PID) score=$($proc.Score) ram=$($st.ram)% chain=[$chainStr]" -ForegroundColor Yellow
            # MANDATORY (user 2026-06-06): always print BOTH the killed process and its parent command line.
            Write-Host "       self  cmd: $selfCmd" -ForegroundColor DarkGray
            Write-Host "       parent($parentName pid=$parentPid) cmd: $parentCmd" -ForegroundColor DarkGray
            $auditEntry = @{ ts=[DateTime]::UtcNow.ToString("o"); pid=$proc.PID; name=$proc.Name; score=$proc.Score; ram_pct=$st.ram; cpu_pct=$st.cpu; self_cmdline=$selfCmd; parent_pid=$parentPid; parent_name=$parentName; parent_cmdline=$parentCmd; ancestry=$ancestry }
            $auditEntry | ConvertTo-Json -Compress | Add-Content -Path $auditPath -Encoding UTF8
        } catch {}
    }
    $final = Get-ResourceStatus
    Write-Host "[emergency-kill] done killed=$killed keptProtected=$keptCount final(CPU:$($final.cpu)% RAM:$($final.ram)%)" -ForegroundColor Green
    exit 0
}
# Load check modules (sorted by name, so 00- runs before 01- etc.)
$doctorDir = Join-Path $binDir 'wkappbot.hq\doctor'
if (Test-Path $doctorDir -PathType Container) {
    Get-ChildItem $doctorDir -Filter '*.ps1' | Sort-Object Name | ForEach-Object { . $_.FullName }
} else {
    Add-Check 'doctor modules' 'warn' "not found at $doctorDir -- run setup.ps1"
    Emit '!' 'doctor modules' 'missing'
}

# Summary
if (-not $Json) {
    Write-Host ''
    $color = if ($fail -gt 0) { 'Red' } elseif ($warn -gt 0) { 'Yellow' } else { 'Green' }
    Write-Host "wkdoctor: $pass ok, $warn warn, $fail fail" -ForegroundColor $color
    Write-Host "wkdoctor note: $(Get-DoctorNote)" -ForegroundColor $color
}
if ($Json) {
    [PSCustomObject]@{ pass = $pass; warn = $warn; fail = $fail; items = $items } | ConvertTo-Json -Depth 5
}
if ($fail -gt 0) { exit 1 } else { exit 0 }
