<?php

namespace App\Services;

use App\Models\Server;
use App\Support\Ssh\CredentialVault;
use App\Support\Ssh\Sftp;
use App\Support\Ssh\SshClient;
use Illuminate\Support\Facades\Cache;
use InvalidArgumentException;
use phpseclib3\Crypt\PublicKeyLoader;
use RuntimeException;
use Throwable;

/**
 * Reads real host facts over SSH and maps raw command output into the shapes the
 * Livewire pages already consume. All parsing forces the C locale (the target may
 * be localized) and prefers /proc over `free`/`top` for locale-independent numbers.
 *
 * Every public reader connects, runs one compound command, parses, disconnects.
 * Connection/parse failures bubble up as RuntimeException — callers render an
 * offline state instead of crashing.
 */
class FleetService
{
    private const MARK = '===CLUSEV:';

    // Unique marker the connection probe echoes — proves the exec channel works.
    private const PROBE_MARKER = 'CLUSEV_PROBE_OK';

    public function __construct(private readonly CredentialVault $vault) {}

    private function client(Server $server, int $timeout = 12): SshClient
    {
        return (new SshClient($this->vault, timeout: $timeout))->connect($server);
    }

    /**
     * A remote `priv()` shell function: runs its arguments directly when already
     * root, otherwise via sudo. For a password credential the stored password is
     * fed to `sudo -S` over stdin — the PLAINTEXT password never appears in any
     * argv; only its base64 is transiently in the remote `sh -c` argv (reversible
     * only by a co-located local user during the sub-second exec). Key/other auth
     * falls back to passwordless `sudo -n`. Prefix this string, then `priv <cmd>`.
     */
    private function sudoFn(Server $server): string
    {
        $cred = $server->credential;
        $password = ($cred && $cred->auth_type === 'password') ? (string) $cred->secret : '';

        if ($password !== '') {
            $b64 = base64_encode($password."\n");
            $branch = 'echo \''.$b64.'\' | base64 -d | sudo -S -p \'\' "$@"';
        } else {
            $branch = 'sudo -n "$@"';
        }

        return 'priv() { if [ "$(id -u)" = 0 ]; then "$@"; else '.$branch.'; fi; }; ';
    }

    /**
     * Run an arbitrary command as root (sudo password / direct root), returning
     * [ok, output]. The command runs under `sh -c` so pipes/redirects work; it is
     * passed base64-encoded so quoting/escaping is never an issue.
     *
     * @return array{ok: bool, output: string}
     */
    public function runPrivileged(Server $server, string $command, int $timeout = 60): array
    {
        $b64 = base64_encode($command);
        $ssh = $this->client($server, $timeout);

        try {
            [$out, $code] = $ssh->run(
                'export LC_ALL=C; '.$this->sudoFn($server).
                'priv sh -c "$(printf %s \''.$b64.'\' | base64 -d)" 2>&1'
            );

            return ['ok' => $code === 0, 'output' => trim($out)];
        } finally {
            $ssh->disconnect();
        }
    }

    /**
     * Run an unprivileged command as the SSH user, returning [ok, output].
     *
     * @return array{ok: bool, output: string}
     */
    public function runPlain(Server $server, string $command, int $timeout = 60): array
    {
        $b64 = base64_encode($command);
        $ssh = $this->client($server, $timeout);

        try {
            [$out, $code] = $ssh->run('export LC_ALL=C; sh -c "$(printf %s \''.$b64.'\' | base64 -d)" 2>&1');

            return ['ok' => $code === 0, 'output' => trim($out)];
        } finally {
            $ssh->disconnect();
        }
    }

    /** Split a marker-delimited compound output into [section => body]. */
    private function sections(string $out): array
    {
        $parts = [];
        $current = null;
        foreach (preg_split('/\R/', $out) as $line) {
            if (str_starts_with($line, self::MARK)) {
                $current = rtrim(substr($line, strlen(self::MARK)), '=');
                $parts[$current] = [];

                continue;
            }
            if ($current !== null) {
                $parts[$current][] = $line;
            }
        }

        return array_map(fn (array $l): string => implode("\n", $l), $parts);
    }

