<?php
// ═══════════════════════════════════════════════════════════════════════════════════════════════
// PURPOSE
//   The system-metrics library. Everything the monitor page shows about this machine —
//   CPU per core, memory breakdown, GPUs, disks and pools, network, UPS, parity, VMs,
//   containers, transcodes — plus a roll-up of the same for remote nodes.
//
// DESIGN PRINCIPLES
//   Prefer the Unraid API, fall back to reading the system directly.
//       vv_api_data() is tried first; when it is unavailable each metric has a local path
//       (/proc, /sys, emhttp ini files, shell tools). The API going away degrades detail,
//       never the page.
//
//   Remote stats read every host*.conf, not just this host's.
//       A partner's API key lives in the partner's own conf. vv_remote_hosts_stats() globs
//       CONF_DIR for all host*.conf and merges what it finds, because sparse checkout means
//       the partner's file arrives through the conf cache rather than from git.
//
//   Background cache first, live call second.
//       Remote payloads written by remote_arr_cache_writer.sh (every 2h) are used when
//       present; otherwise a live call runs behind a 30s inline cache. The expensive path is
//       the exception, not the default.
//
//   Reports raw numbers, applies no policy.
//       Thresholds, alerting and remediation belong to the watchdogs. This file answers
//       "what is the value" and nothing else.
//
// OPERATIONAL SAFEGUARDS
//   Every read degrades to empty, never fatal.
//       Filesystem reads use @ with a ?: fallback and every shell_exec redirects stderr.
//       A missing GPU, absent UPS, or unreadable sysfs node yields [] and the corresponding
//       card simply does not render. One missing subsystem cannot blank the whole page.
//
//   Absent tooling is a normal outcome.
//       No nvidia-smi means no GPU section — not an error. The page is built to be correct
//       on hardware that lacks any given subsystem.
//
//   Missing state files return an explicit unavailable flag.
//       vv_transcode_sessions() returns ['available' => false] when transcode_state.db does
//       not exist, so the caller can distinguish "not running" from "zero sessions".
//
//   External IP lookups are cached and time-boxed.
//       curl runs with --max-time and the result is cached 300s, so a slow or unreachable
//       endpoint cannot stall a page render.
//
//   Read-only throughout. Nothing here starts, stops, or reconfigures anything.
//
// EXPORTS
//   System      vv_system_info(), vv_system_resources(), vv_cpu_per_core(), vv_memory_breakdown()
//   Storage     vv_df(), vv_storage_pools(), vv_array_disks(), vv_disk_io_rates(),
//               vv_disk_thresholds(), vv_disk_entry(), vv_parity_status()
//   Hardware    vv_gpu_stats(), vv_gpu_stats_all(), vv_gpu_processes(), vv_ups_stats()
//   Containers  vv_docker_containers(), vv_docker_stopped()
//   Network     vv_network_stats()
//   Remote      vv_remote_hosts_stats()
//   Misc        vv_transcode_sessions(), vv_log_tail(), vv_parse_bash_array()
//
// CONFIGURATION
//   STATE_DIR              transcode_state.db lives here
//   HOST*_UNRAID_API_KEY   per-host, read from every host*.conf for remote metrics
//   VV_CACHE_DIR           ext_ip (300s) and monitor_remote_<host> (written externally)
// ═══════════════════════════════════════════════════════════════════════════════════════════════

require_once __DIR__ . '/config.php';
require_once __DIR__ . '/unraid_api.php';

function vv_system_info(): array {
    // ── Shared local reads (always needed regardless of API) ──────────────────
    $ident = @parse_ini_file('/boot/config/ident.cfg') ?: [];
    $var   = [];
    foreach (@file('/var/local/emhttp/var.ini') ?: [] as $line) {
        if (preg_match('/^(\w+)="([^"]*)"/', $line, $m)) $var[$m[1]] = $m[2];
    }
    $version = trim(@file_get_contents('/etc/unraid-version') ?: '');

    // ── API path ──────────────────────────────────────────────────────────────
    $api = vv_api_data();
    if ($api) {
        $os  = $api['info']['os']  ?? [];
        $cpu = $api['info']['cpu'] ?? [];

        // uptime is a String in this schema — try numeric (seconds) first, else fall back to /proc/uptime
        $uptimeRaw = $os['uptime'] ?? '';
        if (is_numeric($uptimeRaw)) {
            $uptimeSec = (int)$uptimeRaw;
        } else {
            $uptimeSec = (int)explode(' ', @file_get_contents('/proc/uptime') ?: '0')[0];
        }
        $uptime = vv_format_uptime($uptimeSec);

        $load = sys_getloadavg();
        return [
            'name'        => $os['hostname']   ?? ($ident['NAME'] ?? gethostname()),
            'comment'     => $ident['COMMENT'] ?? '',
            'timezone'    => $ident['timeZone'] ?? 'UTC',
            'cpu_model'   => $cpu['brand']      ?? ($ident['SYS_MODEL'] ?? ''),
            'cpu_threads' => (int)($cpu['threads'] ?? 0),
            'cpu_cores'   => (int)($cpu['cores']   ?? 0),
            'reg_type'    => 'Unraid OS ' . ($var['regTy'] ?? ''),
            'reg_to'      => $var['regTo']      ?? '',
            'uptime'      => $uptime,
            'uptime_sec'  => $uptimeSec,
            'array_state' => strtoupper($api['array']['state'] ?? $var['mdState'] ?? 'UNKNOWN'),
            'version'     => trim($os['release'] ?? '') ?: $version,
            'load_avg'    => $load ? [round($load[0], 2), round($load[1], 2), round($load[2], 2)] : null,
        ];
    }

    // ── Local fallback ────────────────────────────────────────────────────────
    vv_api_record_fallback('system_info');
    $cpuModel = '';
    foreach (@file('/proc/cpuinfo') ?: [] as $line) {
        if (preg_match('/^model name\s*:\s*(.+)/', $line, $m)) { $cpuModel = trim($m[1]); break; }
    }
    $uptimeSec = (int)explode(' ', @file_get_contents('/proc/uptime') ?: '0')[0];
    $uptime    = vv_format_uptime($uptimeSec);

    $load = sys_getloadavg();
    return [
        'name'        => $ident['NAME']      ?? gethostname(),
        'comment'     => $ident['COMMENT']   ?? '',
        'timezone'    => $ident['timeZone']  ?? 'UTC',
        'cpu_model'   => $ident['SYS_MODEL'] ?? $cpuModel,
        'cpu_threads' => 0,
        'cpu_cores'   => 0,
        'reg_type'    => 'Unraid OS ' . ($var['regTy'] ?? ''),
        'reg_to'      => $var['regTo']       ?? '',
        'uptime'      => $uptime,
        'uptime_sec'  => $uptimeSec,
        'array_state' => $var['mdState']     ?? 'UNKNOWN',
        'version'     => $version,
        'load_avg'    => $load ? [round($load[0], 2), round($load[1], 2), round($load[2], 2)] : null,
    ];
}

