<?php
declare(strict_types=1);

/**
 * Operational health checks for Plenvo. Pure functions — callable from the
 * admin AJAX endpoint, the cron monitor, and any future integrations. The
 * caller is responsible for output (JSON, email body, log line, etc.).
 *
 * Each check returns: ['id' => ..., 'label' => ..., 'status' => 'ok'|'warn'|'error', 'detail' => ...]
 */
class HealthCheck
{
    /** Run all checks and return an array of result rows. Order is stable. */
    public static function runAll(): array
    {
        $db       = Database::getInstance();
        $settings = [];
        foreach ($db->fetchAll('SELECT key, value FROM settings') as $row) {
            $settings[$row['key']] = $row['value'];
        }

        $checks = [];
        $checks[] = self::checkCron($settings);
        foreach (self::checkProbes($settings) as $row) $checks[] = $row;
        $checks[] = self::checkSensitivePathExposure($settings);
        $checks[] = self::checkMigrations();
        $checks[] = self::checkAppVersion($settings);
        if (PluginManager::isEnabled('ldap')) {
            $checks[] = self::checkLdapTransport($settings);
            $checks[] = self::checkLdapCaExpiry($settings);
            $checks[] = self::checkLdapSyncFresh($settings);
        }
        foreach (self::checkHtaccess() as $row) $checks[] = $row;
        $checks[] = self::checkAppKeyPerms();
        $checks[] = self::checkDbPerms();
        $checks[] = self::checkHttpsOnly($settings);
        $checks[] = self::checkAdminMfa();
        $checks[] = self::checkPhpConfig();
        $checks[] = self::checkMailHeartbeat($settings);
        $checks[] = self::checkMailQueue();
        $checks[] = self::checkDiskSpace();
        return $checks;
    }

    /**
     * Aggregate a list of check results into a single overall status.
     * Returns 'error' if any check is error, else 'warn' if any warn,
     * else 'ok' if at least one ok, else 'unknown'.
     */
    public static function aggregate(array $checks): string
    {
        $hasOk = false;
        foreach ($checks as $c) {
            if (($c['status'] ?? '') === 'error') return 'error';
            if (($c['status'] ?? '') === 'warn')  $worst = 'warn';
            if (($c['status'] ?? '') === 'ok')    $hasOk = true;
        }
        if (isset($worst)) return 'warn';
        return $hasOk ? 'ok' : 'unknown';
    }

    /** Read the cached aggregate state stored by the cron. Cheap settings read. */
    public static function lastCronState(): ?array
    {
        try {
            $row = Database::getInstance()->fetch("SELECT value FROM settings WHERE key='health_check_last_state'");
            if (!$row) return null;
            $data = json_decode((string)$row['value'], true);
            return is_array($data) ? $data : null;
        } catch (\Throwable) { return null; }
    }

    /** Persist the most recent aggregate + per-check states for state-based dedup. */
    public static function storeCronState(array $checks): void
    {
        $perCheck = [];
        foreach ($checks as $c) $perCheck[$c['id']] = $c['status'];
        $payload = [
            'at'        => date(DATE_ATOM),
            'aggregate' => self::aggregate($checks),
            'checks'    => $perCheck,
        ];
        Helper::setSetting('health_check_last_state', json_encode($payload, JSON_UNESCAPED_UNICODE));
    }

    // ── individual checks ────────────────────────────────────────────────────

    private static function checkCron(array $settings): array
    {
        $lastRun = (int)($settings['cron_last_run'] ?? 0);
        if ($lastRun === 0) {
            return ['id' => 'cron', 'label' => Lang::get('hc_cron'), 'status' => 'error', 'detail' => Lang::get('hc_cron_never')];
        }
        $age = time() - $lastRun;
        $min = (int)ceil($age / 60);
        if ($age <= 600) {
            return ['id' => 'cron', 'label' => Lang::get('hc_cron'), 'status' => 'ok',    'detail' => Lang::get('hc_cron_ok',    ['min' => (string)$min])];
        }
        if ($age <= 3600) {
            return ['id' => 'cron', 'label' => Lang::get('hc_cron'), 'status' => 'warn',  'detail' => Lang::get('hc_cron_late',  ['min' => (string)$min])];
        }
        $hours = (int)ceil($age / 3600);
        return ['id' => 'cron', 'label' => Lang::get('hc_cron'), 'status' => 'error', 'detail' => Lang::get('hc_cron_stale', ['h' => (string)$hours])];
    }