    /**
     * Probe the server's stored credential: connect + a trivial exec. NEVER throws —
     * returns the failure reason instead, for the create-server guard to surface.
     * Uses a short timeout so an unreachable host fails fast.
     *
     * @return array{ok: bool, error: ?string}
     */
    public function testConnection(Server $server): array
    {
        try {
            $ssh = (new SshClient($this->vault, timeout: 10))->connect($server);
            try {
                // Require a unique marker in the OUTPUT to prove the exec channel really
                // works — auth alone is not enough, since every fleet operation runs
                // commands (a server that authenticates but rejects exec would otherwise
                // be accepted). Checking the output, not the exit code, also tolerates
                // SSH servers that omit the optional exit-status packet.
                [$out] = $ssh->run('echo '.self::PROBE_MARKER);
            } finally {
                $ssh->disconnect();
            }

            if (! str_contains($out, self::PROBE_MARKER)) {
                return ['ok' => false, 'error' => __('backend.ssh_probe_no_exec')];
            }

            return ['ok' => true, 'error' => null];
        } catch (Throwable $e) {
            return ['ok' => false, 'error' => $e->getMessage()];
        }
    }

    // ── Metrics (light — used by the poller and dashboard fallback) ──────

    /** @return array{cpu:int,mem:int,disk:int,load:float} */
    public function metrics(Server $server): array
    {
        $ssh = $this->client($server);

        try {
            return $this->readMetrics($ssh);
        } finally {
            $ssh->disconnect();
        }
    }

    /**
     * Run the metrics command on an already-connected client. The poller reuses
     * one long-lived connection per server (a single login, not one per tick).
     *
     * @return array{cpu:int,mem:int,disk:int,load:float}
     */
    public function readMetrics(SshClient $ssh): array
    {
        $cmd = 'export LC_ALL=C; '
            .'echo '.self::MARK.'mem===; grep -E "MemTotal|MemAvailable" /proc/meminfo; '
            .'echo '.self::MARK.'stat1===; head -1 /proc/stat; sleep 1; '
            .'echo '.self::MARK.'stat2===; head -1 /proc/stat; '
            .'echo '.self::MARK.'load===; cat /proc/loadavg; '
            .'echo '.self::MARK.'cores===; nproc; '
            .'echo '.self::MARK.'disk===; df -B1 --output=size,used,pcent / | tail -1';

        $s = $this->sections($ssh->exec($cmd));

        $memTotalKb = $this->memTotalKb($s['mem'] ?? '');
        $memAvailKb = (int) (preg_match('/MemAvailable:\s+(\d+)/', $s['mem'] ?? '', $mm) ? $mm[1] : 0);
        $df = preg_split('/\s+/', trim($s['disk'] ?? ''));  // size used pcent

        return [
            'cpu' => $this->cpuPercent($s['stat1'] ?? '', $s['stat2'] ?? ''),
            'mem' => $this->memPercent($s['mem'] ?? ''),
            'disk' => (int) str_replace('%', '', $df[2] ?? '0'),
            'load' => (float) (preg_split('/\s+/', trim($s['load'] ?? '0'))[0] ?? 0),
            'cores' => max(1, (int) trim($s['cores'] ?? '1')),
            'mem_used' => $memTotalKb > 0 ? round(($memTotalKb - $memAvailKb) / 1048576, 1) : 0.0,
            'mem_total' => $memTotalKb > 0 ? round($memTotalKb / 1048576, 1) : 0.0,
            'disk_used' => isset($df[1]) && ctype_digit($df[1]) ? (int) round((int) $df[1] / 1_000_000_000) : 0,
            'disk_total' => isset($df[0]) && ctype_digit($df[0]) ? (int) round((int) $df[0] / 1_000_000_000) : 0,
        ];
    }

    /** Poll metrics and persist them onto the Server row. */
    public function refresh(Server $server): array
    {
        $m = $this->metrics($server);
        $this->applyMetrics($server, $m);

        return $m;
    }