function vv_docker_containers(): array {
    $out = shell_exec('docker ps --format \'{"name":"{{.Names}}","status":"{{.Status}}","image":"{{.Image}}"}\' 2>/dev/null');
    $containers = [];
    foreach (explode("\n", trim($out ?? '')) as $line) {
        if (!$line) continue;
        $c = json_decode($line, true);
        if ($c) $containers[] = $c;
    }
    return $containers;
}

function vv_docker_stopped(): array {
    $out = shell_exec('docker ps -a --filter "status=exited" --filter "status=created" --format \'{"name":"{{.Names}}","status":"{{.Status}}"}\' 2>/dev/null');
    $containers = [];
    foreach (explode("\n", trim($out ?? '')) as $line) {
        if (!$line) continue;
        $c = json_decode($line, true);
        if ($c) $containers[] = $c;
    }
    return $containers;
}

// Every installed GPU, one entry per card. Parsed line by line — nvidia-smi emits one row
// per GPU, so splitting the whole output on commas (as this once did) runs the rows together
// and only ever describes GPU 0.
function vv_gpu_stats_all(): array {
    $out = shell_exec('nvidia-smi --query-gpu=index,uuid,name,memory.used,memory.total,utilization.gpu,temperature.gpu,power.draw,utilization.encoder,utilization.decoder --format=csv,noheader,nounits 2>/dev/null');
    if (!$out) return [];

    $gpus = [];
    foreach (explode("\n", trim($out)) as $line) {
        if (!$line) continue;
        $p = array_map('trim', explode(',', $line));
        if (count($p) < 10) continue;
        $gpus[] = [
            'available'    => true,
            'index'        => (int)$p[0],
            'uuid'         => $p[1],
            'name'         => $p[2],
            'memory_used'  => (int)$p[3],
            'memory_total' => (int)$p[4],
            'utilization'  => (int)$p[5],
            'temperature'  => (int)$p[6],
            'power_w'      => is_numeric($p[7]) ? round((float)$p[7], 1) : null,
            'enc_pct'      => (int)$p[8],
            'dec_pct'      => (int)$p[9],
        ];
    }
    return $gpus;
}

// Kept for the existing single-GPU consumers — same shape as before, always GPU 0.
function vv_gpu_stats(): array {
    $gpus = vv_gpu_stats_all();
    return $gpus[0] ?? ['available' => false];
}

// gpu_uuid is included so each process can be attributed to the card it is actually running
// on. Without it a two-GPU box shows every process under every card.
function vv_gpu_processes(): array {
    $out = shell_exec('nvidia-smi --query-compute-apps=gpu_uuid,pid,used_gpu_memory,name --format=csv,noheader,nounits 2>/dev/null');
    $procs = [];
    foreach (explode("\n", trim($out ?? '')) as $line) {
        if (!$line) continue;
        $parts = array_map('trim', explode(',', $line));
        $procs[] = [
            'gpu_uuid'   => $parts[0] ?? '',
            'pid'        => $parts[1] ?? '',
            'memory_mb'  => $parts[2] ?? '',
            'name'       => $parts[3] ?? '',
        ];
    }
    return $procs;
}

function vv_system_resources(): array {
    $mem = [];
    foreach (file('/proc/meminfo') ?: [] as $line) {
        if (preg_match('/^(MemTotal|MemAvailable):\s+(\d+)/', $line, $m))
            $mem[$m[1]] = (int)$m[2];
    }
    return [
        'ram_total_mb' => (int)(($mem['MemTotal']     ?? 0) / 1024),
        'ram_free_mb'  => (int)(($mem['MemAvailable'] ?? 0) / 1024),
        'cache'        => vv_df('/mnt/cache'),
    ];
}

