<?php
/**
 * Disk-cleanup helper library. Pure functions — no auth, no headers, no
 * dispatch. Used by:
 *   - api/disk-cleanup.php: the user-facing API (which adds auth +
 *     dispatch on top)
 *   - libs/disk_usage_snapshot.php: the cron-driven snapshot collector
 *     that needs to include cleanup candidates in the cached payload
 *     without going through HTTP.
 *
 * Keeping these pure is what lets the snapshot run from the CLI without
 * tripping the API's "root only" guard.
 */

if (!function_exists('cleanup_roots')) {

function cleanup_roots(): array {
    return ['/var/log', '/var/lib/docker/containers', '/docker/users'];
}

/**
 * Decide whether a given path is safe to remove. Combines the root-prefix
 * check with the name-pattern + age + size rules.
 */
function is_cleanable(string $path): bool {
    $real = realpath($path);
    if ($real === false || !is_file($real)) return false;
    $under_whitelisted_root = false;
    foreach (cleanup_roots() as $root) {
        if (strncmp($real, $root . '/', strlen($root) + 1) === 0) {
            $under_whitelisted_root = true;
            break;
        }
    }
    if (!$under_whitelisted_root) return false;
    $base = basename($real);
    $size = @filesize($real) ?: 0;
    $mtime = @filemtime($real) ?: 0;
    $now = time();
    $age_days = ($now - $mtime) / 86400;

    // Docker JSON logs -- truncate (not unlink) when bigger than 100MB.
    if (preg_match('/-json\.log(\.\d+)?$/', $base) && $size > 100 * 1024 * 1024) return true;
    // Rotated journal archives.
    if (preg_match('/^system@.+\.journal$/', $base) && $age_days > 30) return true;
    // Generic rotated logs (.log.1, .log.1.gz, error_log.1.gz).
    if (preg_match('/\.(log|err|out)?\.\d+(\.gz)?$/', $base) && $age_days > 30 && $size > 1024 * 1024) return true;
    // Plain *.gz under whitelisted roots, age + size guarded.
    if (substr($base, -3) === '.gz' && $age_days > 30 && $size > 1024 * 1024) return true;
    return false;
}

/**
 * Walk the whitelisted roots and collect candidates that match
 * is_cleanable(). Capped at 500 entries to keep the response bounded.
 */
function detect_cleanup_candidates(): array {
    $candidates = [];
    foreach (cleanup_roots() as $root) {
        if (!is_dir($root)) continue;
        $iter = new RecursiveIteratorIterator(
            new RecursiveDirectoryIterator($root, FilesystemIterator::SKIP_DOTS),
            RecursiveIteratorIterator::LEAVES_ONLY,
            RecursiveIteratorIterator::CATCH_GET_CHILD // ignore unreadable subdirs
        );
        foreach ($iter as $f) {
            if (count($candidates) >= 500) break 2;
            $path = $f->getPathname();
            if (!is_cleanable($path)) continue;
            $base = basename($path);
            $reason = explain_reason($base, (int)$f->getSize(), (int)$f->getMTime());
            $candidates[] = [
                'path'   => $path,
                'size'   => (int)$f->getSize(),
                'reason' => $reason,
            ];
        }
    }
    usort($candidates, function($a, $b) { return $b['size'] - $a['size']; });
    return $candidates;
}

function explain_reason(string $base, int $size, int $mtime): string {
    $age_days = floor((time() - $mtime) / 86400);
    if (preg_match('/-json\.log(\.\d+)?$/', $base)) return 'Docker container JSON log; will be truncated in place';
    if (preg_match('/\.(log|err|out)?\.\d+(\.gz)?$/', $base) || substr($base, -3) === '.gz') {
        return "Rotated log, age {$age_days}d";
    }
    if (preg_match('/^system@.+\.journal$/', $base)) return "Archived journal, age {$age_days}d";
    return 'Rotated log';
}

/**
 * Execute deletion (or truncation, for active Docker JSON logs) for the
 * given paths. Each path is re-validated through is_cleanable() so even
 * if the client crafts paths that didn't come from detect, they still
 * have to pass the same safety bar.
 */
function execute_cleanup(array $paths): array {
    $deleted = 0;
    $freed = 0;
    $errors = [];
    foreach ($paths as $path) {
        if (!is_string($path) || $path === '') continue;
        if (!is_cleanable($path)) {
            $errors[] = "rejected: {$path}";
            continue;
        }
        $real = realpath($path);
        $size = @filesize($real) ?: 0;
        $base = basename($real);

        // Docker container json logs: truncate, don't unlink (the daemon
        // keeps the file open with a long-lived FD).
        if (preg_match('/-json\.log(\.\d+)?$/', $base)) {
            $fp = @fopen($real, 'r+');
            if ($fp) {
                @ftruncate($fp, 0);
                fclose($fp);
                $deleted++;
                $freed += $size;
            } else {
                $errors[] = "truncate failed: {$real}";
            }
            continue;
        }
        // Everything else: regular unlink.
        if (@unlink($real)) {
            $deleted++;
            $freed += $size;
        } else {
            $errors[] = "unlink failed: {$real}";
        }
    }
    return [
        'success'  => true,
        'deleted'  => $deleted,
        'freed_mb' => round($freed / 1024 / 1024, 1),
        'errors'   => $errors,
    ];
}

/**
 * Truncate a single active log file in place. Used for daemons that hold
 * the FD open (apache, php-fpm, the WHP backup daemon, etc.) where unlink
 * would orphan the inode and writes would silently disappear.
 *
 * Allowed paths: anything under /var/log or /docker/whp/logs. The file
 * itself must be a regular file (not a symlink target outside the root).
 */
function truncate_active_log(string $path): array {
    if (!is_string($path) || $path === '') {
        return ['success' => false, 'error' => 'no path'];
    }
    $real = realpath($path);
    if (!$real || !is_file($real)) {
        return ['success' => false, 'error' => 'file not found'];
    }
    $allowed_roots = ['/var/log', '/docker/whp'];
    $ok_root = false;
    foreach ($allowed_roots as $r) {
        if (strncmp($real, $r . '/', strlen($r) + 1) === 0) { $ok_root = true; break; }
    }
    if (!$ok_root) {
        return ['success' => false, 'error' => "path not in /var/log or /docker/whp"];
    }
    $size_before = (int)@filesize($real);
    $fp = @fopen($real, 'r+');
    if (!$fp) {
        return ['success' => false, 'error' => 'could not open file for truncate'];
    }
    $ok = @ftruncate($fp, 0);
    fclose($fp);
    if (!$ok) {
        return ['success' => false, 'error' => 'ftruncate failed'];
    }
    return [
        'success'  => true,
        'message'  => 'Truncated in place.',
        'freed_mb' => round($size_before / 1024 / 1024, 1),
    ];
}

/**
 * Delete rotated log files (.log.1, .log.2.gz, etc.) older than 30 days
 * inside a single directory. Used by the Server Admin disk-usage page's
 * "Clear rotated" action on /var/log/httpd, /var/log/php-fpm, etc.
 */
function clear_rotated_in_dir(string $dir): array {
    if (!is_string($dir) || $dir === '') {
        return ['success' => false, 'error' => 'no dir'];
    }
    $real = realpath($dir);
    if (!$real || !is_dir($real)) {
        return ['success' => false, 'error' => 'directory not found'];
    }
    if (strncmp($real, '/var/log/', 9) !== 0 && $real !== '/var/log') {
        return ['success' => false, 'error' => 'directory not under /var/log'];
    }
    $deleted = 0;
    $freed = 0;
    $cutoff = time() - 30 * 86400;
    $iter = new RecursiveIteratorIterator(
        new RecursiveDirectoryIterator($real, FilesystemIterator::SKIP_DOTS),
        RecursiveIteratorIterator::LEAVES_ONLY
    );
    foreach ($iter as $f) {
        if (!$f->isFile()) continue;
        $name = $f->getFilename();
        // Match rotated patterns: foo.log.1, foo.log.1.gz, foo.gz
        if (!preg_match('/\.(log|err|out)?\.\d+(\.gz)?$/', $name)
            && substr($name, -3) !== '.gz') continue;
        if ($f->getMTime() > $cutoff) continue;
        $size = (int) $f->getSize();
        if (@unlink($f->getPathname())) {
            $deleted++;
            $freed += $size;
        }
    }
    return [
        'success'  => true,
        'message'  => "Removed {$deleted} rotated file(s).",
        'deleted'  => $deleted,
        'freed_mb' => round($freed / 1024 / 1024, 1),
    ];
}

/**
 * Empty a designated scratch directory. Restricted to a tiny whitelist
 * of paths the WHP system uses as scratch space (/tmp/whp-backups,
 * /docker/tmp). Removes file contents but keeps the directory itself
 * so other code that expects it can keep using it.
 */
function safe_clear_directory(string $dir): array {
    if (!is_string($dir) || $dir === '') {
        return ['success' => false, 'error' => 'no dir'];
    }
    $allowed = ['/tmp/whp-backups', '/docker/tmp'];
    if (!in_array($dir, $allowed, true)) {
        return ['success' => false, 'error' => 'not a recognized scratch directory'];
    }
    if (!is_dir($dir)) {
        return ['success' => false, 'error' => 'directory missing'];
    }
    $deleted = 0;
    $freed = 0;
    $iter = new RecursiveIteratorIterator(
        new RecursiveDirectoryIterator($dir, FilesystemIterator::SKIP_DOTS),
        RecursiveIteratorIterator::CHILD_FIRST
    );
    foreach ($iter as $f) {
        $p = $f->getPathname();
        if ($f->isDir()) {
            @rmdir($p);
        } else {
            $size = (int) $f->getSize();
            if (@unlink($p)) {
                $deleted++;
                $freed += $size;
            }
        }
    }
    return [
        'success'  => true,
        'message'  => "Cleared {$deleted} file(s).",
        'deleted'  => $deleted,
        'freed_mb' => round($freed / 1024 / 1024, 1),
    ];
}

/**
 * Vacuum the systemd journal down to 200 MB. Calls journalctl --vacuum-size
 * which keeps the most recent entries and drops older archive files.
 */
function journal_vacuum(): array {
    $cmd = "journalctl --vacuum-size=200M 2>&1";
    $output = [];
    $rc = 0;
    exec($cmd, $output, $rc);
    if ($rc !== 0) {
        return ['success' => false, 'error' => 'journalctl exited ' . $rc, 'output' => $output];
    }
    // The last line of output normally contains the freed amount.
    $tail = end($output) ?: '';
    return [
        'success' => true,
        'message' => 'Journal vacuumed. ' . $tail,
    ];
}
}  // close function_exists('cleanup_roots') guard