    /** Persist a metrics reading onto the Server row + cache (latest + rolling history). */
    public function applyMetrics(Server $server, array $m): void
    {
        $server->forceFill([
            'cpu' => $m['cpu'],
            'mem' => $m['mem'],
            'disk' => $m['disk'],
            'status' => $this->statusFor($m),
            'last_seen_at' => now(),
        ])->save();

        Cache::put("metrics:latest:{$server->id}", $m, now()->addMinutes(5));

        $history = Cache::get("metrics:history:{$server->id}", []);
        $history[] = ['cpu' => $m['cpu'], 'mem' => $m['mem'], 'disk' => $m['disk'], 'load' => round($m['load'], 2)];
        Cache::put("metrics:history:{$server->id}", array_slice($history, -40), now()->addHour());
    }

    /** Last persisted metrics reading (cache), or null. */
    public function latest(Server $server): ?array
    {
        return Cache::get("metrics:latest:{$server->id}");
    }

    /**
     * Rolling cpu/mem history for sparklines/charts.
     *
     * @return array{cpu: array<int,int>, mem: array<int,int>}
     */
    public function history(Server $server): array
    {
        $h = Cache::get("metrics:history:{$server->id}", []);

        return [
            'cpu' => array_map('intval', array_column($h, 'cpu')),
            'mem' => array_map('intval', array_column($h, 'mem')),
            'disk' => array_map('intval', array_column($h, 'disk')),
            'load' => array_map('floatval', array_column($h, 'load')),
        ];
    }

    private function statusFor(array $m): string
    {
        return match (true) {
            $m['cpu'] >= 90 || $m['mem'] >= 92 || $m['disk'] >= 90 => 'warning',
            default => 'online',
        };
    }

    // ── Full host snapshot (Server-Details) ─────────────────────────────

    public function snapshot(Server $server): array
    {
        $ssh = $this->client($server);
        $cmd = 'export LC_ALL=C; '
            .'echo '.self::MARK.'mem===; grep -E "MemTotal|MemAvailable" /proc/meminfo; '
            .'echo '.self::MARK.'stat1===; head -1 /proc/stat; sleep 1; '
            .'echo '.self::MARK.'stat2===; head -1 /proc/stat; '
            .'echo '.self::MARK.'load===; cat /proc/loadavg; '
            .'echo '.self::MARK.'uptime===; cat /proc/uptime; '
            .'echo '.self::MARK.'os===; . /etc/os-release 2>/dev/null; echo "$PRETTY_NAME"; uname -r; uname -m; nproc; hostname; systemd-detect-virt 2>/dev/null || echo -; '
            .'echo '.self::MARK.'df===; df -B1 -x tmpfs -x devtmpfs -x overlay -x squashfs --output=target,fstype,size,pcent; '
            .'echo '.self::MARK.'addr===; ip -o -4 addr show; '
            .'echo '.self::MARK.'link===; ip -o link show; '
            .'echo '.self::MARK.'netdev===; cat /proc/net/dev; '
            // hardening state (sshd -T + pkg checks) is read privileged via HardeningService::state().
            .'echo '.self::MARK.'keys===; ssh-keygen -lf ~/.ssh/authorized_keys 2>/dev/null';

        try {
            $s = $this->sections($ssh->exec($cmd));
        } finally {
            $ssh->disconnect();
        }

        $osLines = $this->lines($s['os'] ?? '');
        $totalKb = $this->memTotalKb($s['mem'] ?? '');
        $volumes = $this->parseVolumes($s['df'] ?? '');
        $rootBytes = collect($volumes)->firstWhere('mount', '/')['bytes'] ?? 0;

        return [
            'metrics' => [
                'cpu' => $this->cpuPercent($s['stat1'] ?? '', $s['stat2'] ?? ''),
                'mem' => $this->memPercent($s['mem'] ?? ''),
                'load' => (float) (preg_split('/\s+/', trim($s['load'] ?? '0'))[0] ?? 0),
            ],
            'identity' => [
                'os' => $osLines[0] ?? '—',
                'kernel' => $osLines[1] ?? '—',
                'arch' => $osLines[2] ?? '—',
                'cores' => (int) ($osLines[3] ?? 0),
                'hostname' => $osLines[4] ?? '—',
                'virt' => $osLines[5] ?? '—',
                'ram_gb' => $totalKb > 0 ? round($totalKb / 1048576, 1) : 0,
                'disk_gb' => $rootBytes > 0 ? (int) round($rootBytes / 1e9) : 0,
                'uptime' => $this->humanUptime((float) (preg_split('/\s+/', trim($s['uptime'] ?? '0'))[0] ?? 0)),
            ],
            'volumes' => $volumes,
            'interfaces' => $this->parseInterfaces($s['addr'] ?? '', $s['link'] ?? '', $s['netdev'] ?? ''),
            'sshKeys' => $this->parseKeys($s['keys'] ?? ''),
        ];
    }