function vv_cpu_per_core(): array {
    // Parse /proc/stat — [user, nice, system, idle, iowait, irq, softirq]
    $raw = [];
    foreach (file('/proc/stat') ?: [] as $line) {
        if (!preg_match('/^(cpu\d*)\s+(\d+)\s+(\d+)\s+(\d+)\s+(\d+)\s+(\d+)\s+(\d+)\s+(\d+)/', $line, $m)) continue;
        $raw[$m[1]] = [(int)$m[2],(int)$m[3],(int)$m[4],(int)$m[5],(int)$m[6],(int)$m[7],(int)$m[8]];
    }

    $stateFile = VV_CACHE_DIR . '/vv_cpu_stat.json';
    $prev = file_exists($stateFile) ? (json_decode(file_get_contents($stateFile), true) ?: []) : [];
    // Atomic write — concurrent fast/slow polls read a consistent snapshot
    $tmp = $stateFile . '.tmp';
    file_put_contents($tmp, json_encode($raw));
    rename($tmp, $stateFile);

    $usage = function(array $c, ?array $p): int {
        if (!$p) return 0;
        $dt = array_sum($c) - array_sum($p);
        $di = ($c[3] + $c[4]) - ($p[3] + $p[4]);
        return $dt > 0 ? max(0, min(100, (int)round((1 - $di / $dt) * 100))) : 0;
    };

    $overall = $usage($raw['cpu'] ?? [], $prev['cpu'] ?? null);
    $cores   = [];
    foreach ($raw as $cpu => $c) {
        if ($cpu === 'cpu') continue;
        $num     = (int)substr($cpu, 3);
        $freqKhz = (int)@file_get_contents("/sys/devices/system/cpu/$cpu/cpufreq/scaling_cur_freq");
        $maxKhz  = (int)@file_get_contents("/sys/devices/system/cpu/$cpu/cpufreq/cpuinfo_max_freq");
        $minKhz  = (int)@file_get_contents("/sys/devices/system/cpu/$cpu/cpufreq/cpuinfo_min_freq");
        $cores[] = [
            'core'      => $num,
            'usage_pct' => $usage($c, $prev[$cpu] ?? null),
            'freq_mhz'  => $freqKhz > 0 ? (int)round($freqKhz / 1000) : 0,
            'max_mhz'   => $maxKhz  > 0 ? (int)round($maxKhz  / 1000) : 0,
            'min_mhz'   => $minKhz  > 0 ? (int)round($minKhz  / 1000) : 0,
        ];
    }
    usort($cores, fn($a, $b) => $a['core'] - $b['core']);
    return ['overall' => $overall, 'cores' => $cores];
}

function vv_memory_breakdown(): array {
    $mem = [];
    foreach (file('/proc/meminfo') ?: [] as $line) {
        if (preg_match('/^(\w+):\s+(\d+)/', $line, $m)) $mem[$m[1]] = (int)$m[2];
    }
    $totalKb = $mem['MemTotal'] ?? 0;

    // ZFS ARC
    $arcKb = 0;
    foreach (@file('/proc/spl/kstat/zfs/arcstats') ?: [] as $line) {
        if (preg_match('/^size\s+\d+\s+(\d+)/', $line, $m)) { $arcKb = (int)($m[1] / 1024); break; }
    }

    // Docker — sum docker stats used memory per container (matches Unraid dashboard)
    $dockerKb = 0;
    $dsOut = shell_exec("docker stats --no-stream --format '{{.MemUsage}}' 2>/dev/null") ?: '';
    foreach (explode("\n", trim($dsOut)) as $line) {
        if (!preg_match('/^([0-9.]+)(GiB|MiB|KiB|B)\s*\//', trim($line), $m)) continue;
        $val = (float)$m[1];
        $dockerKb += match($m[2]) {
            'GiB'   => (int)($val * 1048576),
            'MiB'   => (int)($val * 1024),
            'KiB'   => (int)$val,
            default => (int)($val / 1024),
        };
    }

    // VM (QEMU/KVM RSS)
    $vmKb = 0;
    foreach (preg_split('/\s+/', trim(shell_exec('ps -C qemu-system-x86_64 -o rss= 2>/dev/null') ?: '')) as $rss) {
        if (is_numeric($rss) && $rss > 0) $vmKb += (int)$rss;
    }

    $freeKb   = max(0, $mem['MemAvailable'] ?? 0);
    $systemKb = max(0, $totalKb - $freeKb - $arcKb - $dockerKb - $vmKb);

    // Top processes by RSS — group same-named procs, take top 5
    $grouped = [];
    $psOut = shell_exec("ps -eo comm,rss --sort=-rss 2>/dev/null | tail -n +2 | head -40") ?: '';
    foreach (explode("\n", trim($psOut)) as $line) {
        $parts = preg_split('/\s+/', trim($line), 2);
        if (count($parts) === 2 && is_numeric($parts[1]) && (int)$parts[1] > 0)
            $grouped[$parts[0]] = ($grouped[$parts[0]] ?? 0) + (int)$parts[1];
    }
    arsort($grouped);
    $topProcs = [];
    foreach (array_slice($grouped, 0, 3, true) as $name => $kb)
        $topProcs[] = ['name' => $name, 'kb' => $kb];

    // Swap — from API metrics when available, else /proc/meminfo
    $swapTotalKb = 0; $swapUsedKb = 0;
    $apiMem = vv_api_data()['metrics']['memory'] ?? [];
    if (!empty($apiMem['swapTotal'])) {
        $swapTotalKb = (int)(((float)$apiMem['swapTotal']) / 1024);
        $swapUsedKb  = (int)(((float)$apiMem['swapUsed'])  / 1024);
    } else {
        $swapTotalKb = $mem['SwapTotal'] ?? 0;
        $swapUsedKb  = ($mem['SwapTotal'] ?? 0) - ($mem['SwapFree'] ?? 0);
    }

    return [
        'total_kb'      => $totalKb,
        'system_kb'     => $systemKb,
        'vm_kb'         => $vmKb,
        'zfs_kb'        => $arcKb,
        'docker_kb'     => $dockerKb,
        'free_kb'       => $freeKb,
        'swap_total_kb' => $swapTotalKb,
        'swap_used_kb'  => $swapUsedKb,
        'top_procs'     => $topProcs,
    ];
}