    private static function checkProbes(array $settings): array
    {
        $appUrl = rtrim($settings['app_url'] ?? '', '/');
        $rows = [];
        if ($appUrl === '') {
            foreach (['probe_db' => 'hc_probe_db', 'probe_uploads' => 'hc_probe_uploads'] as $id => $lk) {
                $rows[] = ['id' => $id, 'label' => Lang::get($lk), 'status' => 'warn', 'detail' => Lang::get('hc_no_app_url')];
            }
            return $rows;
        }
        foreach ([
            ['id' => 'probe_db',      'label' => Lang::get('hc_probe_db'),      'url' => $appUrl . '/data/database.sqlite'],
            ['id' => 'probe_uploads', 'label' => Lang::get('hc_probe_uploads'), 'url' => $appUrl . '/data/uploads/'],
        ] as $probe) {
            $code = self::probeUrl($probe['url']);
            if ($code === 200) {
                $rows[] = ['id' => $probe['id'], 'label' => $probe['label'], 'status' => 'error', 'detail' => Lang::get('hc_probe_exposed')];
            } elseif (in_array($code, [403, 404, 410], true)) {
                $rows[] = ['id' => $probe['id'], 'label' => $probe['label'], 'status' => 'ok',    'detail' => Lang::get('hc_probe_blocked',    ['code' => (string)$code])];
            } elseif ($code === 0) {
                $rows[] = ['id' => $probe['id'], 'label' => $probe['label'], 'status' => 'warn',  'detail' => Lang::get('hc_probe_no_conn')];
            } else {
                $rows[] = ['id' => $probe['id'], 'label' => $probe['label'], 'status' => 'warn',  'detail' => Lang::get('hc_probe_unexpected', ['code' => (string)$code])];
            }
        }
        return $rows;
    }

    private static function checkMigrations(): array
    {
        $migErr     = Migrator::lastError();
        $migPending = Migrator::pendingCount();
        if ($migErr) {
            $first  = $migErr['failures'][0] ?? null;
            $detail = $first
                ? Lang::get('hc_migrations_failed', ['id' => $first['id'] ?? '?', 'error' => mb_substr((string)($first['error'] ?? ''), 0, 120)])
                : Lang::get('hc_migrations_failed_generic');
            return ['id' => 'migrations', 'label' => Lang::get('hc_migrations'), 'status' => 'error', 'detail' => $detail];
        }
        if ($migPending > 0) {
            return ['id' => 'migrations', 'label' => Lang::get('hc_migrations'), 'status' => 'warn', 'detail' => Lang::get('hc_migrations_pending', ['n' => (string)$migPending])];
        }
        return ['id' => 'migrations', 'label' => Lang::get('hc_migrations'), 'status' => 'ok', 'detail' => Lang::get('hc_migrations_ok')];
    }

    /**
     * Code APP_VERSION (`index.php`) vs DB `settings.app_version`. The Database
     * constructor INSERT-OR-REPLACEs the row on every boot, so a mismatch
     * surfaces an abnormal state: the boot write failed (read-only DB,
     * settings table missing) or a partial restore put an older row into the
     * DB while a stuck worker still serves the new code.
     */
    private static function checkAppVersion(array $settings): array
    {
        $code = defined('APP_VERSION') ? APP_VERSION : 'unknown';
        $db   = $settings['app_version'] ?? null;

        if ($db === null || $db === '') {
            return [
                'id' => 'app_version', 'label' => Lang::get('hc_app_version'),
                'status' => 'warn',
                'detail' => Lang::get('hc_app_version_mismatch', ['code' => $code, 'db' => '—']),
            ];
        }
        if ($db !== $code) {
            return [
                'id' => 'app_version', 'label' => Lang::get('hc_app_version'),
                'status' => 'error',
                'detail' => Lang::get('hc_app_version_mismatch', ['code' => $code, 'db' => $db]),
            ];
        }
        return [
            'id' => 'app_version', 'label' => Lang::get('hc_app_version'),
            'status' => 'ok',
            'detail' => Lang::get('hc_app_version_ok', ['code' => $code]),
        ];
    }

