<?php
declare(strict_types=1);

class AdminController
{
    public static function users(): void
    {
        Auth::requireAdmin();
        $db      = Database::getInstance();
        $search  = Helper::get('search');
        $errors  = [];
        $success = Helper::flash('success');

        if (Helper::isPost()) {
            Helper::verifyCsrf();
            $action = Helper::post('action');

            if ($action === 'create') {
                $username  = trim(Helper::post('username'));
                $email     = strtolower(trim(Helper::post('email')));
                $password  = Helper::post('password');
                $role      = Helper::post('role', 'user');
                if (strlen($username) < 3) $errors[] = Lang::get('username_min');
                if (!filter_var($email, FILTER_VALIDATE_EMAIL)) $errors[] = Lang::get('invalid_email');
                if ($pwErr = PasswordPolicy::validate($password)) $errors[] = t($pwErr);
                if (empty($errors) && $db->fetch('SELECT id FROM users WHERE email = ?', [$email])) $errors[] = Lang::get('email_taken');

                if (empty($errors)) {
                    $newId = $db->insert(
                        'INSERT INTO users (username, email, password, role) VALUES (?,?,?,?)',
                        [$username, $email, password_hash($password, PASSWORD_BCRYPT, ['cost' => Auth::BCRYPT_COST]), $role]
                    );
                    SecurityLog::log('admin_user_create', SecurityLog::SEV_INFO, ['target_user_id' => (int)$newId, 'role' => $role, 'email_hint' => Helper::maskEmail($email)]);
                    Helper::redirect('/admin/users', ['success' => Lang::get('user_created')]);
                }
            }

            if ($action === 'update') {
                $userId    = (int)Helper::post('user_id');
                $username  = trim(Helper::post('username'));
                $email     = strtolower(trim(Helper::post('email')));
                $role      = Helper::post('role', 'user');
                $active    = (int)(Helper::post('is_active') === '1');

                if ($userId === Auth::id()) { $role = 'admin'; $active = 1; }

                if (!filter_var($email, FILTER_VALIDATE_EMAIL)) {
                    $errors[] = Lang::get('invalid_email');
                } elseif ($db->fetch('SELECT id FROM users WHERE email = ? AND id != ?', [$email, $userId])) {
                    $errors[] = Lang::get('email_taken');
                } else {
                    $db->execute(
                        'UPDATE users SET username=?, email=?, role=?, is_active=? WHERE id=?',
                        [$username, $email, $role, $active, $userId]
                    );
                    Helper::redirect('/admin/users', ['success' => Lang::get('user_updated')]);
                }
            }

            if ($action === 'reset_password') {
                $userId      = (int)Helper::post('user_id');
                $newPassword = Helper::post('new_password');
                if ($pwErr = PasswordPolicy::validate($newPassword)) {
                    $errors[] = t($pwErr);
                } else {
                    $db->execute(
                        'UPDATE users SET password=?, failed_login_count=0, locked_until=NULL WHERE id=?',
                        [password_hash($newPassword, PASSWORD_BCRYPT, ['cost' => Auth::BCRYPT_COST]), $userId]
                    );
                    SecurityLog::log('admin_password_reset', SecurityLog::SEV_WARN, ['target_user_id' => $userId]);
                    Helper::redirect('/admin/users', ['success' => t('password_reset_success')]);
                }
            }

            if ($action === 'delete') {
                $userId = (int)Helper::post('user_id');
                if ($userId !== Auth::id()) {
                    SettingsController::deleteUser($db, $userId);
                    SecurityLog::log('admin_user_delete', SecurityLog::SEV_CRITICAL, ['target_user_id' => $userId]);
                    Helper::redirect('/admin/users', ['success' => Lang::get('user_deleted')]);
                }
            }
        }

        $params = [];
        $where  = '1=1';
        if ($search) {
            $where    = '(u.username LIKE ? OR u.email LIKE ?)';
            $params[] = '%' . $search . '%';
            $params[] = '%' . $search . '%';
        }

        // Column-header sort. Whitelist of sortable columns guards against
        // ORDER-BY injection — the GET value is mapped to a fixed SQL fragment.
        $sort = (string)Helper::get('sort', 'username');
        $dir  = strtolower((string)Helper::get('dir', 'asc')) === 'desc' ? 'DESC' : 'ASC';
        $sortMap = [
            'username'   => 'u.username',
            'email'      => 'u.email',
            'role'       => 'u.role',
            'is_active'  => 'u.is_active',
            'group_name' => 'group_name',
            'last_login' => 'u.last_login',
            'created_at' => 'u.created_at',
        ];
        $orderCol = $sortMap[$sort] ?? 'u.username';
        // For nullable columns sort NULLs last regardless of direction so
        // "users without a last login" do not crowd the top of an asc sort.
        $nullsLast = in_array($sort, ['last_login', 'group_name'], true)
            ? "(CASE WHEN $orderCol IS NULL OR $orderCol = '' THEN 1 ELSE 0 END), "
            : '';

        $users  = $db->fetchAll("SELECT u.*,
            (SELECT GROUP_CONCAT(g2.name, ', ') FROM group_members gm2 JOIN groups g2 ON g2.id = gm2.group_id WHERE gm2.user_id = u.id) AS group_name,
            (SELECT gm3.role FROM group_members gm3 WHERE gm3.user_id = u.id LIMIT 1) AS group_role,
            (SELECT gm4.group_id FROM group_members gm4 WHERE gm4.user_id = u.id LIMIT 1) AS group_id
            FROM users u WHERE $where ORDER BY {$nullsLast}$orderCol $dir, u.username ASC", $params);
        $groups = $db->fetchAll('SELECT * FROM groups ORDER BY name');

        $sortDir = strtolower($dir);
        view('admin/users', compact('users', 'groups', 'errors', 'success', 'search', 'sort', 'sortDir'));
    }

    public static function settings(): void
    {
        Auth::requireAdmin();
        $db      = Database::getInstance();
        $errors  = [];
        $success = Helper::flash('success');
        $warning = Helper::flash('warning');
        $testResult = null;

        if (Helper::isPost()) {
            Helper::verifyCsrf();
            $action = Helper::post('action');

            if ($action === 'save_app') {
                Helper::setSetting('app_name', trim(Helper::post('app_name', 'Plenvo')));
                Helper::setSetting('app_url',  rtrim(trim(Helper::post('app_url', '')), '/'));
                $defaultLang = Helper::post('app_default_language', Lang::FALLBACK);
                if (!Lang::isSupported($defaultLang)) $defaultLang = Lang::FALLBACK;
                Helper::setSetting('app_default_language', $defaultLang);
                $defaultTz = trim((string)Helper::post('app_default_timezone', 'UTC'));
                if (!Helper::isValidTimezone($defaultTz)) $defaultTz = 'UTC';
                Helper::setSetting('app_default_timezone', $defaultTz);
                Helper::setSetting('allow_registration', Helper::post('allow_registration', '0'));
                Helper::setSetting('notify_admins_new_registration', Helper::post('notify_admins_new_registration', '0'));
                $maxHours = max(0, min(8760, (int)Helper::post('unverified_user_max_hours', '48')));
                Helper::setSetting('unverified_user_max_hours', (string)$maxHours);
                Helper::setSetting('notifications_enabled', Helper::post('notifications_enabled', '0'));
                Helper::setSetting('mfa_enforce', Helper::post('mfa_enforce', '0'));
                Helper::setSetting('password_reset_enabled', Helper::post('password_reset_enabled', '0'));
                Helper::setSetting('registration_domains', trim(Helper::post('registration_domains', '')));
                $privacyUrl = trim(Helper::post('privacy_url', ''));
                $imprintUrl = trim(Helper::post('imprint_url', ''));
                // Only allow http/https to prevent javascript: XSS via href
                $safeUrl = fn(string $u) => preg_match('#^https?://#i', $u) ? $u : '';
                Helper::setSetting('privacy_url', $safeUrl($privacyUrl));
                Helper::setSetting('imprint_url', $safeUrl($imprintUrl));
                Helper::redirect('/admin/settings', ['success' => Lang::get('success')]);
            }

            if ($action === 'save_security') {
                $clamp = fn(int $v, int $min, int $max) => max($min, min($max, $v));
                // Account lockout (skipped automatically for users with MFA)
                Helper::setSetting('account_lockout_enabled', Helper::post('account_lockout_enabled', '0'));
                Helper::setSetting('account_lockout_threshold',
                    (string)$clamp((int)Helper::post('account_lockout_threshold', '5'), 3, 100));
                Helper::setSetting('account_lockout_duration_minutes',
                    (string)$clamp((int)Helper::post('account_lockout_duration_minutes', '15'), 1, 1440));
                // Session timeouts (0 = disabled)
                Helper::setSetting('session_idle_timeout_minutes',
                    (string)$clamp((int)Helper::post('session_idle_timeout_minutes', '30'), 0, 1440));
                Helper::setSetting('session_absolute_lifetime_hours',
                    (string)$clamp((int)Helper::post('session_absolute_lifetime_hours', '8'), 0, 168));
                // Optional Pwned-Passwords (HIBP) check
                Helper::setSetting('password_check_pwned', Helper::post('password_check_pwned', '0'));
                // Security event log retention (0 = keep forever)
                $retention = max(0, min(3650, (int)Helper::post('security_log_retention_days', '365')));
                Helper::setSetting('security_log_retention_days', (string)$retention);
                // Trust X-Forwarded-For only behind a known reverse proxy
                Helper::setSetting('trust_proxy_headers', Helper::post('trust_proxy_headers', '0'));
                Helper::redirect('/admin/settings', ['success' => Lang::get('success')]);
            }

            if ($action === 'save_ai') {
                Helper::setSetting('ai_enabled',      Helper::post('ai_enabled', '0'));
                Helper::setSetting('ai_provider',     Helper::post('ai_provider', 'ollama'));
                // Ollama
                Helper::setSetting('ai_ollama_url',        rtrim(trim(Helper::post('ai_ollama_url', 'http://localhost:11434')), '/'));
                Helper::setSetting('ai_ollama_model',      trim(Helper::post('ai_ollama_model', 'llama3')));
                Helper::setSetting('ai_ollama_auth_type',  Helper::post('ai_ollama_auth_type', 'none'));
                Helper::setSetting('ai_ollama_basic_user', trim(Helper::post('ai_ollama_basic_user', '')));
                if (($k = trim(Helper::post('ai_ollama_bearer_token', ''))) !== '') Helper::setSetting('ai_ollama_bearer_token', $k);
                if (($k = trim(Helper::post('ai_ollama_basic_pass', '')))   !== '') Helper::setSetting('ai_ollama_basic_pass', $k);
                // Claude
                Helper::setSetting('ai_claude_model', trim(Helper::post('ai_claude_model', 'claude-opus-4-7')));
                if (($k = trim(Helper::post('ai_claude_key', ''))) !== '') Helper::setSetting('ai_claude_key', $k);
                // OpenAI
                Helper::setSetting('ai_openai_model', trim(Helper::post('ai_openai_model', 'gpt-4o')));
                if (($k = trim(Helper::post('ai_openai_key', ''))) !== '') Helper::setSetting('ai_openai_key', $k);
                // Copilot (Azure OpenAI)
                Helper::setSetting('ai_copilot_endpoint',   rtrim(trim(Helper::post('ai_copilot_endpoint', '')), '/'));
                Helper::setSetting('ai_copilot_deployment', trim(Helper::post('ai_copilot_deployment', '')));
                Helper::setSetting('ai_copilot_api_version', trim(Helper::post('ai_copilot_api_version', '2024-02-01')));
                if (($k = trim(Helper::post('ai_copilot_key', ''))) !== '') Helper::setSetting('ai_copilot_key', $k);
                Helper::redirect('/admin/settings', ['success' => Lang::get('success')]);
            }

            if ($action === 'save_smtp') {
                $provider = Helper::post('mail_provider', 'smtp');
                Helper::setSetting('mail_provider', $provider);
                Helper::setSetting('smtp_from_name', trim(Helper::post('smtp_from_name')));

                if ($provider === 'microsoft') {
                    Helper::setSetting('ms_tenant_id',  trim(Helper::post('ms_tenant_id', '')));
                    Helper::setSetting('ms_client_id',  trim(Helper::post('ms_client_id', '')));
                    Helper::setSetting('ms_from_email', trim(Helper::post('ms_from_email', '')));
                    $msSecret = trim(Helper::post('ms_client_secret', ''));
                    if ($msSecret !== '') Helper::setSetting('ms_client_secret', $msSecret);
                } elseif ($provider === 'local') {
                    Helper::setSetting('smtp_from_email', trim(Helper::post('smtp_from_email')));
                } else {
                    Helper::setSetting('smtp_host',       trim(Helper::post('smtp_host')));
                    Helper::setSetting('smtp_port',       (string)(int)Helper::post('smtp_port', '587'));
                    Helper::setSetting('smtp_encryption', Helper::post('smtp_encryption', 'tls'));
                    Helper::setSetting('smtp_username',   trim(Helper::post('smtp_username')));
                    Helper::setSetting('smtp_from_email', trim(Helper::post('smtp_from_email')));
                    $newPass = Helper::post('smtp_password');
                    if (!empty($newPass)) Helper::setSetting('smtp_password', $newPass);
                }

                Helper::redirect('/admin/settings', ['success' => Lang::get('success')]);
            }

            if ($action === 'perform_update') {
                $info = Updater::getRemoteInfo();
                if (!$info || !version_compare($info['version'], APP_VERSION, '>')) {
                    $errors[] = t('update_already_latest');
                } else {
                    $result = Updater::perform($info);
                    if ($result['ok']) {
                        Helper::redirect('/admin/settings', ['success' => t('update_success')]);
                    }
                    $errors[] = $result['error'];
                }
            }

            if ($action === 'test_smtp') {
                $testResult = Mailer::test([
                    'smtp_host'       => trim(Helper::post('smtp_host')),
                    'smtp_port'       => (int)Helper::post('smtp_port', '587'),
                    'smtp_encryption' => Helper::post('smtp_encryption', 'tls'),
                    'smtp_username'   => trim(Helper::post('smtp_username')),
                    'smtp_password'   => Helper::post('smtp_password') ?: Helper::getSetting('smtp_password'),
                ]);
            }

            if ($action === 'test_msgraph') {
                $testResult = Mailer::testMsGraph([
                    'ms_tenant_id'     => trim(Helper::post('ms_tenant_id', '')),
                    'ms_client_id'     => trim(Helper::post('ms_client_id', '')),
                    'ms_client_secret' => Helper::post('ms_client_secret', '') ?: Helper::getSetting('ms_client_secret', ''),
                ]);
            }

            if ($action === 'save_system') {
                Helper::setSetting('updates_enabled', Helper::post('updates_enabled', '0'));
                Helper::setSetting('auto_update_enabled', Helper::post('auto_update_enabled', '0'));
                $time = trim((string)Helper::post('auto_update_time', '03:00'));
                if (!preg_match('/^([01]\d|2[0-3]):[0-5]\d$/', $time)) $time = '03:00';
                Helper::setSetting('auto_update_time', $time);
                Helper::redirect('/admin/settings', ['success' => Lang::get('success')]);
            }

            if ($action === 'save_inactive_cleanup') {
                Helper::setSetting('inactive_user_cleanup_enabled', Helper::post('inactive_user_cleanup_enabled', '0'));
                $months = max(1, min(120, (int)Helper::post('inactive_user_threshold_months', '12')));
                Helper::setSetting('inactive_user_threshold_months', (string)$months);
                $warnDays = max(1, min(90, (int)Helper::post('inactive_user_warning_days', '14')));
                Helper::setSetting('inactive_user_warning_days', (string)$warnDays);
                SecurityLog::log('inactive_cleanup_settings_changed', SecurityLog::SEV_WARN, [
                    'enabled'   => (string)Helper::post('inactive_user_cleanup_enabled', '0'),
                    'months'    => (string)$months,
                    'warn_days' => (string)$warnDays,
                ]);
                Helper::redirect('/admin/settings', ['success' => Lang::get('success')]);
            }

            if ($action === 'save_backup_passphrase') {
                // The "Clear" button sends `clear_passphrase=1`. An empty
                // passphrase field with no clear flag is a no-op (silent
                // pass-through), so the form survives the bulk "Save all
                // settings" loop where most password fields stay empty.
                if (Helper::post('clear_passphrase', '') === '1') {
                    Helper::setSetting('backup_default_passphrase_enc', '');
                    SecurityLog::log('backup_passphrase_cleared', SecurityLog::SEV_WARN);
                    Helper::redirect('/admin/settings#backupCard', ['success' => Lang::get('backup_passphrase_cleared_ok')]);
                }
                $newPass = (string)Helper::post('backup_default_passphrase', '');
                if ($newPass === '') {
                    Helper::redirect('/admin/settings#backupCard', ['success' => Lang::get('success')]);
                }
                if (strlen($newPass) < 12) {
                    Helper::redirect('/admin/settings#backupCard', ['error' => Lang::get('backup_passphrase_too_short')]);
                }
                Helper::setSetting('backup_default_passphrase_enc', Crypto::encrypt($newPass));
                SecurityLog::log('backup_passphrase_set', SecurityLog::SEV_WARN);
                Helper::redirect('/admin/settings#backupCard', ['success' => Lang::get('backup_passphrase_saved_ok')]);
            }

            if ($action === 'save_auto_backup') {
                $enabled = Helper::post('auto_backup_enabled', '0') === '1' ? '1' : '0';
                $freq    = Helper::post('auto_backup_frequency', 'daily');
                if (!in_array($freq, ['daily', 'weekly', 'monthly'], true)) $freq = 'daily';
                $time    = trim((string)Helper::post('auto_backup_time', '03:00'));
                if (!preg_match('/^([01]\d|2[0-3]):[0-5]\d$/', $time)) $time = '03:00';
                $weekday = max(0, min(6,  (int)Helper::post('auto_backup_weekday', '0')));
                $dom     = max(1, min(28, (int)Helper::post('auto_backup_day_of_month', '1')));
                Helper::setSetting('auto_backup_enabled',       $enabled);
                Helper::setSetting('auto_backup_frequency',     $freq);
                Helper::setSetting('auto_backup_time',          $time);
                Helper::setSetting('auto_backup_weekday',       (string)$weekday);
                Helper::setSetting('auto_backup_day_of_month',  (string)$dom);
                SecurityLog::log('auto_backup_settings_changed', SecurityLog::SEV_WARN, [
                    'enabled' => $enabled, 'frequency' => $freq, 'time' => $time,
                    'weekday' => $weekday, 'day_of_month' => $dom,
                ]);
                Helper::redirect('/admin/settings#backupCard', ['success' => Lang::get('success')]);
            }

            if ($action === 'save_backup_retention') {
                $days = max(0, min(3650, (int)Helper::post('backup_retention_days', '0')));
                Helper::setSetting('backup_retention_days', (string)$days);
                SecurityLog::log('backup_retention_changed', SecurityLog::SEV_WARN, ['days' => $days]);
                Helper::redirect('/admin/settings#backupCard', ['success' => Lang::get('success')]);
            }

            if ($action === 'save_healthcheck') {
                Helper::setSetting('health_check_cron_enabled', Helper::post('health_check_cron_enabled', '0'));
                $hcInterval = max(5, min(1440, (int)Helper::post('health_check_interval_minutes', '60')));
                Helper::setSetting('health_check_interval_minutes', (string)$hcInterval);
                $hcEmail = trim((string)Helper::post('health_check_alert_email', ''));
                Helper::setSetting('health_check_alert_email', $hcEmail);
                Helper::redirect('/admin/settings', ['success' => Lang::get('success')]);
            }

            if ($action === 'add_domain_language') {
                $domain = strtolower(trim(Helper::post('domain', '')));
                $lang   = Helper::post('language', '');
                $domain = preg_replace('/^https?:\/\//', '', $domain);
                $domain = trim($domain, '/');
                if ($domain === '' || !preg_match('/^[a-z0-9.\-]+\.[a-z]{2,}$/i', $domain)) {
                    $errors[] = Lang::get('domain_lang_invalid_domain');
                } elseif (!Lang::isSupported($lang)) {
                    $errors[] = Lang::get('domain_lang_invalid_language');
                } else {
                    try {
                        $db->execute(
                            'INSERT INTO domain_languages (domain, language) VALUES (?, ?)
                             ON CONFLICT(domain) DO UPDATE SET language = excluded.language',
                            [$domain, $lang]
                        );
                        Helper::redirect('/admin/settings', ['success' => Lang::get('domain_lang_saved')]);
                    } catch (\Throwable $e) {
                        $errors[] = Lang::get('domain_lang_save_failed');
                    }
                }
            }

            if ($action === 'delete_domain_language') {
                $id = (int)Helper::post('id');
                if ($id > 0) {
                    $db->execute('DELETE FROM domain_languages WHERE id = ?', [$id]);
                    Helper::redirect('/admin/settings', ['success' => Lang::get('domain_lang_deleted')]);
                }
            }
        }

        $settings = [];
        $rows = $db->fetchAll('SELECT key, value FROM settings');
        foreach ($rows as $row) {
            $settings[$row['key']] = $row['value'];
        }

        // Always fetch fresh on GET so the settings page reflects the current state
        $updatesEnabled  = ($settings['updates_enabled'] ?? '1') === '1';
        $updateInfo      = $updatesEnabled ? Updater::getRemoteInfo(!Helper::isPost()) : null;
        if ($updatesEnabled) Updater::notifyAdminsIfNewVersion($updateInfo);
        $mailerConfigured = Mailer::isConfigured();

        $domainLanguages = [];
        try {
            $domainLanguages = $db->fetchAll('SELECT * FROM domain_languages ORDER BY domain');
        } catch (\Throwable) { /* migration may not have run yet */ }

        $backups = Backup::listBackups();
        $mailQueueStats = Mailer::queueStats();

        view('admin/settings', compact('settings', 'errors', 'success', 'warning', 'testResult', 'updateInfo', 'updatesEnabled', 'mailerConfigured', 'domainLanguages', 'backups', 'mailQueueStats'));
    }

    public static function plugins(): void
    {
        Auth::requireAdmin();
        $errors  = [];
        $success = Helper::flash('success');

        if (Helper::isPost()) {
            Helper::verifyCsrf();
            $action = Helper::post('action');

            if ($action === 'enable') {
                $pid = Helper::post('plugin_id');
                PluginManager::enable($pid);
                SecurityLog::log('plugin_enable', SecurityLog::SEV_WARN, ['plugin_id' => $pid]);
                Helper::redirect('/admin/plugins', ['success' => Lang::get('plugin_activated_msg')]);
            }

            if ($action === 'disable') {
                $pluginId = Helper::post('plugin_id');
                if (!PluginManager::filter('plugin_can_disable', true, ['id' => $pluginId])) {
                    $errors[] = t('plugin_lock_active_error');
                } else {
                    PluginManager::disable($pluginId);
                    SecurityLog::log('plugin_disable', SecurityLog::SEV_WARN, ['plugin_id' => $pluginId]);
                    Helper::redirect('/admin/plugins', ['success' => Lang::get('plugin_deactivated_msg')]);
                }
            }

            if ($action === 'delete') {
                $dirName = Helper::post('plugin_dir');
                if (preg_match('/^[a-zA-Z0-9_-]+$/', $dirName)) {
                    $path = BASE_PATH . '/plugins/' . $dirName;
                    if (is_dir($path)) {
                        $pluginId = '';
                        $manifest = $path . '/plugin.json';
                        if (file_exists($manifest)) {
                            $pluginId = (string)(json_decode((string)file_get_contents($manifest), true)['id'] ?? '');
                        }
                        if (!PluginManager::filter('plugin_can_delete', true, ['id' => $pluginId, 'dir' => $dirName])) {
                            $errors[] = t('plugin_lock_active_error');
                        } else {
                            self::deleteDir($path);
                            SecurityLog::log('plugin_delete', SecurityLog::SEV_CRITICAL, ['plugin_id' => $pluginId, 'dir' => $dirName]);
                            Helper::redirect('/admin/plugins', ['success' => t('plugin_deleted')]);
                        }
                    }
                }
                if (empty($errors)) $errors[] = t('plugin_delete_error');
            }

            if ($action === 'upload') {
                $result = self::handlePluginUpload();
                if ($result === true) {
                    SecurityLog::log('plugin_upload', SecurityLog::SEV_WARN, ['filename' => basename($_FILES['plugin_zip']['name'] ?? '?')]);
                    Helper::redirect('/admin/plugins', ['success' => t('plugin_uploaded')]);
                }
                $errors[] = $result;
            }
        }

        $plugins = PluginManager::getAll();
        view('admin/plugins', compact('plugins', 'errors', 'success'));
    }

    private static function handlePluginUpload(): bool|string
    {
        $file = $_FILES['plugin_zip'] ?? null;
        if (!$file || $file['error'] !== UPLOAD_ERR_OK) return t('upload_failed');
        if ($file['size'] > 5 * 1024 * 1024) return t('file_too_large');
        if (strtolower(pathinfo($file['name'], PATHINFO_EXTENSION)) !== 'zip') return t('zip_only');
        if (!class_exists('ZipArchive')) return t('zip_not_supported');

        $zip = new ZipArchive();
        if ($zip->open($file['tmp_name']) !== true) return t('zip_not_openable');

        // Security: scan for path traversal + locate plugin.json
        $pluginSubdir = null;
        $foundManifest = false;
        for ($i = 0; $i < $zip->numFiles; $i++) {
            $name = $zip->getNameIndex($i);
            if (str_contains($name, '..') || str_starts_with($name, '/')) {
                $zip->close();
                return t('zip_path_traversal');
            }
            if (preg_match('#^([^/]+)/plugin\.json$#', $name, $m)) {
                $pluginSubdir  = $m[1];
                $foundManifest = true;
            } elseif ($name === 'plugin.json') {
                $foundManifest = true;
            }
        }
        if (!$foundManifest) { $zip->close(); return t('zip_no_manifest'); }

        $manifestPath = $pluginSubdir ? $pluginSubdir . '/plugin.json' : 'plugin.json';
        $meta = json_decode((string)$zip->getFromName($manifestPath), true);
        if (!$meta || empty($meta['id']) || empty($meta['class'])) {
            $zip->close();
            return t('invalid_plugin_json');
        }
        if (!preg_match('/^[a-zA-Z0-9_-]+$/', $meta['id'])) {
            $zip->close();
            return t('invalid_plugin_id');
        }

        $targetDir = BASE_PATH . '/plugins/' . $meta['id'];
        if (!is_dir($targetDir)) mkdir($targetDir, 0750, true);

        for ($i = 0; $i < $zip->numFiles; $i++) {
            $name = $zip->getNameIndex($i);
            $relative = $pluginSubdir ? (str_starts_with($name, $pluginSubdir . '/') ? substr($name, strlen($pluginSubdir) + 1) : null) : $name;
            if ($relative === null || $relative === '') continue;
            $dest = $targetDir . '/' . $relative;
            if (str_ends_with($name, '/')) {
                if (!is_dir($dest)) mkdir($dest, 0750, true);
            } else {
                $ext = strtolower(pathinfo($relative, PATHINFO_EXTENSION));
                $basename = basename($relative);
                // Reject anything that PHP / Apache could interpret as
                // server-side code or per-directory configuration. Dotfiles
                // are blocked outright because `.user.ini` (`ini` extension)
                // would otherwise drop into a plugin directory next to the
                // legitimate `.php` files of an existing plugin id and let
                // an admin-uploaded plugin pivot to RCE via auto_prepend_file.
                if (str_starts_with($basename, '.')
                    || in_array($ext, ['php','phtml','phar','php3','php4','php5','php7','htaccess','htpasswd','ini','inc'], true)
                    || str_contains($relative, "\0")) {
                    continue;
                }
                if (!is_dir(dirname($dest))) mkdir(dirname($dest), 0750, true);
                file_put_contents($dest, $zip->getFromIndex($i));
            }
        }

        $zip->close();
        return true;
    }

    private static function deleteDir(string $path): void
    {
        foreach (scandir($path) as $item) {
            if ($item === '.' || $item === '..') continue;
            $full = $path . '/' . $item;
            is_dir($full) ? self::deleteDir($full) : unlink($full);
        }
        rmdir($path);
    }

    // ── Health check (AJAX) ──────────────────────────────────────────────────────

    public static function securityLog(): void
    {
        Auth::requireAdmin();
        $db = Database::getInstance();

        $filterType     = trim((string)Helper::get('type', ''));
        $filterSeverity = trim((string)Helper::get('severity', ''));

        $where  = '1=1';
        $params = [];
        if ($filterType !== '' && preg_match('/^[a-z_]+$/', $filterType)) {
            $where    .= ' AND event_type = ?';
            $params[]  = $filterType;
        }
        if (in_array($filterSeverity, ['info', 'warn', 'critical'], true)) {
            $where    .= ' AND severity = ?';
            $params[]  = $filterSeverity;
        }

        $events = $db->fetchAll(
            "SELECT se.*, u.username, u.email FROM security_events se
             LEFT JOIN users u ON u.id = se.user_id
             WHERE $where
             ORDER BY se.created_at DESC
             LIMIT 500",
            $params
        );

        // Distinct event types for the filter dropdown
        $types = array_column($db->fetchAll('SELECT DISTINCT event_type FROM security_events ORDER BY event_type'), 'event_type');

        $retentionDays = (int)Helper::getSetting('security_log_retention_days', '365');

        view('admin/security-log', compact('events', 'types', 'filterType', 'filterSeverity', 'retentionDays'));
    }

    public static function healthCheck(): void
    {
        Auth::requireAdmin();
        header('Content-Type: application/json');
        $checks = HealthCheck::runAll();
        // Persist the fresh result so the topbar traffic light reflects it
        // immediately. Without this, the light shows the last cron-stored
        // aggregate, which goes stale when the cron is off or hasn't ticked
        // since the issue was fixed.
        HealthCheck::storeCronState($checks);
        echo json_encode(['checks' => $checks]);
    }

    // ── AI model list (AJAX) ──────────────────────────────────────────────────────

    public static function aiModels(): void
    {
        Auth::requireAdmin();
        header('Content-Type: application/json');

        $provider = Helper::get('provider', '');
        $result = match ($provider) {
            'ollama'  => self::fetchOllamaModels(),
            'claude'  => self::fetchClaudeModels(),
            'openai'  => self::fetchOpenAIModels(),
            'copilot' => self::fetchCopilotDeployments(),
            default   => null,
        };

        if ($result === null) { echo json_encode(['error' => t('model_load_unknown_provider')]); return; }
        if ($result === false) { echo json_encode(['error' => t('model_load_failed')]); return; }

        echo json_encode(['models' => $result]);
    }

    private static function curlGet(string $url, array $headers, int $timeout = 10): string|false
    {
        $ch = curl_init($url);
        curl_setopt_array($ch, [
            CURLOPT_RETURNTRANSFER => true,
            CURLOPT_HTTPHEADER     => $headers,
            CURLOPT_TIMEOUT        => $timeout,
        ]);
        $resp = curl_exec($ch);
        $err  = curl_error($ch);
        curl_close($ch);
        return ($err || !$resp) ? false : $resp;
    }

    private static function fetchOllamaModels(): array|false
    {
        $url      = rtrim(Helper::getSetting('ai_ollama_url', 'http://localhost:11434'), '/');
        $authType = Helper::getSetting('ai_ollama_auth_type', 'none');
        $headers  = [];
        if ($authType === 'bearer' && ($t = Helper::getSetting('ai_ollama_bearer_token', '')))
            $headers[] = 'Authorization: Bearer ' . $t;
        elseif ($authType === 'basic' && ($u = Helper::getSetting('ai_ollama_basic_user', '')))
            $headers[] = 'Authorization: Basic ' . base64_encode($u . ':' . Helper::getSetting('ai_ollama_basic_pass', ''));

        $resp = self::curlGet($url . '/api/tags', $headers);
        if (!$resp) return false;
        $data = json_decode($resp, true);
        if (!isset($data['models'])) return false;
        return array_column($data['models'], 'name');
    }

    private static function fetchClaudeModels(): array|false
    {
        $apiKey = Helper::getSetting('ai_claude_key', '');
        if (!$apiKey) return false;
        $resp = self::curlGet('https://api.anthropic.com/v1/models', [
            'x-api-key: ' . $apiKey,
            'anthropic-version: 2023-06-01',
        ]);
        if (!$resp) return false;
        $data = json_decode($resp, true);
        if (!isset($data['data'])) return false;
        return array_column($data['data'], 'id');
    }

    private static function fetchOpenAIModels(): array|false
    {
        $apiKey = Helper::getSetting('ai_openai_key', '');
        if (!$apiKey) return false;
        $resp = self::curlGet('https://api.openai.com/v1/models', ['Authorization: Bearer ' . $apiKey]);
        if (!$resp) return false;
        $data = json_decode($resp, true);
        if (!isset($data['data'])) return false;
        $models = array_filter(
            array_column($data['data'], 'id'),
            fn($m) => preg_match('/^(gpt-|o1|o3|chatgpt-)/', $m)
        );
        sort($models);
        return array_values($models);
    }

    private static function fetchCopilotDeployments(): array|false
    {
        $endpoint   = rtrim(Helper::getSetting('ai_copilot_endpoint', ''), '/');
        $apiKey     = Helper::getSetting('ai_copilot_key', '');
        $apiVersion = Helper::getSetting('ai_copilot_api_version', '2024-02-01');
        if (!$endpoint || !$apiKey) return false;
        $resp = self::curlGet(
            $endpoint . '/openai/deployments?api-version=' . $apiVersion,
            ['api-key: ' . $apiKey]
        );
        if (!$resp) return false;
        $data = json_decode($resp, true);
        if (!isset($data['data'])) return false;
        return array_column($data['data'], 'id');
    }

    public static function updateRun(): void
    {
        Auth::requireAdmin();
        $version = Helper::get('version', '');
        view('admin/update-run', compact('version'));
    }

    public static function updatePerform(): void
    {
        ob_start();
        Auth::requireAdmin();

        // Pre-update backup + download + extraction + Migrator::run can each
        // burn dozens of seconds on a heavy install; default
        // `max_execution_time = 30` would otherwise kill the request mid-write
        // and leave the install in a partial state. ignore_user_abort keeps
        // the chain running even when the admin closes the browser tab.
        @set_time_limit(0);
        @ignore_user_abort(true);

        $json = function(array $data): never {
            ob_end_clean();
            header('Content-Type: application/json');
            echo json_encode($data);
            exit;
        };

        if (!Helper::isPost()) {
            $json(['ok' => false, 'error' => 'POST required.', 'log' => []]);
        }

        $token = $_POST['csrf_token'] ?? '';
        if (!Auth::verifyCsrfToken($token)) {
            http_response_code(403);
            $json(['ok' => false, 'error' => 'CSRF token mismatch.', 'log' => []]);
        }

        $info = Updater::getRemoteInfo();
        if (!$info || !version_compare($info['version'], APP_VERSION, '>')) {
            $json(['ok' => false, 'error' => t('update_already_latest'), 'log' => []]);
        }

        $result = Updater::perform($info);
        $json($result);
    }

    public static function update(): void
    {
        Auth::requireAdmin();

        $result = null;
        $force  = Helper::get('force') === '1';

        if (Helper::isPost()) {
            Helper::verifyCsrf();
            $action = Helper::post('action');

            if ($action === 'perform') {
                $info = Updater::getRemoteInfo();
                if (!$info || !version_compare($info['version'], APP_VERSION, '>')) {
                    Helper::redirect('/admin/update', ['error' => t('update_already_latest')]);
                }
                $result = Updater::perform($info);
                if ($result['ok']) {
                    Helper::redirect('/admin/update', ['success' => t('update_success')]);
                }
            }
        }

        $info    = Updater::getRemoteInfo($force);
        $success = Helper::flash('success');
        $error   = Helper::flash('error') ?? ($result ? ($result['error'] ?? null) : null);

        view('admin/update', compact('info', 'success', 'error'));
    }

    /**
     * Stream a freshly built backup ZIP to the admin's browser. The ZIP
     * contains a SQLite snapshot, app.key, plugin state, avatars and uploads;
     * if `passphrase` is non-empty the file is wrapped in AES-256-GCM with
     * PBKDF2-derived key (200_000 iterations).
     */
    public static function backupDownload(): void
    {
        Auth::requireAdmin();
        if (!Helper::isPost()) {
            Helper::redirect('/admin/settings');
        }
        Helper::verifyCsrf();

        // VACUUM INTO + ZIP build can take minutes on a large DB; default
        // PHP max_execution_time would otherwise leave a half-finished
        // ZIP in data/backups/ that surfaces in the listing but fails
        // every restore attempt. ignore_user_abort keeps writing even if
        // the admin closes the tab.
        @set_time_limit(0);
        @ignore_user_abort(true);

        try {
            $passphrase = self::resolveBackupPassphrase((string)Helper::post('passphrase', ''));
        } catch (\Throwable $e) {
            SecurityLog::log('backup_default_passphrase_unreadable', SecurityLog::SEV_CRITICAL, ['op' => 'create']);
            Helper::redirect('/admin/settings#backupCard', ['error' => Lang::get('backup_err_default_passphrase_unreadable')]);
        }
        $result = Backup::create($passphrase);

        if (!$result['ok']) {
            SecurityLog::log('backup_download_failed', SecurityLog::SEV_WARN, ['reason' => $result['error'] ?? 'unknown']);
            Helper::redirect('/admin/settings#backupCard', ['error' => Lang::get($result['error'] ?? 'backup_err_generic')]);
        }

        SecurityLog::log('backup_created', SecurityLog::SEV_WARN, [
            'encrypted' => $result['encrypted'] ? 1 : 0,
            'size'      => $result['size'],
            'filename'  => $result['filename'],
        ]);

        // The file persists under data/backups/. The admin gets a flash
        // success and lands back on the Backup card where the new entry is
        // visible at the top of "Vorhandene Backups" — they can then
        // download it explicitly via the per-row Download button.
        Helper::redirect('/admin/settings#backupCard', ['success' => Lang::get('backup_create_ok', ['filename' => $result['filename']])]);
    }

    /** Re-download a previously-created backup that is still on disk. */
    public static function backupRedownload(): void
    {
        Auth::requireAdmin();
        if (!Helper::isPost()) {
            Helper::redirect('/admin/settings');
        }
        Helper::verifyCsrf();
        @set_time_limit(0);
        @ignore_user_abort(true);

        $filename = (string)Helper::post('filename', '');
        $path     = Backup::resolveBackupPath($filename);
        if ($path === null) {
            Helper::redirect('/admin/settings#backupCard', ['error' => Lang::get('backup_err_unknown_backup')]);
        }
        SecurityLog::log('backup_redownload', SecurityLog::SEV_WARN, ['filename' => $filename]);
        self::streamBackup($path, $filename, (int)(@filesize($path) ?: 0));
    }

    /** Delete a backup from data/backups/. */
    public static function backupDeletePersisted(): void
    {
        Auth::requireAdmin();
        if (!Helper::isPost()) {
            Helper::redirect('/admin/settings');
        }
        Helper::verifyCsrf();

        $filename = (string)Helper::post('filename', '');
        if (Backup::deletePersisted($filename)) {
            SecurityLog::log('backup_delete', SecurityLog::SEV_WARN, ['filename' => $filename]);
            Helper::redirect('/admin/settings#backupCard', ['success' => Lang::get('backup_delete_ok')]);
        }
        Helper::redirect('/admin/settings#backupCard', ['error' => Lang::get('backup_err_unknown_backup')]);
    }

    /**
     * Resolve the passphrase to use for a backup operation: explicit input
     * wins; if the input is empty, fall back to the admin-stored default
     * passphrase (decrypted via `Backup::defaultPassphrase()`). Returns null
     * when neither is set — downstream Backup methods interpret null as
     * "no encryption".
     */
    private static function resolveBackupPassphrase(string $entered): ?string
    {
        return $entered !== '' ? $entered : Backup::defaultPassphrase();
    }

    private static function streamBackup(string $path, string $filename, int $size): void
    {
        while (ob_get_level() > 0) ob_end_clean();
        header('Content-Type: application/octet-stream');
        header('Content-Disposition: attachment; filename="' . $filename . '"');
        if ($size > 0) header('Content-Length: ' . $size);
        header('Cache-Control: private, no-store');
        readfile($path);
        exit;
    }

    /**
     * Two-step restore:
     *   step=validate (default) – upload + stage + show the review page
     *   step=apply              – take the staged token + edited app_url
     *                             and execute the swap
     *   step=cancel             – drop the staged ZIP without applying
     */
    public static function backupRestore(): void
    {
        Auth::requireAdmin();
        if (!Helper::isPost()) {
            Helper::redirect('/admin/settings');
        }
        Helper::verifyCsrf();

        // Restore extract + Migrator::run can take a while; mirror the
        // create path. See `backupDownload()` for the rationale.
        @set_time_limit(0);
        @ignore_user_abort(true);

        $step = (string)Helper::get('step', 'validate');

        if ($step === 'cancel') {
            $token = (string)Helper::post('token', '');
            Backup::cancelStaged($token);
            SecurityLog::log('backup_restore_cancelled', SecurityLog::SEV_INFO);
            Helper::redirect('/admin/settings#backupCard', ['success' => Lang::get('backup_cancel_success')]);
        }

        if ($step === 'validate-existing') {
            // Stage from a backup that is already on disk under data/backups/.
            // Same flow as the upload path (sequence: stage → review → apply),
            // just without the file upload.
            $filename = (string)Helper::post('filename', '');
            $path     = Backup::resolveBackupPath($filename);
            if ($path === null) {
                Helper::redirect('/admin/settings#backupCard', ['error' => Lang::get('backup_err_unknown_backup')]);
            }
            try {
                $passphrase = self::resolveBackupPassphrase((string)Helper::post('passphrase', ''));
            } catch (\Throwable $e) {
                SecurityLog::log('backup_default_passphrase_unreadable', SecurityLog::SEV_CRITICAL, ['op' => 'restore_existing', 'filename' => $filename]);
                Helper::redirect('/admin/settings#backupCard', ['error' => Lang::get('backup_err_default_passphrase_unreadable')]);
            }

            SecurityLog::log('backup_restore_validate', SecurityLog::SEV_INFO, [
                'source'    => 'persisted',
                'filename'  => $filename,
                'encrypted' => Backup::isEncrypted($path) ? 1 : 0,
            ]);

            $result = Backup::stage($path, $passphrase);
            if (!$result['ok']) {
                SecurityLog::log('backup_restore_validate_failed', SecurityLog::SEV_WARN, ['reason' => $result['error'] ?? 'unknown', 'filename' => $filename]);
                Helper::redirect('/admin/settings#backupCard', ['error' => Lang::get($result['error'] ?? 'backup_err_generic')]);
            }
            view('admin/backup-review', [
                'token' => $result['token'],
                'meta'  => $result['meta'],
            ]);
        }

        if ($step === 'apply') {
            $token = (string)Helper::post('token', '');
            $meta  = Backup::getStagedMeta($token);
            if ($meta === null) {
                Helper::redirect('/admin/settings#backupCard', ['error' => Lang::get('backup_err_staging_expired')]);
            }

            // The new app_url field: empty string means "auto-detect", any
            // non-empty value must be a valid http(s) URL with a host.
            $rawUrl    = trim((string)Helper::post('new_app_url', ''));
            $newAppUrl = '';
            if ($rawUrl !== '') {
                $parsed = parse_url($rawUrl);
                if (!is_array($parsed)
                    || !in_array(strtolower((string)($parsed['scheme'] ?? '')), ['http', 'https'], true)
                    || empty($parsed['host'])
                ) {
                    Helper::redirect('/admin/settings#backupCard', ['error' => Lang::get('backup_err_url_invalid')]);
                }
                $newAppUrl = rtrim($rawUrl, '/');
            }

            SecurityLog::log('backup_restore_started', SecurityLog::SEV_CRITICAL, [
                'source_version' => $meta['source_version']  ?? '',
                'source_app_url' => $meta['source_app_url']  ?? '',
                'new_app_url'    => $newAppUrl,
                'was_encrypted'  => !empty($meta['was_encrypted']),
            ]);

            $result = Backup::applyStaged($token, $newAppUrl);
            if (!$result['ok']) {
                SecurityLog::log('backup_restore_failed', SecurityLog::SEV_CRITICAL, [
                    'reason' => $result['error'] ?? 'unknown',
                    'log'    => $result['log']  ?? [],
                ]);
                Helper::redirect('/admin/settings#backupCard', ['error' => Lang::get($result['error'] ?? 'backup_err_generic')]);
            }

            SecurityLog::log('backup_restore_completed', SecurityLog::SEV_CRITICAL, ['log' => $result['log']]);
            Auth::logout();
            Helper::redirect('/login', ['success' => Lang::get('backup_restore_success')]);
        }

        // Default: step=validate.
        $file = $_FILES['backup_file'] ?? null;
        if (!$file || ($file['error'] ?? UPLOAD_ERR_NO_FILE) !== UPLOAD_ERR_OK) {
            Helper::redirect('/admin/settings#backupCard', ['error' => Lang::get('backup_err_upload')]);
        }
        if (!is_uploaded_file($file['tmp_name'])) {
            Helper::redirect('/admin/settings#backupCard', ['error' => Lang::get('backup_err_upload')]);
        }
        if (($file['size'] ?? 0) > Backup::MAX_RESTORE_UPLOAD_BYTES) {
            Helper::redirect('/admin/settings#backupCard', ['error' => Lang::get('backup_err_too_large')]);
        }

        try {
            $passphrase = self::resolveBackupPassphrase((string)Helper::post('passphrase', ''));
        } catch (\Throwable $e) {
            SecurityLog::log('backup_default_passphrase_unreadable', SecurityLog::SEV_CRITICAL, ['op' => 'restore_upload']);
            Helper::redirect('/admin/settings#backupCard', ['error' => Lang::get('backup_err_default_passphrase_unreadable')]);
        }

        SecurityLog::log('backup_restore_validate', SecurityLog::SEV_INFO, [
            'size'      => (int)($file['size'] ?? 0),
            'encrypted' => Backup::isEncrypted($file['tmp_name']) ? 1 : 0,
        ]);

        $result = Backup::stage($file['tmp_name'], $passphrase);

        if (!$result['ok']) {
            SecurityLog::log('backup_restore_validate_failed', SecurityLog::SEV_WARN, ['reason' => $result['error'] ?? 'unknown']);
            Helper::redirect('/admin/settings#backupCard', ['error' => Lang::get($result['error'] ?? 'backup_err_generic')]);
        }

        view('admin/backup-review', [
            'token' => $result['token'],
            'meta'  => $result['meta'],
        ]);
    }
}