function vv_df(string $path): array {
    $out = shell_exec("df -BM --output=size,used,avail '$path' 2>/dev/null | tail -1");
    if (!$out) return ['available' => false, 'path' => $path];
    $parts = preg_split('/\s+/', trim($out));
    return [
        'available' => true,
        'path'      => $path,
        'size_mb'   => (int)$parts[0],
        'used_mb'   => (int)$parts[1],
        'free_mb'   => (int)$parts[2],
    ];
}

function vv_network_stats(): array {
    $iface = trim(shell_exec("ip route show default 2>/dev/null | awk 'NR==1{print \$5}'") ?: '');
    if (!$iface) {
        $best = ''; $bestBytes = 0;
        foreach (file('/proc/net/dev') ?: [] as $line) {
            if (!preg_match('/^\s*(\w+):\s+(\d+)/', $line, $m) || $m[1] === 'lo') continue;
            if ((int)$m[2] > $bestBytes) { $bestBytes = (int)$m[2]; $best = $m[1]; }
        }
        $iface = $best;
    }
    if (!$iface) return ['available' => false];

    $rxBytes = $txBytes = 0;
    foreach (file('/proc/net/dev') ?: [] as $line) {
        if (!preg_match('/^\s*' . preg_quote($iface, '/') . ':\s+(.+)$/', $line, $m)) continue;
        $parts   = preg_split('/\s+/', trim($m[1]));
        $rxBytes = (int)($parts[0] ?? 0);
        $txBytes = (int)($parts[8] ?? 0);
        break;
    }

    $stateFile = VV_CACHE_DIR . '/vv_net_stat.json';
    $now  = ['rx' => $rxBytes, 'tx' => $txBytes, 'ts' => microtime(true)];
    $prev = file_exists($stateFile) ? (json_decode(file_get_contents($stateFile), true) ?: []) : [];
    $tmp = $stateFile . '.tmp';
    file_put_contents($tmp, json_encode($now));
    rename($tmp, $stateFile);

    $rxRate = $txRate = 0;
    if (!empty($prev['ts']) && ($dt = $now['ts'] - $prev['ts']) > 0.1) {
        $rxRate = max(0, (int)(($rxBytes - ($prev['rx'] ?? $rxBytes)) / $dt));
        $txRate = max(0, (int)(($txBytes - ($prev['tx'] ?? $txBytes)) / $dt));
    }

    $speedMbps = (int)@file_get_contents("/sys/class/net/$iface/speed");

    // Local IP — use primary iface
    $localIp = trim(shell_exec(
        "ip -4 addr show " . escapeshellarg($iface) . " 2>/dev/null | awk '/inet /{print \$2}' | cut -d/ -f1 | head -1"
    ) ?: '');

    // External IP — curl ifconfig.me, cached 5 min so we don't hammer it
    $extIp   = '';
    $extData = vv_cache_read('ext_ip', 300);
    if ($extData) {
        $extIp = $extData['ip'] ?? '';
    } else {
        $fetched = trim(shell_exec('curl -sf --max-time 4 https://ifconfig.me 2>/dev/null') ?: '');
        if (preg_match('/^\d+\.\d+\.\d+\.\d+$/', $fetched)) {
            $extIp = $fetched;
            vv_cache_write('ext_ip', ['ip' => $extIp]);
        }
    }

    // Tailscale IP — use `tailscale ip` CLI (interface name varies: tailscale0, tailscale1, etc.)
    $tsIp = trim(shell_exec('tailscale ip -4 2>/dev/null | head -1') ?: '');

    return [
        'available'  => true,
        'iface'      => $iface,
        'speed_mbps' => $speedMbps > 0 ? $speedMbps : null,
        'rx_bps'     => $rxRate,
        'tx_bps'     => $txRate,
        'local_ip'   => $localIp,
        'ext_ip'     => $extIp,
        'ts_ip'      => $tsIp,
    ];
}

function vv_disk_entry(array $d, string $key, string $role = 'data'): ?array {
    $name      = $d['name'] ?? $key;
    $isParity  = $role === 'parity';
    $mounted   = ($d['fsStatus'] ?? '') === 'Mounted';
    // Parity has no filesystem — use raw size only
    $size_kb   = (int)($isParity ? ($d['size'] ?? 0) : ($mounted ? ($d['fsSize'] ?? 0) : ($d['size'] ?? 0)));
    $used_kb   = (int)($isParity ? 0 : ($d['fsUsed'] ?? 0));
    if ($size_kb <= 0) return null;
    $tempRaw   = trim($d['temp'] ?? '');
    return [
        'name'      => $name,
        'device'    => $d['device'] ?? $key,
        'role'      => $role,
        'size_gb'   => round($size_kb / 1048576, 1),
        'used_gb'   => round($used_kb / 1048576, 1),
        'pct'       => (!$isParity && $size_kb > 0) ? round($used_kb / $size_kb * 100, 1) : null,
        'temp'      => is_numeric($tempRaw) ? (int)$tempRaw : null,
        'transport' => $d['transport'] ?? 'ata',
        'mounted'   => $mounted,
        'status'    => $d['status'] ?? '',
    ];
}