    /**
     * LDAP plugin: transport mode. cleartext is reported as error so it shows
     * up red on the admin topbar light. The admin can acknowledge it via
     * `ldap_cleartext_acknowledged_at` to silence the email-alert pipeline
     * (the check stays error so it remains visible in compliance audits).
     */
    private static function checkLdapTransport(array $settings): array
    {
        $enabled = ($settings['ldap_enabled'] ?? '0') === '1';
        if (!$enabled) {
            return ['id' => 'ldap_transport', 'label' => Lang::get('hc_ldap_transport'),
                    'status' => 'ok', 'detail' => Lang::get('hc_ldap_inactive')];
        }
        $mode = (string)($settings['ldap_transport'] ?? 'ldaps');
        if ($mode === 'ldaps' || $mode === 'starttls') {
            return ['id' => 'ldap_transport', 'label' => Lang::get('hc_ldap_transport'),
                    'status' => 'ok', 'detail' => Lang::get('hc_ldap_transport_ok', ['mode' => strtoupper($mode)])];
        }
        return ['id' => 'ldap_transport', 'label' => Lang::get('hc_ldap_transport'),
                'status' => 'error', 'detail' => Lang::get('hc_ldap_transport_cleartext')];
    }

    /**
     * LDAP plugin: CA certificate expiry. Reads the cached validTo timestamp
     * stored at upload time so we do not re-parse the PEM on every health tick.
     * Warns 30 days out, errors after expiry.
     */
    private static function checkLdapCaExpiry(array $settings): array
    {
        $enabled = ($settings['ldap_enabled'] ?? '0') === '1';
        $caPath  = (string)($settings['ldap_ca_path'] ?? '');
        if (!$enabled || $caPath === '') {
            return ['id' => 'ldap_ca_expiry', 'label' => Lang::get('hc_ldap_ca'),
                    'status' => 'ok', 'detail' => Lang::get('hc_ldap_ca_none')];
        }
        $validTo = (int)($settings['ldap_ca_valid_to'] ?? 0);
        if ($validTo === 0) {
            return ['id' => 'ldap_ca_expiry', 'label' => Lang::get('hc_ldap_ca'),
                    'status' => 'warn', 'detail' => Lang::get('hc_ldap_ca_unknown')];
        }
        $daysLeft = (int)floor(($validTo - time()) / 86400);
        if ($daysLeft < 0) {
            return ['id' => 'ldap_ca_expiry', 'label' => Lang::get('hc_ldap_ca'),
                    'status' => 'error', 'detail' => Lang::get('hc_ldap_ca_expired', ['days' => (string)abs($daysLeft)])];
        }
        if ($daysLeft <= 30) {
            return ['id' => 'ldap_ca_expiry', 'label' => Lang::get('hc_ldap_ca'),
                    'status' => 'warn', 'detail' => Lang::get('hc_ldap_ca_soon', ['days' => (string)$daysLeft])];
        }
        return ['id' => 'ldap_ca_expiry', 'label' => Lang::get('hc_ldap_ca'),
                'status' => 'ok', 'detail' => Lang::get('hc_ldap_ca_ok', ['days' => (string)$daysLeft])];
    }