    // ── systemd services + journal (Services page) ──────────────────────

    /** @return array{services:array<int,array>,journal:array<int,array>,cursor:?string} */
    public function systemd(Server $server, int $journalLines = 25): array
    {
        $ssh = $this->client($server);
        $cmd = 'export LC_ALL=C; '
            .'echo '.self::MARK.'units===; systemctl list-units --type=service --all --plain --no-legend --no-pager; '
            .'echo '.self::MARK.'files===; systemctl list-unit-files --type=service --no-legend --no-pager; '
            .'echo '.self::MARK.'journal===; '.$this->sudoFn($server).'priv journalctl -n '.(int) $journalLines.' --no-pager -o short-iso --show-cursor 2>&1';

        try {
            $s = $this->sections($ssh->exec($cmd));
        } finally {
            $ssh->disconnect();
        }

        [$body, $cursor] = $this->splitJournalCursor($s['journal'] ?? '');
        $journal = $this->parseJournalLines($body);
        if ($journal === []) {
            $journal[] = ['time' => '—', 'unit' => 'journal', 'level' => 'warn', 'text' => __('backend.journal_needs_privileges')];
        }

        return [
            'services' => $this->parseUnits($s['units'] ?? '', $s['files'] ?? ''),
            'journal' => $journal,
            'cursor' => $cursor,
        ];
    }

    /**
     * Incremental journal read for the live poll: only entries AFTER $cursor, plus
     * the new cursor to resume from. Without a valid cursor (first call) it returns
     * the last $tail entries like systemd(). Empty result = nothing new since the
     * cursor (NOT a permission problem), so no placeholder row is injected.
     *
     * This is a bounded LIVE TAIL, not a complete log shipper: `-n $tail` keeps the
     * NEWEST $tail entries since the cursor, so a single tick that bursts past $tail
     * lines shows the most recent ones (what "live" means) and drops the older overflow
     * — which the capped on-screen window would evict anyway. Set $tail to the display
     * ceiling so the visible window is always fully filled. Replaying a full backlog is
     * the deferred web terminal's job, not this poll's.
     *
     * @return array{journal:array<int,array>,cursor:?string}
     */
    public function journalSince(Server $server, ?string $cursor, int $tail = 200): array
    {
        // Journald cursors are `key=hex` segments joined by `;` — reject anything else
        // before it reaches the shell, then single-quote it.
        $valid = is_string($cursor) && preg_match('/^[a-z0-9;=]+$/i', $cursor) === 1;
        $selector = $valid ? "--after-cursor='".$cursor."'" : '';

        $ssh = $this->client($server);
        $cmd = 'export LC_ALL=C; '.$this->sudoFn($server)
            .'priv journalctl '.$selector.' -n '.(int) $tail.' --no-pager -o short-iso --show-cursor 2>&1';

        try {
            $out = $ssh->exec($cmd);
        } finally {
            $ssh->disconnect();
        }

        [$body, $next] = $this->splitJournalCursor($out);

        return [
            'journal' => $this->parseJournalLines($body),
            'cursor' => $next ?? $cursor, // keep the old cursor when nothing new arrived
        ];
    }

    // ── SFTP-ish directory listing (Files page) ─────────────────────────

