/**
 * Disk space monitor — heartbeat-driven disk monitoring and autonomous cleanup.
 *
 * Called after each heartbeat. Uses fs.statfs() to probe available space,
 * runs two tiers of cleanup when low, and reports results via health events.
 *
 * Tier 1: Deterministic whitelist cleanup (caches, logs, temp files).
 * Tier 2: LLM-guided smart cleanup — the agent analyses du results and
 *         decides which non-protected paths to remove.
 *
 * No owner notifications — all reporting goes through health events to
 * the onboarding service for monitoring and audit.
 */
import { statfs, realpath } from "node:fs/promises";
import { existsSync } from "node:fs";
import { execFile } from "node:child_process";
import { promisify } from "node:util";
import { homedir } from "node:os";
import { join, resolve, normalize, dirname } from "node:path";
import { z } from "zod";
import { logger } from "../logger.js";
import { runSideCall } from "./model-provider.js";
const execFileAsync = promisify(execFile);
// ---------------------------------------------------------------------------
// Thresholds (bytes)
// ---------------------------------------------------------------------------
const GB = 1_073_741_824;
const THRESHOLD_LOW = 10 * GB;
const THRESHOLD_CRITICAL = 5 * GB;
const THRESHOLD_RECOVERED = 15 * GB;
// ---------------------------------------------------------------------------
// Cooldowns
// ---------------------------------------------------------------------------
const CLEANUP_COOLDOWN_MS = 6 * 60 * 60 * 1000; // 6 hours
let currentState = "normal";
let lastCleanupAt = 0;
let cleanupRunning = false;
// ---------------------------------------------------------------------------
// Suggestion templates for du analysis
// ---------------------------------------------------------------------------
const SUGGESTIONS = [
    { pattern: /\/opt\/homebrew/i, suggestion: "`brew cleanup` or uninstall unused packages" },
    { pattern: /\/Library\/Application Support/i, suggestion: "May contain Xcode/Docker caches — inspect and clean" },
    { pattern: /\/Library\/Developer/i, suggestion: "Xcode caches/simulators — can be cleaned" },
    { pattern: /node_modules/i, suggestion: "Delete node_modules in unused projects" },
    { pattern: /\/Library\/Caches/i, suggestion: "Application caches — generally safe to trim" },
    { pattern: /\.Trash/i, suggestion: "Empty the Trash" },
];
function getSuggestion(dirPath) {
    for (const { pattern, suggestion } of SUGGESTIONS) {
        if (pattern.test(dirPath))
            return suggestion;
    }
    return null;
}
// ---------------------------------------------------------------------------
// Path safety — tier 2 uses a positive allowlist ("deny unless under a known
// cleanup root"), NOT a denylist. A denylist always has gaps.
// ---------------------------------------------------------------------------
const home = homedir();
// Tier 2 LLM cleanup may ONLY delete paths under these roots.
const TIER2_ALLOWED_ROOTS = [
    join(home, "Library/Caches"),
    join(home, "Library/Logs"),
    join(home, "Library/Developer"),
    join(home, ".cache"),
    "/tmp",
    join(home, "screenshots"),
];
const MIN_DELETE_DEPTH = 4;
function normalizePath(targetPath) {
    const expanded = targetPath.startsWith("~") ? targetPath.replace("~", home) : targetPath;
    return normalize(resolve(expanded));
}
export function isUnderAllowedRoot(targetPath) {
    const resolved = normalizePath(targetPath);
    for (const root of TIER2_ALLOWED_ROOTS) {
        if (resolved === root || resolved.startsWith(root + "/"))
            return true;
    }
    return false;
}
export function hasGitAncestor(targetPath) {
    let dir = normalizePath(targetPath);
    const fsRoot = resolve("/");
    while (dir !== fsRoot) {
        if (existsSync(join(dir, ".git")))
            return true;
        const parent = dirname(dir);
        if (parent === dir)
            break;
        dir = parent;
    }
    return false;
}
export async function resolveSafeDeletionTarget(targetPath) {
    const normalized = normalizePath(targetPath);
    const segments = normalized.split("/").filter(Boolean);
    if (segments.length < MIN_DELETE_DEPTH)
        return null;
    if (!isUnderAllowedRoot(normalized))
        return null;
    if (hasGitAncestor(normalized))
        return null;
    try {
        const real = await realpath(normalized);
        if (!isUnderAllowedRoot(real))
            return null;
        if (hasGitAncestor(real))
            return null;
        return real;
    }
    catch {
        return normalized;
    }
}
// ---------------------------------------------------------------------------
// Disk probe
// ---------------------------------------------------------------------------
export async function probeDisk(mountPoint = "/") {
    const stats = await statfs(mountPoint);
    return {
        totalBytes: stats.blocks * stats.bsize,
        freeBytes: stats.bfree * stats.bsize,
        availableBytes: stats.bavail * stats.bsize,
    };
}
// ---------------------------------------------------------------------------
// Cleanup primitives
// ---------------------------------------------------------------------------
// Tier 1 primitives — no safety gate. Callers are responsible for passing
// known-safe paths (tier 1 whitelist) or pre-validated paths (tier 2 via
// resolveSafeDeletionTarget).
async function deleteOldFiles(dir, daysOld, minSizeMb = 0, namePattern) {
    try {
        const { stdout: beforeOut } = await execFileAsync("du", ["-sk", dir], { timeout: 30_000 });
        const beforeKb = parseInt(beforeOut.split("\t")[0] ?? "0", 10);
        const findArgs = [dir, "-type", "f", "-mtime", `+${daysOld}`];
        if (namePattern) {
            findArgs.push("-name", namePattern);
        }
        if (minSizeMb > 0) {
            findArgs.push("-size", `+${minSizeMb}M`);
        }
        findArgs.push("-delete");
        await execFileAsync("find", findArgs, { timeout: 60_000 });
        await execFileAsync("find", [dir, "-type", "d", "-empty", "-delete"], { timeout: 30_000 }).catch(() => { });
        const { stdout: afterOut } = await execFileAsync("du", ["-sk", dir], { timeout: 30_000 }).catch(() => ({ stdout: "0\t" }));
        const afterKb = parseInt(afterOut.split("\t")[0] ?? "0", 10);
        const freedBytes = Math.max(0, (beforeKb - afterKb) * 1024);
        return { target: dir, freedBytes };
    }
    catch (err) {
        return { target: dir, freedBytes: 0, error: err instanceof Error ? err.message : String(err) };
    }
}
async function deleteDirectory(dir) {
    try {
        const { stdout: beforeOut } = await execFileAsync("du", ["-sk", dir], { timeout: 30_000 });
        const beforeKb = parseInt(beforeOut.split("\t")[0] ?? "0", 10);
        if (beforeKb === 0)
            return { target: dir, freedBytes: 0 };
        await execFileAsync("rm", ["-rf", dir], { timeout: 60_000 });
        return { target: dir, freedBytes: beforeKb * 1024 };
    }
    catch (err) {
        return { target: dir, freedBytes: 0, error: err instanceof Error ? err.message : String(err) };
    }
}
async function prunePnpmStore() {
    const target = "pnpm store";
    try {
        const before = await probeDisk();
        await execFileAsync("pnpm", ["store", "prune"], { timeout: 120_000 });
        const after = await probeDisk();
        return { target, freedBytes: Math.max(0, after.availableBytes - before.availableBytes) };
    }
    catch (err) {
        return { target, freedBytes: 0, error: err instanceof Error ? err.message : String(err) };
    }
}
async function dockerSystemPrune() {
    const target = "docker system prune";
    try {
        const before = await probeDisk();
        await execFileAsync("docker", ["system", "prune", "-f"], { timeout: 120_000 });
        const after = await probeDisk();
        return { target, freedBytes: Math.max(0, after.availableBytes - before.availableBytes) };
    }
    catch (err) {
        return { target, freedBytes: 0, error: err instanceof Error ? err.message : String(err) };
    }
}
// ---------------------------------------------------------------------------
// Tier 1: Deterministic whitelist cleanup
// ---------------------------------------------------------------------------
async function runTier1Cleanup() {
    const results = [];
    // ~/screenshots/ — files older than 7 days
    results.push(await deleteOldFiles(join(home, "screenshots"), 7));
    // ~/Library/Caches/Google/Chrome/ — browser caches
    results.push(await deleteDirectory(join(home, "Library/Caches/Google/Chrome/Default/Cache")));
    results.push(await deleteDirectory(join(home, "Library/Caches/Google/Chrome/Default/Code Cache")));
    // Package manager caches
    results.push(await deleteDirectory(join(home, "Library/Caches/pip")));
    results.push(await deleteDirectory(join(home, "Library/Caches/go-build")));
    results.push(await deleteDirectory(join(home, ".npm/_cacache")));
    results.push(await deleteDirectory(join(home, ".local/share/pnpm/store")));
    results.push(await deleteDirectory(join(home, ".cache")));
    // /tmp — files >100MB older than 7 days
    results.push(await deleteOldFiles("/tmp", 7, 100));
    // ~/Library/Logs — files older than 30 days
    results.push(await deleteOldFiles(join(home, "Library/Logs"), 30));
    // ~/.claude/projects — only old transcript/archive .jsonl files
    results.push(await deleteOldFiles(join(home, ".claude/projects"), 30, 0, "*.jsonl"));
    // pnpm store prune
    results.push(await prunePnpmStore());
    // docker system prune -f
    results.push(await dockerSystemPrune());
    return results.filter((r) => r.freedBytes > 0 || r.error);
}
// ---------------------------------------------------------------------------
// Deep analysis (du scan top N)
// ---------------------------------------------------------------------------
async function deepAnalysis(topN = 15) {
    // Scan home at depth 2 (~/Library/Caches, ~/Library/Developer, etc.)
    // and other top-level dirs at depth 2 for granularity.
    // Never scan /Users at depth 1 — that would surface home dirs as candidates.
    const scanTargets = [
        { dir: home, depth: 2 },
        { dir: "/Library", depth: 1 },
        { dir: "/opt", depth: 1 },
        { dir: "/tmp", depth: 1 },
    ];
    const entries = [];
    for (const { dir, depth } of scanTargets) {
        try {
            const { stdout } = await execFileAsync("du", ["-d", String(depth), "-k", dir], { timeout: 60_000 });
            for (const line of stdout.trim().split("\n")) {
                const parts = line.split("\t");
                if (parts.length < 2)
                    continue;
                const sizeKb = parseInt(parts[0] ?? "0", 10);
                const duPath = parts[1] ?? "";
                if (!duPath || duPath === dir)
                    continue;
                if (sizeKb < 1024 * 100)
                    continue; // skip < 100MB
                entries.push({
                    path: duPath,
                    sizeBytes: sizeKb * 1024,
                    suggestion: getSuggestion(duPath),
                });
            }
        }
        catch {
            // du may fail on permission-denied directories — skip
        }
    }
    entries.sort((a, b) => b.sizeBytes - a.sizeBytes);
    return entries.slice(0, topN);
}
// ---------------------------------------------------------------------------
// Tier 2: LLM-guided smart cleanup
// ---------------------------------------------------------------------------
const LlmCleanupSchema = z.object({
    deletions: z.array(z.object({
        path: z.string().describe("Absolute path to delete"),
        reason: z.string().describe("Why this is safe to delete"),
    })).describe("Paths the agent recommends deleting"),
    skipped: z.array(z.object({
        path: z.string(),
        reason: z.string().describe("Why this was NOT selected for deletion"),
    })).describe("Paths considered but intentionally kept"),
});
const LLM_CLEANUP_SYSTEM_PROMPT = [
    "You are a disk cleanup agent on a macOS machine running the VisionClaw AI agent.",
    "The machine is running low on disk space. Tier 1 (deterministic) cleanup already ran but did not free enough space.",
    "",
    "You will receive a list of the largest directories on disk. Your job is to decide which ones can be safely deleted.",
    "",
    "## Rules",
    "1. You may ONLY select paths under these allowed roots (all others are rejected by the safety gate):",
    "   - ~/Library/Caches/",
    "   - ~/Library/Logs/",
    "   - ~/Library/Developer/",
    "   - ~/.cache/",
    "   - /tmp/",
    "   - ~/screenshots/",
    "2. Paths inside git repositories are automatically rejected.",
    "3. Prefer deleting caches, build artifacts, old logs, and temporary files.",
    "4. Be conservative — only delete things you are confident are regenerable.",
    "5. Include a clear reason for every deletion AND every skip.",
    "",
    "Respond with the structured JSON schema provided.",
].join("\n");
async function runTier2Cleanup(analysis, config) {
    const userMessage = [
        "## Disk usage — largest directories (after tier 1 cleanup)",
        "",
        ...analysis.map((e) => `- ${e.path}: ${formatBytes(e.sizeBytes)}${e.suggestion ? ` (hint: ${e.suggestion})` : ""}`),
        "",
        "Which of these should be deleted to free space? Remember: only delete what you are confident is safe.",
    ].join("\n");
    let decision = null;
    try {
        decision = await runSideCall(config, {
            systemPrompt: LLM_CLEANUP_SYSTEM_PROMPT,
            userMessage,
            schema: LlmCleanupSchema,
            schemaName: "disk_cleanup_decision",
            maxTokens: 1024,
            timeoutMs: 30_000,
            useCase: "disk_cleanup",
        });
    }
    catch (err) {
        logger.warn(`Tier 2 LLM cleanup failed: ${err instanceof Error ? err.message : String(err)}`);
        return { decision: null, results: [] };
    }
    if (decision.deletions.length === 0) {
        return { decision, results: [] };
    }
    const results = [];
    for (const del of decision.deletions) {
        const safePath = await resolveSafeDeletionTarget(del.path);
        if (!safePath) {
            results.push({ target: del.path, freedBytes: 0, error: "protected — LLM override blocked" });
            continue;
        }
        results.push(await deleteDirectory(safePath));
    }
    return { decision, results };
}
// ---------------------------------------------------------------------------
// Format helpers
// ---------------------------------------------------------------------------
function formatBytes(bytes) {
    if (bytes >= GB)
        return `${(bytes / GB).toFixed(1)}GB`;
    const MB = 1_048_576;
    if (bytes >= MB)
        return `${(bytes / MB).toFixed(0)}MB`;
    return `${(bytes / 1024).toFixed(0)}KB`;
}
// ---------------------------------------------------------------------------
// Main entry point — called from HeartbeatManager after each heartbeat
//
// State machine:
//   ≥ 10GB        → normal (no action)
//   < 10GB        → tier 1 whitelist cleanup + du analysis + health event
//   still < 10GB  → tier 2 LLM smart cleanup (protection-list constrained)
//   < 5GB         → critical health event
//   > 15GB        → recovered health event
// ---------------------------------------------------------------------------
export async function checkDisk(config) {
    if (cleanupRunning)
        return null;
    let probe;
    try {
        probe = await probeDisk();
    }
    catch (err) {
        logger.warn(`Disk probe failed: ${err instanceof Error ? err.message : String(err)}`);
        return null;
    }
    const healthEvents = [];
    const now = Date.now();
    // Recovery: crossed back above the hysteresis threshold
    if (currentState !== "normal" && probe.availableBytes >= THRESHOLD_RECOVERED) {
        const prevState = currentState;
        currentState = "normal";
        lastCleanupAt = 0;
        healthEvents.push({
            eventType: "disk_space_recovered",
            severity: "info",
            message: `Disk space recovered: ${formatBytes(probe.availableBytes)} available (was ${prevState})`,
            details: { availableBytes: probe.availableBytes },
        });
        return {
            probe,
            cleanupResults: [],
            totalFreed: 0,
            postCleanupAvailable: probe.availableBytes,
            analysis: [],
            healthEvents,
        };
    }
    // Normal — nothing to do
    if (probe.availableBytes >= THRESHOLD_LOW) {
        return null;
    }
    // Below 10GB — decide whether to run expensive cleanup or just report.
    const enteringLow = currentState === "normal";
    const enteringCritical = currentState !== "critical" && probe.availableBytes < THRESHOLD_CRITICAL;
    const cleanupCooldownElapsed = (now - lastCleanupAt) >= CLEANUP_COOLDOWN_MS;
    const shouldCleanup = enteringLow || enteringCritical || cleanupCooldownElapsed;
    let cleanupResults = [];
    let analysis = [];
    let totalFreed = 0;
    let postCleanupAvailable = probe.availableBytes;
    if (shouldCleanup) {
        cleanupRunning = true;
        try {
            logger.system(`Disk space low: ${formatBytes(probe.availableBytes)} available — running tier 1 cleanup`);
            // Tier 1: deterministic whitelist cleanup + du analysis
            const [tier1Results, duAnalysis] = await Promise.all([
                runTier1Cleanup(),
                deepAnalysis(),
            ]);
            cleanupResults = tier1Results;
            analysis = duAnalysis;
            totalFreed = cleanupResults.reduce((sum, r) => sum + r.freedBytes, 0);
            postCleanupAvailable = probe.availableBytes + totalFreed;
            logger.system(`Tier 1 cleanup complete: freed ${formatBytes(totalFreed)}, ${formatBytes(postCleanupAvailable)} available`);
            // Tier 2: LLM smart cleanup — if still below threshold and config is available
            if (postCleanupAvailable < THRESHOLD_LOW && config) {
                logger.system("Tier 1 insufficient — running tier 2 LLM cleanup");
                const tier2 = await runTier2Cleanup(analysis, config);
                if (tier2.results.length > 0) {
                    cleanupResults.push(...tier2.results);
                    const tier2Freed = tier2.results.reduce((sum, r) => sum + r.freedBytes, 0);
                    totalFreed += tier2Freed;
                    postCleanupAvailable += tier2Freed;
                    logger.system(`Tier 2 cleanup complete: freed ${formatBytes(tier2Freed)}, ${formatBytes(postCleanupAvailable)} available`);
                }
            }
            lastCleanupAt = now;
        }
        finally {
            cleanupRunning = false;
        }
    }
    // Determine final state
    const isCritical = postCleanupAvailable < THRESHOLD_CRITICAL;
    currentState = isCritical ? "critical" : "low";
    // Health event — always emitted (keeps dashboard current)
    healthEvents.push({
        eventType: isCritical ? "disk_space_critical" : "disk_space_low",
        severity: isCritical ? "critical" : "warning",
        message: shouldCleanup
            ? `Disk space ${isCritical ? "critical" : "low"}: ${formatBytes(probe.availableBytes)} available, cleaned ${formatBytes(totalFreed)}`
            : `Disk space ${isCritical ? "critical" : "low"}: ${formatBytes(probe.availableBytes)} available (cleanup on cooldown)`,
        details: {
            availableBytes: probe.availableBytes,
            ...(shouldCleanup ? {
                totalFreed,
                postCleanupAvailable,
                cleanupResults: cleanupResults.map((r) => ({
                    target: r.target,
                    freedBytes: r.freedBytes,
                    ...(r.error ? { error: r.error } : {}),
                })),
                topDirs: analysis.slice(0, 5).map((e) => ({ path: e.path, sizeBytes: e.sizeBytes })),
            } : { postCleanupAvailable }),
        },
    });
    return {
        probe,
        cleanupResults,
        totalFreed,
        postCleanupAvailable,
        analysis,
        healthEvents,
    };
}
//# sourceMappingURL=disk-monitor.js.map