    /**
     * LDAP plugin: directory sync freshness. The cron sweep stamps
     * `ldap_sync_last_run` after each successful pass. Warns if older than 4 h,
     * errors after 24 h. ok also when LDAP is enabled but sync has never run
     * yet (fresh install) — it would otherwise scream on day one.
     */
    private static function checkLdapSyncFresh(array $settings): array
    {
        $lastRun = (int)($settings['ldap_sync_last_run'] ?? 0);
        if ($lastRun === 0) {
            return ['id' => 'ldap_sync', 'label' => Lang::get('hc_ldap_sync'),
                    'status' => 'ok', 'detail' => Lang::get('hc_ldap_sync_never')];
        }
        $age = time() - $lastRun;
        $h   = (int)floor($age / 3600);
        if ($age <= 4 * 3600)  return ['id' => 'ldap_sync', 'label' => Lang::get('hc_ldap_sync'), 'status' => 'ok',    'detail' => Lang::get('hc_ldap_sync_ok',   ['h' => (string)$h])];
        if ($age <= 24 * 3600) return ['id' => 'ldap_sync', 'label' => Lang::get('hc_ldap_sync'), 'status' => 'warn',  'detail' => Lang::get('hc_ldap_sync_warn', ['h' => (string)$h])];
        return                        ['id' => 'ldap_sync', 'label' => Lang::get('hc_ldap_sync'), 'status' => 'error', 'detail' => Lang::get('hc_ldap_sync_stale', ['h' => (string)$h])];
    }

    private static function checkHtaccess(): array
    {
        $rows = [];
        foreach ([
            ['id' => 'htaccess_data',    'label' => Lang::get('hc_htaccess_data'),    'path' => DATA_PATH   . '/.htaccess'],
            ['id' => 'htaccess_uploads', 'label' => Lang::get('hc_htaccess_uploads'), 'path' => UPLOAD_PATH . '/.htaccess'],
        ] as $fc) {
            if (file_exists($fc['path'])) {
                $rows[] = ['id' => $fc['id'], 'label' => $fc['label'], 'status' => 'ok',    'detail' => Lang::get('hc_file_present')];
            } else {
                $rows[] = ['id' => $fc['id'], 'label' => $fc['label'], 'status' => 'error', 'detail' => Lang::get('hc_file_missing')];
            }
        }
        return $rows;
    }

    private static function probeUrl(string $url): int
    {
        $ctx = stream_context_create(['http' => [
            'method'        => 'GET',
            'timeout'       => 5,
            'ignore_errors' => true,
            'user_agent'    => 'Plenvo/' . (defined('APP_VERSION') ? APP_VERSION : '?') . ' (health-check)',
        ], 'ssl' => [
            'verify_peer'      => false,
            'verify_peer_name' => false,
        ]]);
        @file_get_contents($url, false, $ctx);
        // PHP 8.4 added http_get_last_response_headers(); 8.5 deprecates the
        // legacy `$http_response_header` magic variable. Accessing the magic
        // variable here would print a Deprecated notice to stdout when
        // display_errors is on, which then prevents every later header() /
        // session_start() call in the same request and turns API responses
        // into broken HTML.
        $lastHeaders = function_exists('http_get_last_response_headers')
            ? (http_get_last_response_headers() ?: [])
            : ($http_response_header ?? []);
        if (!empty($lastHeaders[0]) && preg_match('/\s(\d{3})\s/', $lastHeaders[0], $m)) {
            return (int)$m[1];
        }
        return 0;
    }

    // ── Additional checks (BSI hardening pass) ───────────────────────────────

    /** #1 — Probe further sensitive paths. .htaccess should block every one of them. */
    private static function checkSensitivePathExposure(array $settings): array
    {
        $appUrl = rtrim($settings['app_url'] ?? '', '/');
        if ($appUrl === '') {
            return ['id' => 'paths_exposed', 'label' => Lang::get('hc_paths'), 'status' => 'warn', 'detail' => Lang::get('hc_no_app_url')];
        }
        $paths = [
            '/src/', '/controllers/', '/vendor/', '/lang/', '/config/', '/migrations/',
            '/data/app.key', '/data/database.sqlite',
            // Backup-related paths: even if no live restore is in flight, an
            // accidentally-served listing of these directories would leak
            // either a freshly-staged backup ZIP or the rollback archive
            // (full prior database + encryption key).
            '/data/.restore-staging/', '/data/.restore-backup-/',
        ];
        $exposed = [];
        foreach ($paths as $p) {
            $code = self::probeUrl($appUrl . $p);
            if ($code === 200) $exposed[] = $p;
        }
        if (empty($exposed)) {
            return ['id' => 'paths_exposed', 'label' => Lang::get('hc_paths'), 'status' => 'ok', 'detail' => Lang::get('hc_paths_ok', ['n' => (string)count($paths)])];
        }
        return ['id' => 'paths_exposed', 'label' => Lang::get('hc_paths'), 'status' => 'error', 'detail' => Lang::get('hc_paths_exposed', ['list' => implode(', ', $exposed)])];
    }