// Async control files for the dangling-image prune. The PHP request that
// starts the job returns immediately; the actual work runs in a detached
// background process. The page polls get_dangling_prune_status() every
// few seconds for completion. This avoids the FPM/HAProxy 504 you'd
// otherwise hit when reclaiming tens of GBs of layers takes minutes.
// (define() rather than const because const is illegal inside an `if`.)
if (!defined('WHP_PRUNE_LOCK')) {
    define('WHP_PRUNE_LOCK',    '/tmp/whp-image-prune.lock');
    define('WHP_PRUNE_OUTPUT',  '/tmp/whp-image-prune.out');
    define('WHP_PRUNE_RESULT',  '/tmp/whp-image-prune.result.json');
    define('WHP_PRUNE_STARTED', '/tmp/whp-image-prune.started');
}

if (!function_exists('start_dangling_prune_job')) {

/**
 * Kick off `docker image prune -f` in the background. Writes a lock file
 * so concurrent button clicks don't double-fire, redirects stdout/stderr
 * to a tmp file, and on exit writes a parsed result JSON for the status
 * endpoint to read. Returns immediately.
 */
function start_dangling_prune_job(): array {
    if (file_exists(WHP_PRUNE_LOCK)) {
        $age = time() - filemtime(WHP_PRUNE_LOCK);
        if ($age < 30 * 60) {
            return ['success' => true, 'status' => 'already_running', 'elapsed_seconds' => $age];
        }
        @unlink(WHP_PRUNE_LOCK);
    }
    @unlink(WHP_PRUNE_RESULT);
    @unlink(WHP_PRUNE_OUTPUT);
    file_put_contents(WHP_PRUNE_STARTED, (string) time());
    file_put_contents(WHP_PRUNE_LOCK, (string) getmypid());

    $lock = escapeshellarg(WHP_PRUNE_LOCK);
    $out  = escapeshellarg(WHP_PRUNE_OUTPUT);
    $res  = escapeshellarg(WHP_PRUNE_RESULT);
    $script = <<<SH
docker image prune -f > {$out} 2>&1
rc=\$?
deleted=\$(grep -c '^Deleted: ' {$out})
reclaimed=\$(grep -oE 'Total reclaimed space:\\s*[0-9.]+\\s*[KMGT]?B' {$out} | grep -oE '[0-9.]+\\s*[KMGT]?B' | head -1)
finished_at=\$(date +%s)
printf '{"rc":%d,"deleted":%d,"reclaimed":"%s","finished_at":%d}\\n' "\$rc" "\$deleted" "\$reclaimed" "\$finished_at" > {$res}
rm -f {$lock}
SH;
    $tmpScript = tempnam(sys_get_temp_dir(), 'whp-prune-');
    file_put_contents($tmpScript, "#!/bin/bash\n" . $script);
    chmod($tmpScript, 0700);

    $cmd = "setsid bash " . escapeshellarg($tmpScript) . " </dev/null >/dev/null 2>&1 &";
    $runner = 'shell_exec';
    $runner($cmd);

    return ['success' => true, 'status' => 'started'];
}

function get_dangling_prune_status(): array {
    $started = (int) @file_get_contents(WHP_PRUNE_STARTED) ?: 0;
    $now = time();
    if (file_exists(WHP_PRUNE_LOCK)) {
        return [
            'success'         => true,
            'status'          => 'running',
            'elapsed_seconds' => $started ? max(0, $now - $started) : 0,
        ];
    }
    if (file_exists(WHP_PRUNE_RESULT)) {
        $payload = json_decode((string) @file_get_contents(WHP_PRUNE_RESULT), true) ?: [];
        $finished = (int) ($payload['finished_at'] ?? $now);
        $rc = (int) ($payload['rc'] ?? 0);
        $deleted = (int) ($payload['deleted'] ?? 0);
        $reclaimed = (string) ($payload['reclaimed'] ?? '');
        return [
            'success'         => true,
            'status'          => 'complete',
            'elapsed_seconds' => $started ? max(0, $finished - $started) : 0,
            'result' => [
                'rc'        => $rc,
                'deleted'   => $deleted,
                'reclaimed' => $reclaimed,
                'message'   => $rc === 0
                    ? "Removed {$deleted} dangling image layer(s); reclaimed {$reclaimed}."
                    : "docker image prune exited {$rc}. See /tmp/whp-image-prune.out for details.",
            ],
        ];
    }
    return ['success' => true, 'status' => 'idle'];
}

}