function vv_ups_stats(): array {
    $raw = shell_exec('apcaccess 2>/dev/null') ?: '';
    if (!$raw) return ['available' => false];

    $fields = [];
    foreach (explode("\n", $raw) as $line) {
        if (preg_match('/^(\w+)\s*:\s*(.+)$/', trim($line), $m)) {
            $fields[trim($m[1])] = trim($m[2]);
        }
    }
    if (empty($fields)) return ['available' => false];

    $parse_num = fn(string $k) => isset($fields[$k]) ? (float)$fields[$k] : null;

    $loadPct  = $parse_num('LOADPCT');
    $nomPower = $parse_num('NOMPOWER');
    $watts    = ($loadPct !== null && $nomPower !== null) ? round($loadPct / 100 * $nomPower) : null;

    return [
        'available'  => true,
        'model'      => $fields['MODEL']   ?? '',
        'status'     => trim(explode(' ', $fields['STATUS'] ?? 'UNKNOWN')[0]),
        'line_v'     => $parse_num('LINEV'),
        'output_v'   => $parse_num('OUTPUTV'),
        'load_pct'   => $loadPct,
        'nom_power'  => $nomPower,
        'watts'      => $watts,
        'bcharge'    => $parse_num('BCHARGE'),
        'timeleft'   => $parse_num('TIMELEFT'),
        'num_xfers'  => (int)($fields['NUMXFERS'] ?? 0),
        'on_batt_s'  => $parse_num('CUMONBATT'),
        'selftest'   => $fields['SELFTEST'] ?? '',
    ];
}

function vv_parity_status(): array {
    $var = [];
    foreach (@file('/var/local/emhttp/var.ini') ?: [] as $line) {
        if (preg_match('/^(\w+)="([^"]*)"/', $line, $m)) $var[$m[1]] = $m[2];
    }

    $numDisabled = (int)($var['mdNumDisabled'] ?? 0);
    $numMissing  = (int)($var['mdNumMissing']  ?? 0);
    $exitCode    = (int)($var['sbSyncExit'] ?? 0);
    $errors      = (int)($var['sbSyncErrs'] ?? 0);
    // Emulated (DISK_DSBL) disks are protected by parity and don't make parity invalid.
    // True invalidity: sync errors on the last check, or unprotectable missing slots.
    $isValid     = $errors === 0 && $numMissing === 0;
    $inProgress   = ($var['mdResync'] ?? '0') !== '0';
    $resyncAction = trim($var['mdResyncAction'] ?? '');
    $resyncPos    = (int)($var['mdResyncPos'] ?? 0);
    $resyncSize   = (int)($var['mdResyncSize'] ?? 1);
    $resyncPct    = $resyncSize > 0 ? round($resyncPos / $resyncSize * 100, 1) : 0;

    // Last check from log
    $lastDate = null; $lastDuration = 0; $lastSpeed = 0; $lastErrors = 0; $lastExit = 0;
    $logFile  = '/boot/config/parity-checks.log';
    if (file_exists($logFile)) {
        $lines = file($logFile, FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES) ?: [];
        if ($lines) {
            $p = explode('|', trim(end($lines)));
            $lastDate     = trim($p[0] ?? '');
            $lastDuration = (int)($p[1] ?? 0);
            $lastSpeed    = (int)($p[2] ?? 0);
            $lastExit     = (int)($p[3] ?? 0);
            $lastErrors   = (int)($p[4] ?? 0);
        }
    }

    // Parse last date string to timestamp
    $lastTs = $lastDate ? strtotime($lastDate) : null;

    // Next scheduled check from cron
    $nextTs   = null;
    $cronFile = '/boot/config/plugins/dynamix/parity-check.cron';
    foreach (@file($cronFile) ?: [] as $line) {
        $line = trim($line);
        if ($line === '' || $line[0] === '#') continue;
        if (!str_contains($line, 'mdcmd')) continue;
        $p = preg_split('/\s+/', $line);
        // cron: min hour dom month dow command...
        if (count($p) >= 5 && is_numeric($p[0]) && is_numeric($p[1]) && is_numeric($p[2])) {
            $next = new DateTime('now');
            $next->setTime((int)$p[1], (int)$p[0], 0);
            $next->setDate((int)$next->format('Y'), (int)$next->format('n'), (int)$p[2]);
            if ($next->getTimestamp() <= time()) $next->modify('+1 month');
            $nextTs = $next->getTimestamp();
        }
        break;
    }

    $exitMap = ['0' => 'Completed', '-4' => 'Aborted', '-5' => 'Cancelled'];
    return [
        'valid'         => $isValid,
        'num_disabled'  => $numDisabled,
        'num_missing'   => $numMissing,
        'in_progress'     => $inProgress,
        'resync_action'   => $resyncAction,
        'resync_pct'      => $resyncPct,
        'exit_code'     => $exitCode,
        'exit_label'    => $exitMap[(string)$lastExit] ?? 'Unknown',
        'errors'        => $lastErrors,
        'last_date'     => $lastDate,
        'last_ts'       => $lastTs,
        'last_duration' => $lastDuration,
        'last_speed_mb' => $lastSpeed > 0 ? round($lastSpeed / 1048576, 1) : null,
        'next_ts'       => $nextTs,
    ];
}