    /** #2 — app.key must be 0600 (only owner can read/write). Anything else leaks the AES-256-GCM key.
     *  File absence is a valid state: the key is generated lazily on first encryption (e.g. first TOTP setup). */
    private static function checkAppKeyPerms(): array
    {
        $path = DATA_PATH . '/app.key';
        if (!file_exists($path)) {
            return ['id' => 'key_perms', 'label' => Lang::get('hc_key_perms'), 'status' => 'ok', 'detail' => Lang::get('hc_key_missing')];
        }
        $perms = fileperms($path) & 0777;
        if ($perms === 0600) {
            return ['id' => 'key_perms', 'label' => Lang::get('hc_key_perms'), 'status' => 'ok', 'detail' => Lang::get('hc_key_ok')];
        }
        return ['id' => 'key_perms', 'label' => Lang::get('hc_key_perms'), 'status' => 'error', 'detail' => Lang::get('hc_key_bad', ['perms' => sprintf('%04o', $perms)])];
    }

    /** #3 — database.sqlite must not be world-readable. */
    private static function checkDbPerms(): array
    {
        $path = DATA_PATH . '/database.sqlite';
        if (!file_exists($path)) {
            return ['id' => 'db_perms', 'label' => Lang::get('hc_db_perms'), 'status' => 'warn', 'detail' => Lang::get('hc_db_missing')];
        }
        $perms = fileperms($path) & 0777;
        if (($perms & 0007) === 0) {
            return ['id' => 'db_perms', 'label' => Lang::get('hc_db_perms'), 'status' => 'ok', 'detail' => Lang::get('hc_db_ok', ['perms' => sprintf('%04o', $perms)])];
        }
        return ['id' => 'db_perms', 'label' => Lang::get('hc_db_perms'), 'status' => 'error', 'detail' => Lang::get('hc_db_world_readable', ['perms' => sprintf('%04o', $perms)])];
    }

    /** #4 — app_url uses https, and a probe of the http variant either redirects or fails. */
    private static function checkHttpsOnly(array $settings): array
    {
        $appUrl = trim($settings['app_url'] ?? '');
        if ($appUrl === '') {
            return ['id' => 'https_only', 'label' => Lang::get('hc_https'), 'status' => 'warn', 'detail' => Lang::get('hc_no_app_url')];
        }
        if (!str_starts_with(strtolower($appUrl), 'https://')) {
            return ['id' => 'https_only', 'label' => Lang::get('hc_https'), 'status' => 'error', 'detail' => Lang::get('hc_https_not_https', ['url' => $appUrl])];
        }
        // Check the http:// variant: anything other than a 200 is acceptable (redirect or block).
        $httpUrl = preg_replace('#^https://#i', 'http://', $appUrl);
        $code    = self::probeUrl(rtrim($httpUrl, '/'));
        if ($code === 200) {
            return ['id' => 'https_only', 'label' => Lang::get('hc_https'), 'status' => 'error', 'detail' => Lang::get('hc_https_http_open')];
        }
        return ['id' => 'https_only', 'label' => Lang::get('hc_https'), 'status' => 'ok', 'detail' => Lang::get('hc_https_ok')];
    }