    /** @return array<int,array{name:string,type:string,size:?int,perms:string,owner:string,modified:string}> */
    public function files(Server $server, string $path): array
    {
        $ssh = $this->client($server);
        $safe = "'".str_replace("'", "'\\''", $path)."'";

        try {
            [$out, $code] = $ssh->run('export LC_ALL=C; ls -la --time-style=long-iso '.$safe.' 2>/dev/null');
        } finally {
            $ssh->disconnect();
        }

        if ($code !== 0) {
            return [];
        }

        $entries = [];
        foreach ($this->lines($out) as $line) {
            if ($line === '' || str_starts_with($line, 'total ') || str_starts_with($line, 'insgesamt ')) {
                continue;
            }
            // perms links owner group size YYYY-MM-DD HH:MM name...
            $p = preg_split('/\s+/', $line, 8);
            if (count($p) < 8) {
                continue;
            }
            [$perms, , $owner, , $size, $date, $time, $name] = $p;
            if ($name === '.' || $name === '..') {
                continue;
            }
            $type = match ($perms[0] ?? '-') {
                'd' => 'dir',
                'l' => 'link',
                default => 'file',
            };
            // strip a "-> target" suffix from symlink names
            if ($type === 'link' && str_contains($name, ' -> ')) {
                $name = strstr($name, ' -> ', true);
            }
            $entries[] = [
                'name' => $name,
                'type' => $type,
                'size' => $type === 'dir' ? null : (int) $size,
                'perms' => $perms,
                'owner' => $owner,
                'modified' => $date.' '.$time,
            ];
        }

        usort($entries, fn ($a, $b) => [$a['type'] !== 'dir', $a['name']] <=> [$b['type'] !== 'dir', $b['name']]);

        return $entries;
    }

    // ── control (write) actions — work without sudo, in the SSH user's space ─

    /** Append a public key to the SSH user's authorized_keys. */
    public function addAuthorizedKey(Server $server, string $publicKey): void
    {
        $key = trim(preg_replace('/\s+/', ' ', $publicKey) ?? '');

        if (! preg_match('#^(ssh-(rsa|ed25519|dss)|ecdsa-sha2-\S+)\s+[A-Za-z0-9+/=]+#', $key)) {
            throw new InvalidArgumentException('Ungueltiger SSH-Public-Key.');
        }

        $ssh = $this->client($server);

        try {
            // base64-encode so the key never touches the shell unquoted
            $b64 = base64_encode($key);
            [$out] = $ssh->run(
                'umask 077; mkdir -p ~/.ssh && touch ~/.ssh/authorized_keys && chmod 700 ~/.ssh && chmod 600 ~/.ssh/authorized_keys; '
                ."k=$(printf '%s' '{$b64}' | base64 -d); "
                .'grep -qxF "$k" ~/.ssh/authorized_keys || printf "%s\n" "$k" >> ~/.ssh/authorized_keys; echo DONE'
            );

            if (! str_contains($out, 'DONE')) {
                throw new RuntimeException('Schreiben der authorized_keys fehlgeschlagen.');
            }
        } finally {
            $ssh->disconnect();
        }
    }

    /** Remove an authorized key by SHA256 fingerprint, preserving all others. */
    public function removeAuthorizedKey(Server $server, string $fingerprint): void
    {
        $sftp = (new Sftp($this->vault))->connect($server);

        try {
            $content = $sftp->get('.ssh/authorized_keys');
            if (trim($content) === '') {
                throw new RuntimeException(__('backend.authorized_keys_unreadable'));
            }

            $lines = array_values(array_filter(
                array_map('rtrim', preg_split('/\R/', $content)),
                fn ($l) => $l !== ''
            ));
            $kept = array_values(array_filter($lines, fn ($l) => $this->keyFingerprint($l) !== $fingerprint));

            if (count($kept) === count($lines)) {
                return; // nothing matched — never rewrite blindly (no lockout)
            }

            $sftp->put('.ssh/authorized_keys', $kept === [] ? '' : implode("\n", $kept)."\n");
        } finally {
            $sftp->disconnect();
        }
    }