// Returns a map of disk name → fsUsed in KB from disks.ini.
// Unraid keeps this updated even after spindown, so it's the authoritative source
// for used space when the API reports 0 because the filesystem is unmounted.
function _vv_ini_used_kb(): array {
    $ini = @parse_ini_file('/var/local/emhttp/disks.ini', true) ?: [];
    $out = [];
    foreach ($ini as $key => $d) {
        $name = $d['name'] ?? $key;
        $out[$name] = (int)($d['fsUsed'] ?? 0);
    }
    return $out;
}

function vv_storage_pools(): array {
    // ── API path ──────────────────────────────────────────────────────────────
    $api = vv_api_data();
    if ($api && isset($api['array']['caches'])) {
        $ini_used = _vv_ini_used_kb();
        $out = [];
        foreach ($api['array']['caches'] as $d) {
            $entry = vv_api_disk_entry($d, 'data', $ini_used[$d['name'] ?? ''] ?? 0);
            if ($entry) $out[] = $entry;
        }
        if (!empty($out)) {
            usort($out, fn($a, $b) => strcmp($a['name'], $b['name']));
            return $out;
        }
    }

    // ── Local fallback ────────────────────────────────────────────────────────
    vv_api_record_fallback('storage_pools');
    $ini = @parse_ini_file('/var/local/emhttp/disks.ini', true) ?: [];
    $out = [];
    foreach ($ini as $key => $d) {
        if (($d['type'] ?? '') !== 'Cache') continue;
        if (($d['fsStatus'] ?? '') !== 'Mounted') continue;
        $entry = vv_disk_entry($d, $key);
        if ($entry) $out[] = $entry;
    }
    usort($out, fn($a, $b) => strcmp($a['name'], $b['name']));
    return $out;
}

function vv_array_disks(): array {
    // ── API path ──────────────────────────────────────────────────────────────
    $api = vv_api_data();
    if ($api && (isset($api['array']['parities']) || isset($api['array']['disks']))) {
        $ini_used = _vv_ini_used_kb();
        $parity = [];
        $data   = [];
        foreach ($api['array']['parities'] ?? [] as $d) {
            $entry = vv_api_disk_entry($d, 'parity');
            if ($entry) $parity[] = $entry;
        }
        foreach ($api['array']['disks'] ?? [] as $d) {
            $entry = vv_api_disk_entry($d, 'data', $ini_used[$d['name'] ?? ''] ?? 0);
            if ($entry) $data[] = $entry;
        }
        if (!empty($parity) || !empty($data)) {
            usort($parity, fn($a, $b) => strnatcmp($a['name'], $b['name']));
            usort($data,   fn($a, $b) => strnatcmp($a['name'], $b['name']));
            return array_merge($parity, $data);
        }
    }

    // ── Local fallback ────────────────────────────────────────────────────────
    vv_api_record_fallback('array_disks');
    $ini     = @parse_ini_file('/var/local/emhttp/disks.ini', true) ?: [];
    $parity  = [];
    $data    = [];
    foreach ($ini as $key => $d) {
        $type = $d['type'] ?? '';
        if ($type === 'Parity') {
            $entry = vv_disk_entry($d, $key, 'parity');
            if ($entry) $parity[] = $entry;
        } elseif ($type === 'Data') {
            $entry = vv_disk_entry($d, $key, 'data');
            if ($entry) $data[] = $entry;
        }
    }
    usort($parity, fn($a, $b) => strnatcmp($a['name'], $b['name']));
    usort($data,   fn($a, $b) => strnatcmp($a['name'], $b['name']));
    return array_merge($parity, $data);
}

function vv_disk_io_rates(): array {
    $snapFile = VV_CACHE_DIR . '/vv_diskio_snap.json';
    $now      = microtime(true);

    // Read current whole-disk stats from /proc/diskstats
    $current = [];
    foreach (@file('/proc/diskstats', FILE_IGNORE_NEW_LINES) ?: [] as $line) {
        $p = preg_split('/\s+/', trim($line));
        if (count($p) < 14) continue;
        $dev = $p[2];
        // Keep only whole disks: sda/sdb, nvme0n1, md*, not sda1/nvme0n1p1
        if (!preg_match('/^(sd[a-z]+|nvme\d+n\d+|md\d+)$/', $dev)) continue;
        $current[$dev] = [(int)$p[5], (int)$p[9]]; // [sectors_read, sectors_written]
    }

    // Load previous snapshot
    $snap     = @json_decode(@file_get_contents($snapFile) ?: '', true) ?: [];
    $prevTime = (float)($snap['t'] ?? $now);
    $prev     = $snap['d'] ?? [];

    // Save current snapshot
    @file_put_contents($snapFile, json_encode(['t' => $now, 'd' => $current], JSON_UNESCAPED_UNICODE));

    $dt  = max(0.5, $now - $prevTime);
    $out = [];
    foreach ($current as $dev => [$rs, $ws]) {
        $entry = [
            'tr' => round($rs * 512 / 1073741824, 2), // cumulative GB read
            'tw' => round($ws * 512 / 1073741824, 2), // cumulative GB written
        ];
        if (isset($prev[$dev])) {
            [$prs, $pws] = $prev[$dev];
            $r = max(0.0, ($rs - $prs) * 512 / $dt / 1048576);
            $w = max(0.0, ($ws - $pws) * 512 / $dt / 1048576);
            if ($r > 0.01) $entry['r'] = round($r, 1);
            if ($w > 0.01) $entry['w'] = round($w, 1);
        }
        $out[$dev] = $entry;
    }
    return $out;
}