    /** #5 — count admin users without any second factor configured. */
    private static function checkAdminMfa(): array
    {
        try {
            $db = Database::getInstance();
            $rows = $db->fetchAll(
                "SELECT u.id FROM users u
                 WHERE u.role = 'admin' AND u.is_active = 1
                   AND COALESCE(u.totp_enabled, 0) = 0
                   AND COALESCE(u.email_mfa_enabled, 0) = 0
                   AND NOT EXISTS (SELECT 1 FROM webauthn_credentials wc WHERE wc.user_id = u.id)"
            );
        } catch (\Throwable) {
            return ['id' => 'admin_mfa', 'label' => Lang::get('hc_admin_mfa'), 'status' => 'warn', 'detail' => Lang::get('hc_admin_mfa_check_failed')];
        }
        $n = count($rows);
        if ($n === 0) {
            return ['id' => 'admin_mfa', 'label' => Lang::get('hc_admin_mfa'), 'status' => 'ok', 'detail' => Lang::get('hc_admin_mfa_ok')];
        }
        return ['id' => 'admin_mfa', 'label' => Lang::get('hc_admin_mfa'), 'status' => 'warn', 'detail' => Lang::get('hc_admin_mfa_missing', ['n' => (string)$n])];
    }

    /** #7 — PHP runtime config sanity. Combined check. */
    private static function checkPhpConfig(): array
    {
        // All three values this check inspects describe behaviour of the
        // *web* SAPI: `expose_php` controls the `X-Powered-By` HTTP header,
        // `display_errors` controls browser-visible error output, and
        // `session.cookie_httponly` controls how `session_start()` sets the
        // session cookie. Distros routinely ship `php.ini-cli` with values
        // that differ from `php.ini-apache2` / `php.ini-fpm` (for example
        // `expose_php = On` is the Debian CLI default). Reading these via
        // `ini_get()` from the cron CLI process therefore mis-reports the
        // operator's actual web configuration.
        //
        // Plenvo's `index.php` additionally calls `session_set_cookie_params`
        // with `httponly => true` on every request, so the cookie really is
        // HttpOnly even when the underlying ini value is unset — making a
        // cron-side warning a pure false positive that could not exist for a
        // real user request.
        //
        // Short-circuit under CLI so cron alerts no longer fire on values
        // that aren't applicable to the running web application; the web
        // run of /admin/health-check stays the authoritative source.
        if (PHP_SAPI === 'cli') {
            return [
                'id' => 'php_config',
                'label' => Lang::get('hc_php_config'),
                'status' => 'ok',
                'detail' => Lang::get('hc_php_config_cli_skip', ['v' => PHP_VERSION]),
            ];
        }

        $issues = [];
        // display_errors should be off in production
        if (filter_var(ini_get('display_errors'), FILTER_VALIDATE_BOOLEAN)) {
            $issues[] = 'display_errors=On';
        }
        // expose_php should be off (prevents X-Powered-By: PHP/x.y.z header)
        if (filter_var(ini_get('expose_php'), FILTER_VALIDATE_BOOLEAN)) {
            $issues[] = 'expose_php=On';
        }
        // session.cookie_httponly must be on. `index.php` forces the runtime
        // value to true via session_set_cookie_params, so the actual cookie
        // is always HttpOnly; we still flag the ini for documentation value.
        if (!filter_var(ini_get('session.cookie_httponly'), FILTER_VALIDATE_BOOLEAN)) {
            $issues[] = 'session.cookie_httponly=Off';
        }
        if (empty($issues)) {
            return ['id' => 'php_config', 'label' => Lang::get('hc_php_config'), 'status' => 'ok', 'detail' => Lang::get('hc_php_config_ok', ['v' => PHP_VERSION])];
        }
        return ['id' => 'php_config', 'label' => Lang::get('hc_php_config'), 'status' => 'warn', 'detail' => Lang::get('hc_php_config_issues', ['list' => implode(', ', $issues)])];
    }