    private function keyFingerprint(string $line): ?string
    {
        try {
            return 'SHA256:'.PublicKeyLoader::load($line)->getFingerprint('sha256');
        } catch (Throwable) {
            return null;
        }
    }

    /** Re-read just the authorized keys (after an add/remove). */
    public function sshKeys(Server $server): array
    {
        $ssh = $this->client($server);

        try {
            return $this->parseKeys($ssh->exec('ssh-keygen -lf ~/.ssh/authorized_keys 2>/dev/null'));
        } finally {
            $ssh->disconnect();
        }
    }

    /** Delete a remote file over SFTP (needs write permission; no sudo). */
    public function deleteFile(Server $server, string $path): void
    {
        $sftp = (new Sftp($this->vault))->connect($server);

        try {
            if (! $sftp->delete($path)) {
                throw new RuntimeException(__('backend.delete_failed_perms'));
            }
        } finally {
            $sftp->disconnect();
        }
    }

    /** Raw file content for download (capped at 50 MB). */
    public function getFile(Server $server, string $path): string
    {
        $sftp = (new Sftp($this->vault))->connect($server);

        try {
            if ($sftp->size($path) > 50_000_000) {
                throw new RuntimeException(__('backend.file_too_large_download'));
            }

            return (string) $sftp->get($path);
        } finally {
            $sftp->disconnect();
        }
    }

    /**
     * Read a file for the editor: size-capped, with binary detection.
     *
     * @return array{content: string, binary: bool, tooBig: bool}
     */
    public function readFile(Server $server, string $path, int $maxBytes = 262144): array
    {
        $sftp = (new Sftp($this->vault))->connect($server);

        try {
            $size = $sftp->size($path);
            $content = $size > $maxBytes ? '' : (string) $sftp->get($path);
            $binary = $content !== '' && (str_contains(substr($content, 0, 8000), "\0") || ! mb_check_encoding($content, 'UTF-8'));

            // Never hand back raw non-UTF-8 bytes: bound to a Livewire public property they break the
            // JSON snapshot ("undefined is not valid JSON"). The caller renders a binary notice instead.
            return ['content' => $binary ? '' : $content, 'binary' => $binary, 'tooBig' => $size > $maxBytes];
        } finally {
            $sftp->disconnect();
        }
    }

    public function writeFile(Server $server, string $path, string $content): void
    {
        $sftp = (new Sftp($this->vault))->connect($server);

        try {
            if (! $sftp->put($path, $content)) {
                throw new RuntimeException(__('backend.save_failed_perms'));
            }
        } finally {
            $sftp->disconnect();
        }
    }

    public function uploadFile(Server $server, string $remotePath, string $localPath): void
    {
        $sftp = (new Sftp($this->vault))->connect($server);

        try {
            if (! $sftp->putFromFile($remotePath, $localPath)) {
                throw new RuntimeException(__('backend.upload_failed_perms'));
            }
        } finally {
            $sftp->disconnect();
        }
    }

    /**
     * systemctl start/stop/restart. Runs directly as root, else via `sudo -n`
     * (needs a root or nopasswd-sudo credential).
     *
     * @return array{ok: bool, output: string}
     */
    public function serviceAction(Server $server, string $op, string $unit): array
    {
        if (! in_array($op, ['start', 'stop', 'restart'], true)) {
            throw new InvalidArgumentException('Unbekannte Aktion.');
        }
        if (! preg_match('/^[\w@.:-]+$/', $unit)) {
            throw new InvalidArgumentException('Ungueltige Unit.');
        }

        $ssh = $this->client($server);

        try {
            [$out, $code] = $ssh->run(
                'export LC_ALL=C; '.$this->sudoFn($server)."priv systemctl {$op} {$unit} 2>&1"
            );

            return ['ok' => $code === 0, 'output' => trim($out)];
        } finally {
            $ssh->disconnect();
        }
    }

    // ── parsers ─────────────────────────────────────────────────────────

    private function lines(string $body): array
    {
        return array_values(array_filter(
            array_map('rtrim', preg_split('/\R/', $body)),
            fn ($l) => $l !== ''
        ));
    }