function vv_disk_thresholds(): array {
    $cfg  = @file_get_contents('/boot/config/plugins/dynamix/dynamix.cfg') ?: '';
    $get  = function(string $key) use ($cfg): ?int {
        return preg_match('/^\s*' . preg_quote($key, '/') . '\s*=\s*"?(\d+)"?/m', $cfg, $m)
            ? (int)$m[1] : null;
    };
    return [
        'util_warn' => $get('warning')  ?? 70,
        'util_crit' => $get('critical') ?? 90,
        'hdd_warn'  => $get('hot')      ?? 45,
        'hdd_crit'  => $get('max')      ?? 55,
        'ssd_warn'  => $get('hotssd')   ?? 60,
        'ssd_crit'  => $get('maxssd')   ?? 70,
    ];
}

// Fetch a lightweight snapshot from each remote host that has an API key configured.
// Results are cached in /tmp for 30 seconds so rapid monitor polls don't hammer remote hosts.
function vv_remote_hosts_stats(): array {
    // Read ALL conf files — remote host keys live in their own host*.conf, not the current host's.
    $vars = vv_conf_vars();
    foreach (glob(CONF_DIR . '/host*.conf') ?: [] as $f) {
        $raw = file_get_contents($f) ?: '';
        preg_match_all('/^\s*([A-Z0-9_]+)\s*=\s*["\']?([^"\'#\n]*?)["\']?\s*(?:#.*)?$/m', $raw, $m);
        foreach ($m[1] as $i => $key) {
            if (!isset($vars[$key])) $vars[$key] = trim($m[2][$i]);
        }
    }
    $myHost  = vv_detect_host();
    $hostIds = array_filter(array_keys($vars), fn($k) => preg_match('/^HOST\d+$/', $k) && ($vars[$k] ?? '') !== '');
    sort($hostIds);

    $results = [];
    foreach ($hostIds as $id) {
        if (strtolower($id) === strtolower($myHost)) continue;
        // Background cache written by remote_arr_cache_writer.sh every 2h — use it if present.
        $bgCache = VV_CACHE_DIR . '/monitor_remote_' . strtolower($id) . '.json';
        if (file_exists($bgCache)) {
            $cached = json_decode(file_get_contents($bgCache), true);
            if ($cached) {
                $cached['cache_age'] = time() - (int)filemtime($bgCache);
                $results[$id] = $cached;
                continue;
            }
        }

        // No background cache yet — fall back to live call (uses 30s inline cache).
        $key = $vars[strtoupper($id) . '_UNRAID_API_KEY'] ?? '';
        if (!$key) {
            $results[$id] = ['available' => false, 'no_api_key' => true,
                             'host_id' => $id, 'hostname' => $vars[$id]];
            continue;
        }

        $cacheFile = VV_CACHE_DIR . "/vv_remote_{$id}.json";
        if (file_exists($cacheFile) && (time() - filemtime($cacheFile)) < 30) {
            $cached = json_decode(file_get_contents($cacheFile), true);
            if ($cached) { $results[$id] = $cached; continue; }
        }

        $gql = '{
  info { os { hostname uptime release } cpu { brand threads cores } }
  metrics { cpu { percentTotal } memory { percentTotal total used available } }
  array {
    state
    disks    { fsSize fsUsed temp }
    caches   { fsSize fsUsed temp }
    parities { temp }
  }
  vms { domains { name } }
}';
        $data = vv_unraid_api_query(strtolower($id), $gql, 4, $key);

        if (!$data) {
            $entry = ['available' => false, 'host_id' => $id, 'hostname' => $vars[$id]];
            file_put_contents($cacheFile, json_encode($entry));
            $results[$id] = $entry;
            continue;
        }

        $os   = $data['info']['os']  ?? [];
        $cpu  = $data['info']['cpu'] ?? [];
        $mMem = $data['metrics']['memory'] ?? [];

        $memPct = round((float)($mMem['percentTotal'] ?? 0));
        if ($memPct === 0) {
            $totalBytes = (float)($mMem['total']     ?? 0);
            $availBytes = (float)($mMem['available'] ?? 0);
            $memPct = $totalBytes > 0 ? (int)round(($totalBytes - $availBytes) / $totalBytes * 100) : 0;
        }
        $memTotalGb = isset($mMem['total']) ? _vv_api_bytes_to_gb((float)$mMem['total']) : 0;

        $uptimeRaw = $os['uptime'] ?? '';
        if (is_numeric($uptimeRaw)) {
            $uptimeSec = (int)$uptimeRaw;
            $days  = intdiv($uptimeSec, 86400);
            $hours = intdiv($uptimeSec % 86400, 3600);
            $mins  = intdiv($uptimeSec % 3600, 60);
            $uptime = ($days ? "{$days}d " : '') . ($hours ? "{$hours}h " : '') . "{$mins}m";
        } else {
            $uptimeSec = 0;
            $uptime = $uptimeRaw ?: '—';
        }

        $nodeMetrics = vv_api_node_metrics($data);
        $entry = array_merge([
            'available'    => true,
            'host_id'      => $id,
            'hostname'     => $os['hostname']  ?? $vars[$id],
            'version'      => $os['release']   ?? '',
            'uptime'       => $uptime,
            'uptime_sec'   => $uptimeSec,
            'cpu_load'     => $nodeMetrics['cpu_pct']      ?? 0,
            'cpu_threads'  => (int)($cpu['threads']        ?? 0),
            'mem_total_gb' => $memTotalGb,
            'mem_used_pct' => $memPct,
            'array_state'  => $data['array']['state']      ?? 'UNKNOWN',
        ], $nodeMetrics);
        file_put_contents($cacheFile, json_encode($entry));
        $results[$id] = $entry;
    }
    return $results;
}