    /** #12 — last successful outbound mail timestamp. Stale = SMTP probably broken. */
    private static function checkMailHeartbeat(array $settings): array
    {
        $notifEnabled = ($settings['notifications_enabled'] ?? '1') === '1';
        if (!$notifEnabled) {
            return ['id' => 'mail_heartbeat', 'label' => Lang::get('hc_mail_heartbeat'), 'status' => 'ok', 'detail' => Lang::get('hc_mail_heartbeat_disabled')];
        }
        // Skip if mail isn't even configured — not relevant
        $provider = $settings['mail_provider'] ?? 'smtp';
        $configured = ($provider === 'local')
            || ($provider === 'microsoft' && !empty($settings['ms_tenant_id']))
            || (!empty($settings['smtp_host']));
        if (!$configured) {
            return ['id' => 'mail_heartbeat', 'label' => Lang::get('hc_mail_heartbeat'), 'status' => 'warn', 'detail' => Lang::get('hc_mail_heartbeat_unconfigured')];
        }
        $lastTs = (int)($settings['mail_last_success_ts'] ?? 0);
        if ($lastTs === 0) {
            return ['id' => 'mail_heartbeat', 'label' => Lang::get('hc_mail_heartbeat'), 'status' => 'warn', 'detail' => Lang::get('hc_mail_heartbeat_never')];
        }
        $ageHours = (time() - $lastTs) / 3600;
        if ($ageHours <= 168) { // <= 7 days
            return ['id' => 'mail_heartbeat', 'label' => Lang::get('hc_mail_heartbeat'), 'status' => 'ok', 'detail' => Lang::get('hc_mail_heartbeat_ok', ['h' => (string)(int)$ageHours])];
        }
        $days = (int)($ageHours / 24);
        return ['id' => 'mail_heartbeat', 'label' => Lang::get('hc_mail_heartbeat'), 'status' => 'warn', 'detail' => Lang::get('hc_mail_heartbeat_stale', ['d' => (string)$days])];
    }

    /**
     * Mail queue backlog. Pending rows older than 30 minutes mean the
     * cron worker is not draining the queue (cron not running, SMTP
     * permanently failing, or the worker is consistently throwing) —
     * notifications and password-reset / MFA mails are stuck. Returns
     * `error` in that case so the topbar light flips red and the cron
     * monitor pages an admin.
     */
    private static function checkMailQueue(): array
    {
        $stats = Mailer::queueStats();
        $thresholdSeconds = 30 * 60;
        $pending = $stats['pending'];
        $stuck   = $stats['stuck'];
        $age     = $stats['oldest_pending_age'];

        if ($pending === 0 && $stuck === 0) {
            return ['id' => 'mail_queue', 'label' => Lang::get('hc_mail_queue'), 'status' => 'ok', 'detail' => Lang::get('hc_mail_queue_empty')];
        }
        if ($pending > 0 && $age >= $thresholdSeconds) {
            $minutes = (int)floor($age / 60);
            return ['id' => 'mail_queue', 'label' => Lang::get('hc_mail_queue'), 'status' => 'error',
                    'detail' => Lang::get('hc_mail_queue_backlog', ['n' => (string)$pending, 'm' => (string)$minutes])];
        }
        if ($stuck > 0) {
            return ['id' => 'mail_queue', 'label' => Lang::get('hc_mail_queue'), 'status' => 'warn',
                    'detail' => Lang::get('hc_mail_queue_stuck', ['n' => (string)$stuck])];
        }
        return ['id' => 'mail_queue', 'label' => Lang::get('hc_mail_queue'), 'status' => 'ok',
                'detail' => Lang::get('hc_mail_queue_pending', ['n' => (string)$pending])];
    }

    /** #14 — free disk space. Error below 15 GB. */
    private static function checkDiskSpace(): array
    {
        $bytes = @disk_free_space(DATA_PATH);
        if ($bytes === false) {
            return ['id' => 'disk_space', 'label' => Lang::get('hc_disk'), 'status' => 'warn', 'detail' => Lang::get('hc_disk_unknown')];
        }
        $gb        = $bytes / 1073741824; // 1024^3
        $threshold = 15.0; // GB
        $gbStr     = number_format($gb, 1, '.', '');
        if ($gb < $threshold) {
            return ['id' => 'disk_space', 'label' => Lang::get('hc_disk'), 'status' => 'error', 'detail' => Lang::get('hc_disk_low', ['gb' => $gbStr])];
        }
        return ['id' => 'disk_space', 'label' => Lang::get('hc_disk'), 'status' => 'ok', 'detail' => Lang::get('hc_disk_ok', ['gb' => $gbStr])];
    }
}