    private function cpuPercent(string $a, string $b): int
    {
        $fa = $this->cpuFields($a);
        $fb = $this->cpuFields($b);
        if (! $fa || ! $fb) {
            return 0;
        }
        $totalA = array_sum($fa);
        $totalB = array_sum($fb);
        $dTotal = $totalB - $totalA;
        $dIdle = ($fb['idle'] + $fb['iowait']) - ($fa['idle'] + $fa['iowait']);
        if ($dTotal <= 0) {
            return 0;
        }

        return max(0, min(100, (int) round(100 * ($dTotal - $dIdle) / $dTotal)));
    }

    private function cpuFields(string $line): array
    {
        $p = preg_split('/\s+/', trim($line));
        if (($p[0] ?? '') !== 'cpu') {
            return [];
        }
        $n = array_map('intval', array_slice($p, 1, 8));

        return [
            'user' => $n[0] ?? 0, 'nice' => $n[1] ?? 0, 'system' => $n[2] ?? 0,
            'idle' => $n[3] ?? 0, 'iowait' => $n[4] ?? 0, 'irq' => $n[5] ?? 0,
            'softirq' => $n[6] ?? 0, 'steal' => $n[7] ?? 0,
        ];
    }

    private function memTotalKb(string $body): int
    {
        return (int) (preg_match('/MemTotal:\s+(\d+)/', $body, $m) ? $m[1] : 0);
    }

    private function memPercent(string $body): int
    {
        $total = $this->memTotalKb($body);
        $avail = (int) (preg_match('/MemAvailable:\s+(\d+)/', $body, $m) ? $m[1] : 0);

        return $total > 0 ? max(0, min(100, (int) round(100 * ($total - $avail) / $total))) : 0;
    }

    private function humanUptime(float $seconds): string
    {
        $d = intdiv((int) $seconds, 86400);
        $h = intdiv((int) $seconds % 86400, 3600);

        return $d > 0 ? "{$d}d {$h}h" : "{$h}h";
    }

    private function parseVolumes(string $body): array
    {
        $out = [];
        foreach ($this->lines($body) as $line) {
            // df --output=target,fstype,size,pcent; the mount (target) can contain
            // spaces, so pop the three trailing single-token fields and rejoin the rest.
            $p = preg_split('/\s+/', trim($line));
            if (count($p) < 4 || ! str_starts_with($p[0], '/')) {
                continue; // header / noise
            }
            $pcent = array_pop($p);
            $size = array_pop($p);
            $fs = array_pop($p);
            $mount = implode(' ', $p);
            if (! ctype_digit($size)) {
                continue;
            }
            $out[] = [
                'mount' => $mount,
                'fs' => $fs,
                'size' => $this->humanBytes((int) $size),
                'bytes' => (int) $size,
                'used' => (int) str_replace('%', '', $pcent),
            ];
        }

        return $out;
    }

    private function parseInterfaces(string $addr, string $link, string $netdev): array
    {
        $ips = [];
        foreach ($this->lines($addr) as $line) {
            // "2: ens18    inet 203.0.113.11/24 brd ..."
            if (preg_match('/^\d+:\s+(\S+)\s+inet\s+([\d.]+)/', $line, $m)) {
                $ips[$m[1]][] = $m[2];
            }
        }
        $macs = [];
        foreach ($this->lines($link) as $line) {
            if (preg_match('/^\d+:\s+([^:@]+)[:@].*link\/\w+\s+([0-9a-f:]{17})/', $line, $m)) {
                $macs[trim($m[1])] = $m[2];
            }
        }
        $traffic = [];
        foreach ($this->lines($netdev) as $line) {
            if (preg_match('/^\s*([^:]+):\s*(\d+)(?:\s+\d+){7}\s+(\d+)/', $line, $m)) {
                $traffic[trim($m[1])] = ['rx' => (int) $m[2], 'tx' => (int) $m[3]];
            }
        }

        $out = [];
        foreach ($ips as $name => $list) {
            $out[] = [
                'name' => $name,
                'ip' => implode(', ', $list),
                'mac' => $macs[$name] ?? '—',
                'rx' => isset($traffic[$name]) ? $this->humanBytes($traffic[$name]['rx']) : '—',
                'tx' => isset($traffic[$name]) ? $this->humanBytes($traffic[$name]['tx']) : '—',
            ];
        }

        return $out;
    }