function vv_log_tail(string $path, int $lines): string {
    $fp = @fopen($path, 'r');
    if (!$fp) return '';
    fseek($fp, 0, SEEK_END);
    $size = ftell($fp);
    if ($size <= 0) { fclose($fp); return ''; }
    $chunk = min($size, 4096);
    fseek($fp, -$chunk, SEEK_END);
    $data = fread($fp, $chunk);
    fclose($fp);
    $all = explode("\n", $data ?: '');
    return implode("\n", array_slice($all, -$lines));
}

function vv_parse_bash_array(string $raw, string $varName): array {
    if (!preg_match('/^\s*' . preg_quote($varName, '/') . '\s*=\s*\(([^)]*)\)/ms', $raw, $m)) return [];
    $items = [];
    foreach (explode("\n", $m[1]) as $line) {
        $line = trim(preg_replace('/#.*$/', '', $line), " \t\"'");
        if ($line !== '') $items[] = $line;
    }
    return $items;
}

function vv_transcode_sessions(): array {
    $v         = vv_conf_vars();
    $stateDir  = rtrim($v['STATE_DIR'] ?? STATE_DIR, '/');
    $stateFile = "$stateDir/transcode_state.db";
    if (!file_exists($stateFile)) return ['available' => false];

    $raw = [];
    foreach (file($stateFile) ?: [] as $line) {
        [$k, $v] = array_pad(explode('=', trim($line), 2), 2, '');
        $raw[trim($k)] = trim($v);
    }

    $target       = $raw['current_target'] ?? '';
    $lastFlip     = (int)($raw['last_flip_time'] ?? 0);
    $flipCount    = (int)($raw['flip_count_hour'] ?? 0);
    $isRamdisk    = str_contains($target, 'ramdisk');

    // Count active sessions: subdirs (legacy transcode) + unique hex prefixes (Live TV / Direct Stream HLS)
    $ramdiskPath = '/mnt/ramdisk_transcodes/transcoding-temp';
    $ramSessions = count(glob("$ramdiskPath/*/", GLOB_ONLYDIR) ?: []);
    $maxAge      = (int)(vv_conf_vars()['TRANSCODE_MAX_AGE'] ?? 20);
    $activeFiles = 0;
    $cutoff      = time() - $maxAge * 60;
    $flatPrefixes = [];
    if (is_dir($ramdiskPath)) {
        foreach (new DirectoryIterator($ramdiskPath) as $f) {
            if (!$f->isFile()) continue;
            if (preg_match('/^([0-9a-f]{16,})/', $f->getFilename(), $m)) {
                $flatPrefixes[$m[1]] = true;
            }
            if ($f->getMTime() >= $cutoff) $activeFiles++;
        }
    }
    $ramSessions += count($flatPrefixes);

    // SSD path: first transcoding-temp mount that is not a RAM filesystem (tmpfs/ramfs)
    $ssdPath     = '';
    $ssdSessions = 0;
    foreach (glob('/mnt/*/transcoding-temp/', GLOB_ONLYDIR) ?: [] as $p) {
        $parts = explode('/', rtrim($p, '/'));
        array_pop($parts);
        $mount  = implode('/', $parts) ?: '/';
        $fsType = trim(shell_exec('findmnt -n -o FSTYPE ' . escapeshellarg($mount) . ' 2>/dev/null') ?: '');
        if ($fsType === 'tmpfs' || $fsType === 'ramfs') continue;
        $ssdPath = $p;
        break;
    }
    $ssd = ['available' => false];
    if ($ssdPath) {
        $ssdSessions = count(glob($ssdPath . '/*/', GLOB_ONLYDIR) ?: []);
        $parts = explode('/', rtrim($ssdPath, '/'));
        array_pop($parts);
        $ssdMount = implode('/', $parts) ?: '/';
        $ssd = vv_df($ssdMount);
    }

    // Ramdisk disk usage
    $rd = vv_df('/mnt/ramdisk_transcodes');

    // Last cleanup values from transcode management log
    $lastRdFreed  = null;
    $lastSsdFreed = null;
    $logFile = LOG_DIR . '/Orchestrators/transcode_management.log';
    if (file_exists($logFile)) {
        $lines = file($logFile, FILE_IGNORE_NEW_LINES) ?: [];
        foreach (array_reverse($lines) as $line) {
            if ($lastRdFreed === null && preg_match('/Ramdisk freed:\s*(\S+)/u', $line, $m))
                $lastRdFreed = $m[1];
            if ($lastSsdFreed === null && preg_match('/SSD freed:\s*(\S+)/u', $line, $m))
                $lastSsdFreed = $m[1];
            if ($lastRdFreed !== null && $lastSsdFreed !== null) break;
        }
    }

    return [
        'available'       => true,
        'current_target'  => $target,
        'is_ramdisk'      => $isRamdisk,
        'flip_count_hour' => $flipCount,
        'last_flip_time'  => $lastFlip,
        'last_flip_ago'   => $lastFlip > 0 ? time() - $lastFlip : null,
        'ram_sessions'    => $ramSessions,
        'ssd_sessions'    => $ssdSessions,
        'active_files'    => $activeFiles,
        'ramdisk'         => $rd,
        'ssd'             => $ssd,
        'last_rd_freed'   => $lastRdFreed,
        'last_ssd_freed'  => $lastSsdFreed,
    ];
}