    private function parseKeys(string $body): array
    {
        $out = [];
        foreach ($this->lines($body) as $line) {
            // "3072 SHA256:xxxx comment with spaces (RSA)"
            if (! preg_match('/^(\d+)\s+(\S+)\s+(.*?)\s*\(([^)]+)\)\s*$/', $line, $m)) {
                continue;
            }
            $algo = strtolower($m[4]);
            $out[] = [
                'comment' => trim($m[3]) ?: '—',
                'type' => $algo.'-'.$m[1],
                'fingerprint' => $m[2],
            ];
        }

        return $out;
    }

    private function parseUnits(string $units, string $files): array
    {
        $enabled = [];
        foreach ($this->lines($files) as $line) {
            $p = preg_split('/\s+/', trim($line));
            if (count($p) >= 2) {
                $enabled[$p[0]] = in_array($p[1], ['enabled', 'static', 'enabled-runtime'], true);
            }
        }

        $out = [];
        foreach ($this->lines($units) as $line) {
            // UNIT LOAD ACTIVE SUB DESCRIPTION
            $p = preg_split('/\s+/', trim($line), 5);
            if (count($p) < 5) {
                continue;
            }
            [$unit, $load, $active, $sub, $desc] = $p;
            if ($load === 'not-found') {
                continue;
            }
            $out[] = [
                'name' => $unit,
                'status' => match ($active) {
                    'active' => 'online',
                    'failed' => 'offline',
                    'activating', 'deactivating', 'reloading' => 'warning',
                    default => 'offline', // inactive/dead
                },
                'sub' => $sub,
                'enabled' => $enabled[$unit] ?? false,
                'desc' => $desc,
            ];
        }

        return $out;
    }

    /**
     * Pull the trailing `-- cursor: X` line (journalctl --show-cursor) off the body.
     *
     * @return array{0:string,1:?string} [body without the cursor line, cursor or null]
     */
    private function splitJournalCursor(string $body): array
    {
        $cursor = null;
        $kept = [];
        foreach ($this->lines($body) as $line) {
            if (preg_match('/^-- cursor:\s*(\S+)/', $line, $m)) {
                $cursor = $m[1];

                continue;
            }
            $kept[] = $line;
        }

        return [implode("\n", $kept), $cursor];
    }

    /** Parse journalctl `short-iso` lines into structured rows. No placeholder on empty. */
    private function parseJournalLines(string $body): array
    {
        $out = [];
        foreach ($this->lines($body) as $line) {
            // "2026-06-12T21:05:26+02:00 host unit[pid]: message"
            if (! preg_match('/^(\d{4}-\d\d-\d\dT[\d:]+)\S*\s+\S+\s+([^\s\[]+)(?:\[\d+\])?:\s+(.*)$/', $line, $m)) {
                continue;
            }
            $text = $m[3];
            $level = match (true) {
                (bool) preg_match('/\b(fail|failed|error|fatal|denied|refused)\b/i', $text) => 'error',
                (bool) preg_match('/\b(warn|warning|deprecat)\b/i', $text) => 'warn',
                default => 'info',
            };
            $out[] = [
                'time' => str_replace('T', ' ', substr($m[1], 0, 19)),
                'unit' => $m[2],
                'level' => $level,
                'text' => $text,
            ];
        }

        return $out;
    }

    private function humanBytes(int $bytes): string
    {
        if ($bytes <= 0) {
            return '0 B';
        }
        $units = ['B', 'KB', 'MB', 'GB', 'TB', 'PB'];
        $i = (int) floor(log($bytes, 1024));
        $i = min($i, count($units) - 1);

        return round($bytes / (1024 ** $i), $i >= 3 ? 2 : 0).' '.$units[$i];
    }
}
