<?php

declare(strict_types=1);

namespace FileRise\Http\Controllers;

use FileRise\Support\ACL;
use FileRise\Support\CryptoAtRest;
use FileRise\Support\PersistentTokensKeyRotation;
use FileRise\Support\UploadNamePolicy;
use FileRise\Support\WorkerLauncher;
use FileRise\Storage\SourceContext;
use FileRise\Domain\AdminModel;
use FileRise\Domain\FolderCrypto;
use FileRise\Domain\UploadModel;
use FileRise\Domain\UserModel;
use FilesystemIterator;
use InvalidArgumentException;
use RecursiveDirectoryIterator;
use RecursiveIteratorIterator;
use RuntimeException;
use SplFileInfo;
use Throwable;
use ProGroups;
use ProPortals;

// src/controllers/AdminController.php

require_once dirname(__DIR__, 4) . '/config/config.php';
require_once PROJECT_ROOT . '/src/lib/CryptoAtRest.php';
require_once PROJECT_ROOT . '/src/lib/ACL.php';
require_once PROJECT_ROOT . '/src/lib/SourceContext.php';
require_once PROJECT_ROOT . '/src/FileRise/Support/PersistentTokensKeyRotation.php';

class AdminController
{
    /** Enforce authentication (401). */
    public static function requireAuth(): void
    {
        if (empty($_SESSION['authenticated']) || $_SESSION['authenticated'] !== true) {
            http_response_code(401);
            header('Content-Type: application/json');
            echo json_encode(['error' => 'Unauthorized']);
            exit;
        }
    }

    /** Enforce admin (401). */
    public static function requireAdmin(): void
    {
        self::requireAuth();

        // Prefer the session flag
        $isAdmin = (!empty($_SESSION['isAdmin']) && $_SESSION['isAdmin'] === true);

        // Fallback: check the user’s role in storage (e.g., users.txt/DB)
        if (!$isAdmin) {
            $u = $_SESSION['username'] ?? '';
            if ($u) {
                try {
                    // UserModel::getUserRole($u) should return '1' for admins
                    $isAdmin = (UserModel::getUserRole($u) === '1');
                    if ($isAdmin) {
                        // Normalize session so downstream ACL checks see admin
                        $_SESSION['isAdmin'] = true;
                    }
                } catch (\Throwable $e) {
                    // ignore and continue to deny
                }
            }
        }

        if (!$isAdmin) {
            http_response_code(403);
            header('Content-Type: application/json');
            echo json_encode(['error' => 'Admin privileges required.']);
            exit;
        }
    }

    /** True if the current admin is the first admin in users.txt. */
    private static function isPrimaryAdminUser(): bool
    {
        $user = $_SESSION['username'] ?? '';
        if ($user === '') {
            return false;
        }
        try {
            $primary = UserModel::getPrimaryAdminUsername();
        } catch (\Throwable $e) {
            return false;
        }
        if ($primary === '') {
            return false;
        }
        return strcasecmp($user, $primary) === 0;
    }

    /** Enforce primary admin (registered administrator) for Pro license actions. */
    private static function requirePrimaryAdmin(): void
    {
        self::requireAdmin();
        if (!self::isPrimaryAdminUser()) {
            http_response_code(403);
            header('Content-Type: application/json');
            echo json_encode(['error' => 'Restricted to the registered administrator.']);
            exit;
        }
    }
    /** Get headers in lowercase, robust across SAPIs. */
    private static function headersLower(): array
    {
        $headers = function_exists('getallheaders') ? getallheaders() : [];
        $out = [];
        foreach ($headers as $k => $v) {
            $out[strtolower($k)] = $v;
        }
        // Fallbacks from $_SERVER if needed
        foreach ($_SERVER as $k => $v) {
            if (strpos($k, 'HTTP_') === 0) {
                $h = strtolower(str_replace('_', '-', substr($k, 5)));
                if (!isset($out[$h])) {
                    $out[$h] = $v;
                }
            }
        }
        return $out;
    }

    /** Get or create a stable instance ID (32 hex chars). */
    private static function getInstanceId(): string
    {
        if (!defined('META_DIR')) {
            return '';
        }
        $dir = rtrim((string)META_DIR, "/\\");
        if ($dir === '') {
            return '';
        }
        $path = $dir . DIRECTORY_SEPARATOR . 'instance_id.txt';
        $id = '';
        if (is_file($path)) {
            $id = trim((string)@file_get_contents($path));
        }
        if ($id !== '' && preg_match('/^[a-f0-9]{32}$/i', $id)) {
            return strtolower($id);
        }
        try {
            $id = bin2hex(random_bytes(16));
        } catch (\Throwable $e) {
            return '';
        }
        if (!is_dir($dir)) {
            @mkdir($dir, 0775, true);
        }
        @file_put_contents($path, $id, LOCK_EX);
        return $id;
    }

    /** Enforce CSRF using X-CSRF-Token header (or csrfToken param as fallback). */
    public static function requireCsrf(): void
    {
        $h = self::headersLower();
        $token = trim($h['x-csrf-token'] ?? ($_POST['csrfToken'] ?? ''));
        if (empty($_SESSION['csrf_token']) || $token !== $_SESSION['csrf_token']) {
            http_response_code(403);
            header('Content-Type: application/json');
            echo json_encode(['error' => 'Invalid CSRF token']);
            exit;
        }
    }

    /** Read JSON body (empty array if not valid). */
    private static function readJson(): array
    {
        $raw = file_get_contents('php://input');
        $data = json_decode($raw, true);
        return is_array($data) ? $data : [];
    }

    private static function detectLegacyProBundle(): array
    {
        static $cache = null;
        static $cacheTs = 0;
        $now = time();
        if (is_array($cache) && ($now - $cacheTs) < 30) {
            return $cache;
        }

        $cacheTs = $now;
        $result = [
            'legacy' => false,
            'files' => [],
        ];

        if (!defined('FR_PRO_BUNDLE_DIR') || !FR_PRO_BUNDLE_DIR) {
            $cache = $result;
            return $result;
        }

        $dir = rtrim((string)FR_PRO_BUNDLE_DIR, "/\\");
        if ($dir === '' || !is_dir($dir)) {
            $cache = $result;
            return $result;
        }

        $targets = [
            'ProAudit.php',
            'ProDiskUsage.php',
            'ProSearch.php',
        ];

        foreach ($targets as $file) {
            $path = $dir . DIRECTORY_SEPARATOR . $file;
            if (!is_file($path)) {
                continue;
            }
            $raw = @file_get_contents($path);
            if (!is_string($raw) || $raw === '') {
                continue;
            }
            if (
                strpos($raw, '/src/models/') !== false
                || strpos($raw, '\\src\\models\\') !== false
                || strpos($raw, '/src/controllers/') !== false
                || strpos($raw, '\\src\\controllers\\') !== false
            ) {
                $result['legacy'] = true;
                $result['files'][] = $file;
            }
        }

        $cache = $result;
        return $result;
    }

    public function getConfig(): void
    {
        header('Content-Type: application/json; charset=utf-8');

        $config = AdminModel::getConfig();
        if (isset($config['error'])) {
            http_response_code(500);
            header('Cache-Control: no-store');
            echo json_encode(['error' => $config['error']], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
            return;
        }

        // ---- Effective ONLYOFFICE values (constants override adminConfig) ----
        $ooCfg      = is_array($config['onlyoffice'] ?? null) ? $config['onlyoffice'] : [];
        $effEnabled = defined('ONLYOFFICE_ENABLED')
            ? (bool) ONLYOFFICE_ENABLED
            : (bool) ($ooCfg['enabled'] ?? false);

        $effDocs = (defined('ONLYOFFICE_DOCS_ORIGIN') && ONLYOFFICE_DOCS_ORIGIN !== '')
            ? (string) ONLYOFFICE_DOCS_ORIGIN
            : (string) ($ooCfg['docsOrigin'] ?? '');

        $hasSecret = defined('ONLYOFFICE_JWT_SECRET')
            ? (ONLYOFFICE_JWT_SECRET !== '')
            : (!empty($ooCfg['jwtSecret']));

        $publicOriginCfg = (string) ($ooCfg['publicOrigin'] ?? '');

        $isAdmin = !empty($_SESSION['authenticated']) && !empty($_SESSION['isAdmin']);
        $isPrimaryAdmin = $isAdmin && self::isPrimaryAdminUser();

        // ---- Pro / license info (all guarded for clean core installs) ----
        $licenseString = null;
        if ($isPrimaryAdmin && defined('PRO_LICENSE_FILE') && PRO_LICENSE_FILE && @is_file(PRO_LICENSE_FILE)) {
            $json = @file_get_contents(PRO_LICENSE_FILE);
            if ($json !== false) {
                $decoded = json_decode($json, true);
                if (is_array($decoded) && !empty($decoded['license'])) {
                    $licenseString = (string) $decoded['license'];
                }
            }
        }

        $proActive = defined('FR_PRO_ACTIVE') && FR_PRO_ACTIVE;

        // FR_PRO_INFO is only defined when bootstrap_pro.php has run; guard it
        $proPayload = [];
        if (defined('FR_PRO_INFO') && is_array(FR_PRO_INFO)) {
            $p = FR_PRO_INFO['payload'] ?? null;
            if (is_array($p)) {
                $proPayload = $p;
            }
        }

        // ---- ClamAV / virus scanning (env/constant override awareness) ----
        $envScanRaw = getenv('VIRUS_SCAN_ENABLED');

        if ($envScanRaw !== false && $envScanRaw !== '') {
            // 1) If env var is set, it is the source of truth
            $clamScanUploads = filter_var($envScanRaw, FILTER_VALIDATE_BOOLEAN);
            $clamLockedByEnv = true;
        } elseif (defined('VIRUS_SCAN_ENABLED')) {
            // 2) Optional: support a PHP constant as a "locked" override too
            $clamScanUploads = (bool) VIRUS_SCAN_ENABLED;
            $clamLockedByEnv = true;
        } else {
            // 3) No env/constant -> use whatever was saved in the admin config
            $clamScanUploads = (bool) ($config['clamav']['scanUploads'] ?? false);
            $clamLockedByEnv = false;
        }

        $proType         = $proPayload['type']  ?? null;
        $proEmail        = $proPayload['email'] ?? null;
        $proVersion      = defined('FR_PRO_BUNDLE_VERSION') ? FR_PRO_BUNDLE_VERSION : null;
        $proApiLevel     = defined('FR_PRO_API_LEVEL') ? (int)FR_PRO_API_LEVEL : 0;
        $proBuildEpoch   = defined('FR_PRO_BUNDLE_BUILD_EPOCH') ? (int)FR_PRO_BUNDLE_BUILD_EPOCH : 0;
        $proPlan         = $proPayload['plan']      ?? null;
        $proExpiresAt    = $proPayload['expiresAt'] ?? null;
        $proUpdatesUntil = $proPayload['updatesUntil'] ?? null;
        $proMaxMajor     = $proPayload['maxMajor']  ?? null;
        $instanceId      = self::getInstanceId();

        $legacyBundle = ($isAdmin ? self::detectLegacyProBundle() : ['legacy' => false, 'files' => []]);

        $proInfo = [
            'active'   => (bool)$proActive,
            'type'     => $proType ?: '',
            'email'    => $proEmail ?: '',
            'version'  => $proVersion ?: '',
            'apiLevel' => $proApiLevel,
            'buildEpoch' => $proBuildEpoch,
            'license'  => $licenseString ?: '',
            'primaryAdmin' => (bool)$isPrimaryAdmin,
            'plan'        => $proPlan ?: '',
            'expiresAt'   => $proExpiresAt ?: '',
            'updatesUntil' => $proUpdatesUntil ?: '',
            'maxMajor'    => $proMaxMajor,
            'instanceId'  => $instanceId ?: '',
            'bundleLegacy' => (bool)($legacyBundle['legacy'] ?? false),
            'bundleLegacyFiles' => (isset($legacyBundle['files']) && is_array($legacyBundle['files']))
                ? array_values($legacyBundle['files'])
                : [],
        ];

        $public = AdminModel::buildPublicSubset($config);

        // Safe public view of Pro status (no license string)
        $public['pro'] = [
            'active'  => (bool)$proActive,
            'version' => $proVersion ?: '',
            'apiLevel' => $proApiLevel,
        ];

        if ($isAdmin) {
            // ---- Encryption at rest (master key status) ----
            $encSupported = CryptoAtRest::isAvailable();
            $encKeyBytes = (defined('SODIUM_CRYPTO_SECRETSTREAM_XCHACHA20POLY1305_KEYBYTES')
                ? (int)SODIUM_CRYPTO_SECRETSTREAM_XCHACHA20POLY1305_KEYBYTES
                : 32);
            $envEncRaw = getenv('FR_ENCRYPTION_MASTER_KEY');
            $envEncPresent = ($envEncRaw !== false && trim((string)$envEncRaw) !== '');
            $envEncValid = false;
            if ($envEncPresent) {
                $envEncValid = (CryptoAtRest::decodeKeyString((string)$envEncRaw) !== null);
            }

            $keyFile = rtrim((string)META_DIR, "/\\") . DIRECTORY_SEPARATOR . 'encryption_master.key';
            $filePresent = is_file($keyFile);
            $fileValid = false;
            if ($filePresent && $encSupported) {
                $sz = @filesize($keyFile);
                $fileValid = (is_int($sz) && $sz === $encKeyBytes);
            }

            $encSource = 'missing';
            if ($envEncPresent) {
                $encSource = $envEncValid ? 'env' : 'env_invalid';
            } elseif ($filePresent) {
                $encSource = $fileValid ? 'file' : 'file_invalid';
            }

            $encHasMasterKey = CryptoAtRest::masterKeyIsConfigured();

            // admin-only extras: presence flags + proxy options + ONLYOFFICE effective view
            $envPublished = getenv('FR_PUBLISHED_URL');
            $publishedLockedByEnv = ($envPublished !== false && trim((string)$envPublished) !== '');
            $publishedCfg = (string)($config['publishedUrl'] ?? '');
            $publishedEffective = $publishedLockedByEnv ? trim((string)$envPublished) : $publishedCfg;

            $envFfmpeg = getenv('FR_FFMPEG_PATH');
            $ffmpegLockedByEnv = ($envFfmpeg !== false && trim((string)$envFfmpeg) !== '');
            $ffmpegCfg = (string)($config['ffmpegPath'] ?? '');
            $ffmpegEffective = $ffmpegLockedByEnv ? trim((string)$envFfmpeg) : $ffmpegCfg;

            $envIgnore = getenv('FR_IGNORE_REGEX');
            $ignoreLockedByEnv = ($envIgnore !== false && trim((string)$envIgnore) !== '');
            $ignoreCfg = (string)($config['ignoreRegex'] ?? '');
            $ignoreEffective = $ignoreLockedByEnv ? trim((string)$envIgnore) : $ignoreCfg;

            $envGroupClaim = getenv('FR_OIDC_GROUP_CLAIM');
            $groupClaimLockedByEnv = ($envGroupClaim !== false && trim((string)$envGroupClaim) !== '');
            $groupClaimCfg = (string)($config['oidc']['groupClaim'] ?? '');
            $groupClaimEffective = $groupClaimLockedByEnv ? trim((string)$envGroupClaim) : $groupClaimCfg;

            $envExtraScopes = getenv('FR_OIDC_EXTRA_SCOPES');
            $extraScopesLockedByEnv = ($envExtraScopes !== false && trim((string)$envExtraScopes) !== '');
            $extraScopesCfg = (string)($config['oidc']['extraScopes'] ?? '');
            $extraScopesEffective = $extraScopesLockedByEnv ? trim((string)$envExtraScopes) : $extraScopesCfg;

            $adminExtra = [
                'loginOptions' => array_merge($public['loginOptions'], [
                    'authBypass'     => (bool)($config['loginOptions']['authBypass'] ?? false),
                    'authHeaderName' => (string)($config['loginOptions']['authHeaderName'] ?? 'X-Remote-User'),
                ]),
                'oidc' => array_merge($public['oidc'], [
                    'hasClientId'     => !empty($config['oidc']['clientId']),
                    'hasClientSecret' => !empty($config['oidc']['clientSecret']),
                    'debugLogging'    => !empty($config['oidc']['debugLogging']),
                    'allowDemote'     => !empty($config['oidc']['allowDemote']),
                    'publicClient'    => !empty($config['oidc']['publicClient']),
                    'groupClaim'      => $groupClaimCfg,
                    'groupClaimEffective' => $groupClaimEffective,
                    'groupClaimLockedByEnv' => $groupClaimLockedByEnv,
                    'extraScopes'     => $extraScopesCfg,
                    'extraScopesEffective' => $extraScopesEffective,
                    'extraScopesLockedByEnv' => $extraScopesLockedByEnv,
                ]),
                'onlyoffice' => [
                    'enabled'      => $effEnabled,
                    'docsOrigin'   => $effDocs,         // effective (constants win)
                    'publicOrigin' => $publicOriginCfg, // optional override from adminConfig
                    'hasJwtSecret' => (bool)$hasSecret, // boolean only; never leak secret
                    'lockedByPhp'  => (
                        defined('ONLYOFFICE_ENABLED')
                        || defined('ONLYOFFICE_DOCS_ORIGIN')
                        || defined('ONLYOFFICE_JWT_SECRET')
                        || defined('ONLYOFFICE_PUBLIC_ORIGIN')
                    ),
                ],
                'pro' => $proInfo,
                'deployment' => [
                    'basePath' => (defined('FR_BASE_PATH') ? (string)FR_BASE_PATH : ''),
                    'shareUrl' => (defined('SHARE_URL') ? (string)SHARE_URL : ''),
                    'publishedUrl' => $publishedCfg,
                    'publishedUrlEffective' => $publishedEffective,
                    'publishedUrlLockedByEnv' => $publishedLockedByEnv,
                ],
                'ffmpegPath' => $ffmpegCfg,
                'ffmpegPathEffective' => $ffmpegEffective,
                'ffmpegPathLockedByEnv' => $ffmpegLockedByEnv,
                'ignoreRegex' => $ignoreCfg,
                'ignoreRegexEffective' => $ignoreEffective,
                'ignoreRegexLockedByEnv' => $ignoreLockedByEnv,
                'encryption' => [
                    'supported'   => (bool)$encSupported,
                    'hasMasterKey' => (bool)$encHasMasterKey,
                    'source'      => (string)$encSource, // env|env_invalid|file|file_invalid|missing
                    'lockedByEnv' => (bool)$envEncValid,
                    'envPresent'  => (bool)$envEncPresent,
                    'filePresent' => (bool)$filePresent,
                ],
                'persistentTokensKeyStatus' => fr_get_persistent_tokens_key_status(),
                'proSearch' => (function () use ($config) {
                    $raw = isset($config['proSearch']) && is_array($config['proSearch'])
                        ? $config['proSearch']
                        : [];
                    $enabled = !empty($raw['enabled']);
                    $defaultLimit = isset($raw['defaultLimit']) ? (int)$raw['defaultLimit'] : 50;
                    $env = getenv('FR_PRO_SEARCH_ENABLED');
                    $locked = ($env !== false && $env !== '');
                    if ($locked) {
                        $val = strtolower(trim((string)$env));
                        $enabled = in_array($val, ['1', 'true', 'yes', 'on'], true);
                    }
                    return [
                        'enabled'      => $enabled,
                        'defaultLimit' => max(1, min(200, $defaultLimit)),
                        'lockedByEnv'  => $locked,
                    ];
                })(),
            ];

            header('Cache-Control: no-store'); // don’t cache admin config
            echo json_encode(array_merge($public, $adminExtra), JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
            return;
        }

        // Non-admins / unauthenticated: only the public subset
        header('Cache-Control: no-store');
        echo json_encode($public, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
        return;
    }

    public function setEncryptionKey(): void
    {
        header('Content-Type: application/json; charset=utf-8');

        try {
            self::requireAuth();
            self::requireAdmin();
            self::requireCsrf();

            if (!CryptoAtRest::isAvailable()) {
                http_response_code(409);
                echo json_encode([
                    'ok' => false,
                    'error' => 'not_supported',
                    'message' => 'libsodium secretstream is not available on this PHP build.',
                ], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
                return;
            }

            $envRaw = getenv('FR_ENCRYPTION_MASTER_KEY');
            $envPresent = ($envRaw !== false && trim((string)$envRaw) !== '');
            $envValid = $envPresent && (CryptoAtRest::decodeKeyString((string)$envRaw) !== null);
            if ($envValid) {
                http_response_code(409);
                echo json_encode([
                    'ok' => false,
                    'error' => 'locked_by_env',
                    'message' => 'FR_ENCRYPTION_MASTER_KEY is set; admin key file is disabled.',
                ], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
                return;
            }

            $in = self::readJson();
            $action = isset($in['action']) ? trim((string)$in['action']) : '';
            $force = !empty($in['force']);

            $keyFile = rtrim((string)META_DIR, "/\\") . DIRECTORY_SEPARATOR . 'encryption_master.key';
            if (!is_dir(META_DIR)) {
                @mkdir(META_DIR, 0775, true);
            }

            if ($action === 'clear') {
                if (!$force) {
                    $summary = self::folderCryptoSummary();
                    $encCount = (int)($summary['encryptedCount'] ?? 0);
                    $jobCount = (int)($summary['activeJobs'] ?? 0);
                    if ($encCount > 0 || $jobCount > 0) {
                        http_response_code(409);
                        echo json_encode([
                            'ok' => false,
                            'error' => ($jobCount > 0 ? 'crypto_job_active' : 'encrypted_folders_exist'),
                            'message' => 'Encrypted folders or active crypto jobs detected. Decrypt folders before removing the key, or force removal.',
                            'summary' => [
                                'encryptedCount' => $encCount,
                                'activeJobs' => $jobCount,
                            ],
                        ], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
                        return;
                    }

                    $scan = self::scanEncryptedFilesOnDisk();
                    $scanFound = !empty($scan['found']);
                    $scanTruncated = !empty($scan['truncated']);
                    $scanError = (string)($scan['error'] ?? '');
                    if ($scanFound || $scanTruncated || $scanError !== '') {
                        http_response_code(409);
                        echo json_encode([
                            'ok' => false,
                            'error' => $scanFound
                                ? 'encrypted_files_detected'
                                : ($scanError !== '' ? 'encrypted_files_scan_failed' : 'encrypted_files_scan_truncated'),
                            'message' => $scanFound
                                ? 'Encrypted files detected on disk. Decrypt folders before removing the key, or force removal.'
                                : ($scanError !== ''
                                    ? 'Unable to confirm that no encrypted files remain. Fix errors or force removal.'
                                    : 'Scan truncated. Unable to confirm that no encrypted files remain. Force removal only if you accept the risk.'),
                            'summary' => [
                                'encryptedCount' => $encCount,
                                'activeJobs' => $jobCount,
                                'scan' => [
                                    'found' => $scanFound,
                                    'scanned' => (int)($scan['scanned'] ?? 0),
                                    'truncated' => $scanTruncated,
                                    'error' => $scanError,
                                ],
                            ],
                        ], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
                        return;
                    }
                }
                if (is_file($keyFile)) {
                    @unlink($keyFile);
                }
                echo json_encode(['ok' => true, 'cleared' => true], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
                return;
            }

            $keyBin = null;
            if ($action === 'generate') {
                $keyBin = random_bytes((int)SODIUM_CRYPTO_SECRETSTREAM_XCHACHA20POLY1305_KEYBYTES);
            } elseif ($action === 'set') {
                $keyStr = isset($in['key']) ? (string)$in['key'] : '';
                $keyBin = CryptoAtRest::decodeKeyString($keyStr);
                if ($keyBin === null) {
                    http_response_code(400);
                    echo json_encode(['ok' => false, 'error' => 'invalid_key', 'message' => 'Key must be 64 hex chars or base64:... for 32 bytes.']);
                    return;
                }
            } else {
                http_response_code(400);
                echo json_encode(['ok' => false, 'error' => 'invalid_action']);
                return;
            }

            if (!is_string($keyBin) || strlen($keyBin) !== (int)SODIUM_CRYPTO_SECRETSTREAM_XCHACHA20POLY1305_KEYBYTES) {
                http_response_code(500);
                echo json_encode(['ok' => false, 'error' => 'key_gen_failed']);
                return;
            }

            if (@file_put_contents($keyFile, $keyBin, LOCK_EX) === false) {
                http_response_code(500);
                echo json_encode(['ok' => false, 'error' => 'write_failed']);
                return;
            }
            @chmod($keyFile, 0600);

            echo json_encode(['ok' => true, 'written' => true], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
        } catch (Throwable $e) {
            http_response_code(500);
            echo json_encode(['ok' => false, 'error' => 'exception', 'message' => $e->getMessage()]);
        }
    }

    public function rotatePersistentTokensKey(): void
    {
        header('Content-Type: application/json; charset=utf-8');

        try {
            self::requireAuth();
            self::requireAdmin();
            self::requireCsrf();

            $input = self::readJson();
            if (empty($input['confirmRememberMeExpiry']) || empty($input['confirmMaintenanceWindow'])) {
                http_response_code(400);
                echo json_encode([
                    'ok' => false,
                    'error' => 'confirmation_required',
                    'message' => 'Confirm remember-me invalidation and maintenance-window guidance before rotating the persistent tokens key.',
                ], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
                return;
            }

            $result = PersistentTokensKeyRotation::rotateToGeneratedKey();

            $secure = !empty($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off';
            setcookie('remember_me_token', '', time() - 3600, '/', '', $secure, true);

            echo json_encode([
                'ok' => true,
                'message' => 'Persistent tokens key rotated successfully. Stored secrets were re-encrypted and all remember-me sessions were expired.',
                'details' => $result,
            ], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
        } catch (RuntimeException $e) {
            http_response_code(409);
            echo json_encode([
                'ok' => false,
                'error' => 'persistent_tokens_key_rotation_failed',
                'message' => $e->getMessage(),
            ], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
        } catch (Throwable $e) {
            http_response_code(500);
            echo json_encode([
                'ok' => false,
                'error' => 'persistent_tokens_key_rotation_failed',
                'message' => 'Persistent tokens key rotation failed.',
            ], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
            error_log('Persistent tokens key rotation failed: ' . $e->getMessage());
        }
    }

    private static function folderCryptoSummary(): array
    {
        $encryptedCount = 0;
        $activeJobs = 0;

        try {
            $doc = FolderCrypto::load();
            $folders = is_array($doc['folders'] ?? null) ? $doc['folders'] : [];
            foreach ($folders as $row) {
                if (!is_array($row)) {
                    continue;
                }
                if (!empty($row['encrypted'])) {
                    $encryptedCount++;
                }
                if (!empty($row['job']) && is_array($row['job'])) {
                    $state = strtolower((string)($row['job']['state'] ?? ''));
                    if ($state !== '' && $state !== 'done') {
                        $activeJobs++;
                    }
                }
            }
        } catch (\Throwable $e) {
            // best-effort only
        }

        return [
            'encryptedCount' => $encryptedCount,
            'activeJobs' => $activeJobs,
        ];
    }

    private static function scanEncryptedFilesOnDisk(int $limit = 40000): array
    {
        $root = realpath((string)UPLOAD_DIR);
        if ($root === false || !is_dir($root)) {
            return [
                'found' => false,
                'scanned' => 0,
                'truncated' => true,
                'error' => 'upload_dir_unavailable',
            ];
        }

        $skipDirs = ['trash', 'profile_pics', '@eadir'];
        $scanned = 0;
        $truncated = false;

        try {
            $it = new RecursiveIteratorIterator(
                new RecursiveDirectoryIterator($root, FilesystemIterator::SKIP_DOTS),
                RecursiveIteratorIterator::SELF_FIRST
            );

            foreach ($it as $info) {
                if ($scanned >= $limit) {
                    $truncated = true;
                    break;
                }
                if (!$info instanceof SplFileInfo) {
                    continue;
                }

                $name = $info->getFilename();
                if ($name === '' || $name[0] === '.') {
                    continue;
                }
                $lower = strtolower($name);
                if (in_array($lower, $skipDirs, true)) {
                    continue;
                }
                if (str_starts_with($lower, 'resumable_')) {
                    continue;
                }
                if ($info->isDir() || $info->isLink()) {
                    continue;
                }

                $scanned++;
                try {
                    if (CryptoAtRest::isEncryptedFile($info->getPathname())) {
                        return [
                            'found' => true,
                            'scanned' => $scanned,
                            'truncated' => false,
                            'error' => '',
                        ];
                    }
                } catch (\Throwable $e) {
                    // ignore per-file errors
                }
            }
        } catch (\Throwable $e) {
            return [
                'found' => false,
                'scanned' => $scanned,
                'truncated' => true,
                'error' => 'scan_failed',
            ];
        }

        return [
            'found' => false,
            'scanned' => $scanned,
            'truncated' => $truncated,
            'error' => '',
        ];
    }


    public static function virusLog(): void
    {
        header('Content-Type: application/json; charset=utf-8');

        self::requireAdmin();

        // Pro check
        $isProActive = (defined('FR_PRO_ACTIVE') && FR_PRO_ACTIVE);
        if (!$isProActive) {
            http_response_code(403);
            echo json_encode([
                'ok'      => false,
                'error'   => 'pro_required',
                'message' => 'FileRise Pro is not active on this instance.',
            ]);
            return;
        }

        $metaRoot = class_exists('SourceContext')
            ? SourceContext::metaRoot()
            : rtrim((string)META_DIR, '/\\') . DIRECTORY_SEPARATOR;
        $logFile = rtrim($metaRoot, '/\\') . DIRECTORY_SEPARATOR . 'virus_detections.log';
        $limit   = isset($_GET['limit']) ? max(1, (int)$_GET['limit']) : 200;

        $entries = [];
        $hasMore = false;

        if (is_file($logFile) && is_readable($logFile)) {
            $lines = @file($logFile, FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES) ?: [];
            $total = count($lines);

            if ($total > $limit) {
                $hasMore = true;
                $lines   = array_slice($lines, -$limit);
            }

            $lines = array_reverse($lines); // newest first

            foreach ($lines as $line) {
                $rec = json_decode($line, true);
                if (!is_array($rec)) {
                    continue;
                }

                $entries[] = [
                    'ts'       => $rec['ts']       ?? null,
                    'user'     => $rec['user']     ?? 'Unknown',
                    'ip'       => $rec['ip']       ?? 'unknown',
                    'folder'   => $rec['folder']   ?? 'root',
                    'file'     => $rec['file']     ?? '',
                    'source'   => $rec['source']   ?? 'normal',
                    'engine'   => $rec['engine']   ?? '',
                    'exitCode' => $rec['exitCode'] ?? null,
                    'message'  => $rec['message']  ?? '',
                ];
            }
        }

        $format = isset($_GET['format']) ? strtolower((string)$_GET['format']) : 'json';

        // CSV export
        if ($format === 'csv') {
            header_remove('Content-Type');
            header('Content-Type: text/csv; charset=utf-8');
            header('Content-Disposition: attachment; filename="filerise-virus-detections.csv"');

            $out = fopen('php://output', 'wb');
            if ($out === false) {
                http_response_code(500);
                header('Content-Type: application/json; charset=utf-8');
                echo json_encode([
                    'ok'    => false,
                    'error' => 'stream_error',
                ]);
                return;
            }

            fputcsv($out, [
                'timestamp_utc',
                'user',
                'ip',
                'folder',
                'file',
                'source',
                'engine',
                'exitCode',
                'message',
            ]);

            foreach ($entries as $row) {
                fputcsv($out, [
                    $row['ts']       ?? '',
                    $row['user']     ?? '',
                    $row['ip']       ?? '',
                    $row['folder']   ?? '',
                    $row['file']     ?? '',
                    $row['source']   ?? '',
                    $row['engine']   ?? '',
                    (string)($row['exitCode'] ?? ''),
                    $row['message']  ?? '',
                ]);
            }

            fclose($out);
            return;
        }

        // JSON response
        http_response_code(200);
        echo json_encode([
            'ok'      => true,
            'entries' => $entries,
            'hasMore' => $hasMore,
        ], JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE);
    }

    public static function clamavTest(): void
    {
        header('Content-Type: application/json; charset=utf-8');

        self::requireAdmin();

        if (strtoupper($_SERVER['REQUEST_METHOD'] ?? 'GET') !== 'POST') {
            http_response_code(405);
            echo json_encode(['error' => 'Method not allowed.']);
            return;
        }

        $cmd = defined('VIRUS_SCAN_CMD') ? VIRUS_SCAN_CMD : 'clamscan';

        if (!WorkerLauncher::canRunForeground()) {
            http_response_code(200);
            echo json_encode([
                'success' => false,
                'error' => 'ClamAV test is unavailable on this host because PHP command execution (exec) is disabled.',
                'command' => $cmd,
                'engine' => null,
                'details' => 'Enable exec() or run FileRise on a host that allows foreground command execution for ClamAV tests.',
            ]);
            return;
        }

        // engine version (non-fatal)
        $versionRun = WorkerLauncher::runCommand(escapeshellcmd($cmd) . ' --version 2>&1');
        $versionOutput = is_array($versionRun['output'] ?? null) ? $versionRun['output'] : [];
        $engineLine = trim($versionOutput[0] ?? '');
        $engineInfo = $engineLine ?: null;

        // temp file
        $tmpFile = tempnam(sys_get_temp_dir(), 'fr_clamtest_');
        if ($tmpFile === false) {
            http_response_code(500);
            echo json_encode(['error' => 'Unable to create temporary test file.']);
            return;
        }

        file_put_contents($tmpFile, "FileRise ClamAV connectivity test\n");

        $scanCmd = escapeshellcmd($cmd)
            . ' --stdout --no-summary '
            . escapeshellarg($tmpFile)
            . ' 2>&1';

        $scanRun = WorkerLauncher::runCommand($scanCmd);
        $scanOutput = is_array($scanRun['output'] ?? null) ? $scanRun['output'] : [];
        $scanCode = (int)($scanRun['exitCode'] ?? 1);
        @unlink($tmpFile);

        if ($scanCode === 0) {
            echo json_encode([
                'success' => true,
                'command' => $cmd,
                'engine'  => $engineInfo,
                'details' => 'Test file scanned successfully – no malware detected (exit code 0).',
            ]);
            return;
        }

        $raw = trim(implode("\n", $scanOutput));

        http_response_code(200);
        echo json_encode([
            'success' => false,
            'error'   => 'ClamAV returned exit code ' . $scanCode . '. ' .
                'Check that ClamAV is installed and its virus database is up to date.',
            'command' => $cmd,
            'engine'  => $engineInfo,
            'details' => $raw,
        ]);
    }

    public static function resumableCleanup(): void
    {
        header('Content-Type: application/json; charset=utf-8');

        self::requireAdmin();
        self::requireCsrf();

        if (strtoupper($_SERVER['REQUEST_METHOD'] ?? 'GET') !== 'POST') {
            http_response_code(405);
            echo json_encode(['error' => 'Method not allowed.']);
            return;
        }

        $raw = file_get_contents('php://input');
        $payload = json_decode($raw ?: '{}', true);
        if (!is_array($payload)) {
            $payload = $_POST ?? [];
        }

        $all = !empty($payload['all']);
        $purgeAll = !empty($payload['purgeAll']);
        $sourceId = trim((string)($payload['sourceId'] ?? ''));

        if ($sourceId !== '' && !preg_match('/^[A-Za-z0-9_-]{1,64}$/', $sourceId)) {
            http_response_code(400);
            echo json_encode(['error' => 'Invalid source id.']);
            return;
        }

        $totals = ['checked' => 0, 'removed' => 0, 'remaining' => 0];
        $sourcesProcessed = 0;
        $activeId = class_exists('SourceContext') ? SourceContext::getActiveId() : '';

        try {
            if ($all && class_exists('SourceContext') && method_exists('SourceContext', 'listAllSources')) {
                $sources = SourceContext::listAllSources();
                foreach ($sources as $src) {
                    $id = (string)($src['id'] ?? '');
                    if ($id !== '') {
                        SourceContext::setActiveId($id, false, true);
                    }
                    $res = $purgeAll
                        ? UploadModel::purgeResumableAll()
                        : UploadModel::sweepResumableExpired(true);
                    $totals['checked'] += (int)($res['checked'] ?? 0);
                    $totals['removed'] += (int)($res['removed'] ?? 0);
                    $totals['remaining'] += (int)($res['remaining'] ?? 0);
                    $sourcesProcessed++;
                }
            } else {
                if ($sourceId !== '' && class_exists('SourceContext')) {
                    SourceContext::setActiveId($sourceId, false, true);
                }
                $res = $purgeAll
                    ? UploadModel::purgeResumableAll()
                    : UploadModel::sweepResumableExpired(true);
                $totals['checked'] = (int)($res['checked'] ?? 0);
                $totals['removed'] = (int)($res['removed'] ?? 0);
                $totals['remaining'] = (int)($res['remaining'] ?? 0);
                $sourcesProcessed = 1;
            }

            if ($activeId !== '' && class_exists('SourceContext')) {
                SourceContext::setActiveId($activeId, false, true);
            }

            if ($sourcesProcessed === 0) {
                $sourcesProcessed = 1;
            }

            echo json_encode([
                'success'   => true,
                'checked'   => $totals['checked'],
                'removed'   => $totals['removed'],
                'remaining' => $totals['remaining'],
                'sources'   => $sourcesProcessed,
            ], JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE);
        } catch (\Throwable $e) {
            if ($activeId !== '' && class_exists('SourceContext')) {
                SourceContext::setActiveId($activeId, false, true);
            }
            http_response_code(500);
            echo json_encode(['error' => 'Cleanup failed: ' . $e->getMessage()]);
        }
    }

    private static function decodeFrpPayload(string $license): ?array
    {
        $license = trim($license);
        if ($license === '' || stripos($license, 'FRP1.') !== 0) {
            return null;
        }

        $parts = explode('.', $license, 3);
        if (count($parts) !== 3) {
            return null;
        }

        $payloadPart = (string)$parts[1];
        if ($payloadPart === '') {
            return null;
        }

        $base64 = strtr($payloadPart, '-_', '+/');
        $pad = strlen($base64) % 4;
        if ($pad > 0) {
            $base64 .= str_repeat('=', 4 - $pad);
        }

        $json = base64_decode($base64, true);
        if (!is_string($json) || $json === '') {
            return null;
        }

        $payload = json_decode($json, true);
        return is_array($payload) ? $payload : null;
    }

    private static function normalizeFrpInstances(array $payload): array
    {
        $out = [];
        if (isset($payload['instances']) && is_array($payload['instances'])) {
            foreach ($payload['instances'] as $entry) {
                $id = strtolower(trim((string)$entry));
                if ($id !== '' && preg_match('/^[a-f0-9]{32}$/', $id)) {
                    $out[] = $id;
                }
            }
        }
        if (!$out) {
            return [];
        }
        return array_values(array_unique($out));
    }

    private static function tryAutoBindInstance(string $license, string $instanceId): array
    {
        $result = [
            'attempted' => false,
            'bound' => false,
            'changed' => false,
            'message' => '',
            'license' => $license,
        ];

        $license = trim($license);
        $instanceId = strtolower(trim($instanceId));
        if ($license === '' || $instanceId === '' || !preg_match('/^[a-f0-9]{32}$/', $instanceId)) {
            return $result;
        }

        $payload = self::decodeFrpPayload($license);
        if (!is_array($payload)) {
            return $result;
        }

        $plan = strtolower(trim((string)($payload['plan'] ?? '')));
        if (!in_array($plan, ['personal_yearly', 'business_yearly'], true)) {
            return $result;
        }

        $result['attempted'] = true;
        $instances = self::normalizeFrpInstances($payload);
        if (in_array($instanceId, $instances, true)) {
            $result['bound'] = true;
            $result['message'] = 'Instance ID already bound to this license.';
            return $result;
        }

        $endpoint = defined('FR_PRO_BIND_INSTANCE_URL')
            ? trim((string)FR_PRO_BIND_INSTANCE_URL)
            : 'https://filerise.net/pro/bind_instance.php';
        if ($endpoint === '') {
            $result['message'] = 'Auto-bind endpoint is not configured.';
            return $result;
        }

        $postData = http_build_query([
            'license' => $license,
            'instanceId' => $instanceId,
        ]);

        $status = 0;
        $bodyText = '';

        if (function_exists('curl_init')) {
            $ch = curl_init($endpoint);
            curl_setopt($ch, CURLOPT_POST, true);
            curl_setopt($ch, CURLOPT_POSTFIELDS, $postData);
            curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
            curl_setopt($ch, CURLOPT_CONNECTTIMEOUT, 5);
            curl_setopt($ch, CURLOPT_TIMEOUT, 15);
            curl_setopt($ch, CURLOPT_HTTPHEADER, [
                'Content-Type: application/x-www-form-urlencoded',
                'Accept: application/json',
            ]);
            curl_setopt($ch, CURLOPT_USERAGENT, 'FileRise-Core/1.0 (+https://filerise.net)');
            $resp = curl_exec($ch);
            if (is_string($resp)) {
                $bodyText = $resp;
            }
            $status = (int)curl_getinfo($ch, CURLINFO_HTTP_CODE);
            curl_close($ch);
        } else {
            $ctx = stream_context_create([
                'http' => [
                    'method' => 'POST',
                    'header' => implode("\r\n", [
                        'Content-Type: application/x-www-form-urlencoded',
                        'Accept: application/json',
                        'User-Agent: FileRise-Core/1.0 (+https://filerise.net)',
                    ]) . "\r\n",
                    'content' => $postData,
                    'timeout' => 15,
                    'ignore_errors' => true,
                ],
            ]);
            $resp = @file_get_contents($endpoint, false, $ctx);
            if (is_string($resp)) {
                $bodyText = $resp;
            }
            if (isset($http_response_header) && is_array($http_response_header)) {
                foreach ($http_response_header as $line) {
                    if (preg_match('/^HTTP\/\S+\s+(\d{3})/', $line, $m)) {
                        $status = (int)$m[1];
                        break;
                    }
                }
            }
        }

        $body = null;
        if ($bodyText !== '') {
            $decoded = json_decode($bodyText, true);
            if (is_array($decoded)) {
                $body = $decoded;
            }
        }

        if ($status !== 200 || !is_array($body) || empty($body['ok'])) {
            $code = is_array($body) ? trim((string)($body['code'] ?? '')) : '';
            if ($code === 'instance_limit_reached') {
                $result['message'] = 'License instance limit reached. Use filerise.net/pro/instances.php to manage IDs.';
                return $result;
            }
            if ($code === 'license_superseded') {
                $result['message'] = 'This license key has been superseded. Use your latest issued key.';
                return $result;
            }
            $err = is_array($body) ? trim((string)($body['error'] ?? '')) : '';
            $result['message'] = $err !== '' ? $err : 'Instance auto-bind was not completed.';
            return $result;
        }

        $nextLicense = trim((string)($body['license'] ?? ''));
        if ($nextLicense !== '' && stripos($nextLicense, 'FRP1.') === 0) {
            $result['license'] = $nextLicense;
            $result['changed'] = ($nextLicense !== $license);
        }
        $result['bound'] = !empty($body['bound']) || $result['changed'];
        $msg = trim((string)($body['message'] ?? ''));
        if ($msg === '') {
            $msg = $result['changed']
                ? 'Instance ID was bound to this license automatically.'
                : 'License saved.';
        }
        $result['message'] = $msg;
        return $result;
    }

    public function setLicense(): void
    {
        // Always respond JSON
        header('Content-Type: application/json; charset=utf-8');

        try {
            // Same guards as other admin endpoints
            self::requireAuth();
            self::requirePrimaryAdmin();
            self::requireCsrf();

            $raw = file_get_contents('php://input');
            $data = json_decode($raw ?: '{}', true);
            if (!is_array($data)) {
                http_response_code(400);
                echo json_encode(['success' => false, 'error' => 'Invalid JSON body']);
                return;
            }

            $license = isset($data['license']) ? trim((string)$data['license']) : '';
            $instanceId = self::getInstanceId();
            $autoBind = self::tryAutoBindInstance($license, $instanceId);
            if (!empty($autoBind['changed']) && !empty($autoBind['license']) && is_string($autoBind['license'])) {
                $license = trim((string)$autoBind['license']);
            }

            // Store license + updatedAt in JSON file
            if (!defined('PRO_LICENSE_FILE')) {
                // Fallback if constant not defined for some reason
                define('PRO_LICENSE_FILE', rtrim(USERS_DIR, "/\\") . '/proLicense.json');
            }

            $payload = [
                'license'   => $license,
                'updatedAt' => gmdate('c'),
            ];

            $dir = dirname(PRO_LICENSE_FILE);
            if (!is_dir($dir) && !mkdir($dir, 0755, true)) {
                http_response_code(500);
                echo json_encode(['success' => false, 'error' => 'Failed to create license dir']);
                return;
            }

            $json = json_encode($payload, JSON_PRETTY_PRINT);
            if ($json === false || file_put_contents(PRO_LICENSE_FILE, $json) === false) {
                http_response_code(500);
                echo json_encode(['success' => false, 'error' => 'Failed to write license file']);
                return;
            }

            echo json_encode([
                'success' => true,
                'autoBind' => [
                    'attempted' => !empty($autoBind['attempted']),
                    'bound' => !empty($autoBind['bound']),
                    'changed' => !empty($autoBind['changed']),
                    'message' => (string)($autoBind['message'] ?? ''),
                ],
            ]);
        } catch (Throwable $e) {
            http_response_code(500);
            echo json_encode([
                'success' => false,
                'error'   => 'Exception: ' . $e->getMessage(),
            ]);
        }
    }

    public function getProPortals(): array
    {
        if (!defined('FR_PRO_ACTIVE') || !FR_PRO_ACTIVE || !defined('FR_PRO_BUNDLE_DIR') || !FR_PRO_BUNDLE_DIR) {
            throw new RuntimeException('FileRise Pro is not active.');
        }

        $proPortalsPath = rtrim((string)FR_PRO_BUNDLE_DIR, "/\\") . '/ProPortals.php';
        if (!is_file($proPortalsPath)) {
            throw new RuntimeException('ProPortals.php not found in Pro bundle.');
        }

        require_once $proPortalsPath;

        // ProPortals is implemented in the Pro bundle and handles JSON storage.
        $store   = new ProPortals(FR_PRO_BUNDLE_DIR);
        $portals = $store->listPortals();

        return $portals;
    }

    /**
     * @param array $portalsPayload Raw "portals" array from JSON body
     */
    public function saveProPortals(array $portalsPayload): void
    {
        if (!defined('FR_PRO_ACTIVE') || !FR_PRO_ACTIVE || !defined('FR_PRO_BUNDLE_DIR') || !FR_PRO_BUNDLE_DIR) {
            throw new RuntimeException('FileRise Pro is not active.');
        }

        $proPortalsPath = rtrim((string)FR_PRO_BUNDLE_DIR, "/\\") . '/ProPortals.php';
        if (!is_file($proPortalsPath)) {
            throw new RuntimeException('ProPortals.php not found in Pro bundle.');
        }

        require_once $proPortalsPath;

        if (!is_array($portalsPayload)) {
            throw new InvalidArgumentException('Invalid portals format.');
        }

        $store = new ProPortals(FR_PRO_BUNDLE_DIR);
        $existingPortals = $store->listPortals();
        if (!is_array($existingPortals)) {
            $existingPortals = [];
        }

        $sourcesEnabled = class_exists('SourceContext') && SourceContext::sourcesEnabled();
        $existingPortalGrants = self::collectPortalUserGrants($existingPortals, $sourcesEnabled);
        $desiredPortalGrants = [];

        $data                     = ['portals' => []];
        $invalid                  = [];
        $portalUserPlans          = [];
        $portalUserPasswordUpdates = [];
        $portalUsersToDelete      = [];

        $existingSlugs = array_keys($existingPortals);
        $incomingSlugs = array_keys($portalsPayload);
        $removedSlugs = array_diff($existingSlugs, $incomingSlugs);
        foreach ($removedSlugs as $removedSlug) {
            $removedSlug = trim((string)$removedSlug);
            if ($removedSlug === '') {
                continue;
            }
            $existingPortal = is_array($existingPortals[$removedSlug] ?? null) ? $existingPortals[$removedSlug] : [];
            $existingPortalUser = isset($existingPortal['portalUser']) && is_array($existingPortal['portalUser'])
                ? $existingPortal['portalUser']
                : [];
            $portalUserCreate = !array_key_exists('create', $existingPortalUser) || !empty($existingPortalUser['create']);
            if (!$portalUserCreate) {
                continue;
            }
            $existingPortalUserName = trim((string)($existingPortalUser['username'] ?? ''));
            $username = $existingPortalUserName !== '' ? $existingPortalUserName : self::buildPortalUsername($removedSlug);
            if ($username !== '' && self::portalUserExists($username)) {
                $portalUsersToDelete[strtolower($username)] = $username;
            }
        }

        foreach ($portalsPayload as $slug => $info) {
            $slug = trim((string)$slug);

            if (!is_array($info)) {
                $info = [];
            }

            $label  = trim((string)($info['label'] ?? $slug));
            $folder = trim((string)($info['folder'] ?? ''));
            $sourceId = trim((string)($info['sourceId'] ?? ''));

            if ($sourceId !== '' && !preg_match('/^[A-Za-z0-9_-]{1,64}$/', $sourceId)) {
                throw new InvalidArgumentException(
                    'Invalid source id for portal: ' . ($label !== '' ? $label : ($slug !== '' ? $slug : '(unnamed portal)'))
                );
            }
            if ($sourceId !== '' && class_exists('SourceContext') && SourceContext::sourcesEnabled()) {
                $src = SourceContext::getSourceById($sourceId);
                if (!$src) {
                    throw new InvalidArgumentException(
                        'Invalid source for portal: ' . ($label !== '' ? $label : ($slug !== '' ? $slug : '(unnamed portal)'))
                    );
                }
            }

            // Require both slug and folder; collect invalid ones so the UI can warn.
            if ($slug === '' || $folder === '') {
                $invalid[] = $label !== '' ? $label : ($slug !== '' ? $slug : '(unnamed portal)');
                continue;
            }

            $isNewPortal = !array_key_exists($slug, $existingPortals);
            $existingPortal = is_array($existingPortals[$slug] ?? null) ? $existingPortals[$slug] : [];
            $existingPortalUser = isset($existingPortal['portalUser']) && is_array($existingPortal['portalUser'])
                ? $existingPortal['portalUser']
                : [];

            $clientEmail  = trim((string)($info['clientEmail'] ?? ''));
            $hasAllowDownload = array_key_exists('allowDownload', $info);
            $uploadOnly        = !empty($info['uploadOnly']);
            $allowDownload     = $hasAllowDownload ? !empty($info['allowDownload']) : true;
            $aiEnabled         = array_key_exists('aiEnabled', $info)
                ? !empty($info['aiEnabled'])
                : !empty($existingPortal['aiEnabled']);
            // Normalize legacy semantics for portal-user ACLs
            $allowUploadNormalized   = $hasAllowDownload ? $uploadOnly : true;
            $allowDownloadNormalized = $hasAllowDownload ? $allowDownload : !$uploadOnly;
            $expiresAt    = trim((string)($info['expiresAt'] ?? ''));

            $portalUser = isset($info['portalUser']) && is_array($info['portalUser']) ? $info['portalUser'] : [];
            $portalUserCreate = !array_key_exists('create', $portalUser) ? true : !empty($portalUser['create']);
            $portalUserPresetRaw = strtolower(trim((string)($portalUser['preset'] ?? 'match')));
            $portalUserPreset = in_array($portalUserPresetRaw, ['match', 'view_download', 'view_upload', 'upload_only'], true)
                ? $portalUserPresetRaw
                : 'match';
            $portalUserNameInput = trim((string)($portalUser['username'] ?? ''));
            $portalUserName = $portalUserNameInput;
            $existingPortalUserName = trim((string)($existingPortalUser['username'] ?? ''));
            if ($portalUserName === '' && $existingPortalUserName !== '') {
                $portalUserName = $existingPortalUserName;
            }
            $portalUserPassword = trim((string)($portalUser['password'] ?? ''));
            if ($portalUserCreate && $portalUserPassword !== '' && strlen($portalUserPassword) < 6) {
                throw new InvalidArgumentException(
                    'Portal user password must be at least 6 characters for: ' . ($label !== '' ? $label : $slug)
                );
            }
            if ($portalUserCreate && $isNewPortal && $portalUserPassword === '') {
                throw new InvalidArgumentException(
                    'Portal user password is required for: ' . ($label !== '' ? $label : $slug)
                );
            }
            if ($portalUserName !== '' && !preg_match(REGEX_USER, $portalUserName)) {
                throw new InvalidArgumentException(
                    'Invalid portal user name for portal: ' . ($label !== '' ? $label : $slug)
                );
            }
            if ($portalUserCreate && $isNewPortal && $portalUserNameInput !== '' && self::portalUserExists($portalUserName)) {
                throw new InvalidArgumentException(
                    'Portal user already exists: ' . $portalUserName
                );
            }
            $portalUserResolvedName = $portalUserName !== '' ? $portalUserName : self::buildPortalUsername($slug);
            $portalExpired = self::isPortalExpired($expiresAt);

            $portalUserAllowUpload = $allowUploadNormalized;
            $portalUserAllowDownload = $allowDownloadNormalized;
            switch ($portalUserPreset) {
                case 'view_download':
                    $portalUserAllowUpload = false;
                    $portalUserAllowDownload = true;
                    break;
                case 'view_upload':
                    $portalUserAllowUpload = true;
                    $portalUserAllowDownload = true;
                    break;
                case 'upload_only':
                    $portalUserAllowUpload = true;
                    $portalUserAllowDownload = false;
                    break;
            }

            // Branding + form behavior
            $title        = trim((string)($info['title'] ?? ''));
            $introText    = trim((string)($info['introText'] ?? ''));
            $requireForm  = !empty($info['requireForm']);
            $brandColor   = trim((string)($info['brandColor'] ?? ''));
            $footerText   = trim((string)($info['footerText'] ?? ''));

            // Optional logo info
            $logoFile = trim((string)($info['logoFile'] ?? ''));
            $logoUrl  = trim((string)($info['logoUrl']  ?? ''));

            $theme = isset($info['theme']) && is_array($info['theme']) ? $info['theme'] : [];
            $themeLight = isset($theme['light']) && is_array($theme['light']) ? $theme['light'] : [];
            $themeDark = isset($theme['dark']) && is_array($theme['dark']) ? $theme['dark'] : [];
            $theme = [
                'light' => [
                    'bodyBg'  => trim((string)($themeLight['bodyBg'] ?? '')),
                    'surface' => trim((string)($themeLight['surface'] ?? '')),
                    'text'    => trim((string)($themeLight['text'] ?? '')),
                    'muted'   => trim((string)($themeLight['muted'] ?? '')),
                    'border'  => trim((string)($themeLight['border'] ?? '')),
                    'shadow'  => trim((string)($themeLight['shadow'] ?? '')),
                ],
                'dark' => [
                    'bodyBg'  => trim((string)($themeDark['bodyBg'] ?? '')),
                    'surface' => trim((string)($themeDark['surface'] ?? '')),
                    'text'    => trim((string)($themeDark['text'] ?? '')),
                    'muted'   => trim((string)($themeDark['muted'] ?? '')),
                    'border'  => trim((string)($themeDark['border'] ?? '')),
                    'shadow'  => trim((string)($themeDark['shadow'] ?? '')),
                ],
            ];
            $theme['light'] = array_filter($theme['light'], static fn($value) => $value !== '');
            $theme['dark'] = array_filter($theme['dark'], static fn($value) => $value !== '');
            if (empty($theme['light']) && empty($theme['dark'])) {
                $theme = null;
            }

            // Upload rules / thank-you behavior
            $uploadMaxSizeMb    = isset($info['uploadMaxSizeMb']) ? (int)$info['uploadMaxSizeMb'] : 0;
            $uploadExtWhitelist = trim((string)($info['uploadExtWhitelist'] ?? ''));
            $uploadMaxPerDay    = isset($info['uploadMaxPerDay']) ? (int)$info['uploadMaxPerDay'] : 0;
            $allowSubfolders    = !empty($info['allowSubfolders']);
            $showThankYou       = !empty($info['showThankYou']);
            $thankYouShowRef    = !empty($info['thankYouShowRef']);
            $thankYouText       = trim((string)($info['thankYouText'] ?? ''));

            if ($portalUserCreate && !$portalExpired && $portalUserResolvedName !== '') {
                $caps = self::buildPortalUserCaps($portalUserAllowUpload, $portalUserAllowDownload, $allowSubfolders);
                $sourceKey = self::normalizePortalSourceKey($sourceId, $sourcesEnabled);
                self::recordPortalUserGrant($desiredPortalGrants, $sourceKey, $portalUserResolvedName, $folder, $caps);
            }

            $portalUserPasswordSet = false;
            if ($portalUserCreate) {
                $userExists = $portalUserResolvedName !== '' && self::portalUserExists($portalUserResolvedName);
                if ($portalExpired) {
                    if ($userExists) {
                        $portalUsersToDelete[strtolower($portalUserResolvedName)] = $portalUserResolvedName;
                    }
                } else {
                    if ($portalUserPassword !== '') {
                        if ($userExists) {
                            $portalUserPasswordUpdates[] = [
                                'slug'     => $slug,
                                'label'    => $label,
                                'username' => $portalUserResolvedName,
                                'password' => $portalUserPassword,
                            ];
                        } else {
                            $portalUserPlans[] = [
                                'slug'            => $slug,
                                'folder'          => $folder,
                                'sourceId'        => $sourceId,
                                'allowUpload'     => $portalUserAllowUpload,
                                'allowDownload'   => $portalUserAllowDownload,
                                'allowSubfolders' => $allowSubfolders,
                                'username'        => $portalUserResolvedName,
                                'password'        => $portalUserPassword,
                                'usernameExplicit' => ($portalUserNameInput !== ''),
                            ];
                        }
                        $portalUserPasswordSet = true;
                    } else {
                        $portalUserPasswordSet = $userExists;
                    }
                }
            }

            // Form defaults
            $formDefaults = isset($info['formDefaults']) && is_array($info['formDefaults'])
                ? $info['formDefaults']
                : [];

            $formDefaults = [
                'name'      => trim((string)($formDefaults['name'] ?? '')),
                'email'     => trim((string)($formDefaults['email'] ?? '')),
                'reference' => trim((string)($formDefaults['reference'] ?? '')),
                'notes'     => trim((string)($formDefaults['notes'] ?? '')),
            ];

            // Required flags
            $formRequired = isset($info['formRequired']) && is_array($info['formRequired'])
                ? $info['formRequired']
                : [];

            $formRequired = [
                'name'      => !empty($formRequired['name']),
                'email'     => !empty($formRequired['email']),
                'reference' => !empty($formRequired['reference']),
                'notes'     => !empty($formRequired['notes']),
            ];

            // Labels
            $formLabels = isset($info['formLabels']) && is_array($info['formLabels'])
                ? $info['formLabels']
                : [];

            $formLabels = [
                'name'      => trim((string)($formLabels['name'] ?? 'Name')),
                'email'     => trim((string)($formLabels['email'] ?? 'Email')),
                'reference' => trim((string)($formLabels['reference'] ?? 'Reference / Case / Order #')),
                'notes'     => trim((string)($formLabels['notes'] ?? 'Notes')),
            ];

            // Visibility
            $formVisible = isset($info['formVisible']) && is_array($info['formVisible'])
                ? $info['formVisible']
                : [];

            $formVisible = [
                'name'      => !array_key_exists('name', $formVisible)      || !empty($formVisible['name']),
                'email'     => !array_key_exists('email', $formVisible)     || !empty($formVisible['email']),
                'reference' => !array_key_exists('reference', $formVisible) || !empty($formVisible['reference']),
                'notes'     => !array_key_exists('notes', $formVisible)     || !empty($formVisible['notes']),
            ];

            $portalUserStored = [
                'create'      => $portalUserCreate,
                'preset'      => $portalUserPreset,
                'username'    => $portalUserCreate ? $portalUserResolvedName : $portalUserName,
                'passwordSet' => $portalUserPasswordSet,
            ];

            $portalData = [
                'label'              => $label,
                'folder'             => $folder,
                'sourceId'           => $sourceId,
                'clientEmail'        => $clientEmail,
                'aiEnabled'          => $aiEnabled,
                'uploadOnly'         => $uploadOnly,
                'allowDownload'      => $allowDownload,
                'expiresAt'          => $expiresAt,
                'title'              => $title,
                'introText'          => $introText,
                'requireForm'        => $requireForm,
                'brandColor'         => $brandColor,
                'footerText'         => $footerText,
                'logoFile'           => $logoFile,
                'logoUrl'            => $logoUrl,
                'uploadMaxSizeMb'    => $uploadMaxSizeMb,
                'uploadExtWhitelist' => $uploadExtWhitelist,
                'uploadMaxPerDay'    => $uploadMaxPerDay,
                'allowSubfolders'    => $allowSubfolders,
                'showThankYou'       => $showThankYou,
                'thankYouShowRef'    => $thankYouShowRef,
                'thankYouText'       => $thankYouText,
                'portalUser'         => $portalUserStored,
                'formDefaults'       => $formDefaults,
                'formRequired'       => $formRequired,
                'formLabels'         => $formLabels,
                'formVisible'        => $formVisible,
            ];
            if ($theme !== null) {
                $portalData['theme'] = $theme;
            }
            $data['portals'][$slug] = $portalData;
        }
        if (!empty($invalid)) {
            throw new InvalidArgumentException(
                'One or more portals are missing a slug or folder: ' . implode(', ', $invalid)
            );
        }

        if (!empty($portalUsersToDelete)) {
            foreach ($portalUsersToDelete as $username) {
                self::deletePortalUserIfExists($username);
            }
        }

        $createdUsers = [];
        if (!empty($portalUserPlans)) {
            try {
                self::createPortalUsers($portalUserPlans, $createdUsers);
            } catch (\Throwable $e) {
                self::rollbackPortalUsers($createdUsers);
                throw $e;
            }
        }

        if (!empty($portalUserPasswordUpdates)) {
            foreach ($portalUserPasswordUpdates as $update) {
                $username = (string)($update['username'] ?? '');
                $password = (string)($update['password'] ?? '');
                $label    = (string)($update['label'] ?? '');
                $res = UserModel::adminResetPassword($username, $password);
                if (isset($res['error'])) {
                    self::rollbackPortalUsers($createdUsers);
                    $ctx = $label !== '' ? $label : $username;
                    throw new RuntimeException('Failed to update portal user password for ' . $ctx . ': ' . $res['error']);
                }
            }
        }

        $ok    = $store->savePortals($data);

        if (!$ok) {
            self::rollbackPortalUsers($createdUsers);
            throw new RuntimeException('Could not write portals.json');
        }

        self::syncPortalUserGrants($existingPortalGrants, $desiredPortalGrants, $sourcesEnabled);

        return;
    }

    private static function normalizePortalSourceKey(string $sourceId, bool $sourcesEnabled): string
    {
        if (!$sourcesEnabled) {
            return 'local';
        }
        $id = trim($sourceId);
        if ($id === '') {
            return 'local';
        }
        if (!preg_match('/^[A-Za-z0-9_-]{1,64}$/', $id)) {
            return '';
        }
        return $id;
    }

    private static function buildPortalUserCaps(bool $allowUpload, bool $allowDownload, bool $allowSubfolders): array
    {
        $caps = [];
        if ($allowDownload) {
            $caps['view'] = true;
        }
        if ($allowUpload) {
            $caps['upload'] = true;
        }
        if ($allowSubfolders) {
            $caps['inherit'] = true;
        }
        if (!empty($caps)) {
            $caps['explicit'] = true;
        }
        return $caps;
    }

    private static function recordPortalUserGrant(
        array &$grants,
        string $sourceKey,
        string $username,
        string $folder,
        array $caps
    ): void {
        $username = trim($username);
        if ($username === '' || $sourceKey === '') {
            return;
        }
        $folder = ACL::normalizeFolder($folder);
        if ($folder === '') {
            return;
        }
        if (!isset($grants[$sourceKey]) || !is_array($grants[$sourceKey])) {
            $grants[$sourceKey] = [];
        }
        if (!isset($grants[$sourceKey][$username]) || !is_array($grants[$sourceKey][$username])) {
            $grants[$sourceKey][$username] = [];
        }
        $grants[$sourceKey][$username][$folder] = $caps;
    }

    private static function collectPortalUserGrants(array $portals, bool $sourcesEnabled): array
    {
        $grants = [];
        foreach ($portals as $slug => $info) {
            $slug = trim((string)$slug);
            if (!is_array($info)) {
                $info = [];
            }
            $folder = trim((string)($info['folder'] ?? ''));
            if ($folder === '') {
                continue;
            }

            $portalUser = isset($info['portalUser']) && is_array($info['portalUser']) ? $info['portalUser'] : [];
            $portalUserCreate = !array_key_exists('create', $portalUser) || !empty($portalUser['create']);
            if (!$portalUserCreate) {
                continue;
            }

            $portalUserName = trim((string)($portalUser['username'] ?? ''));
            $portalUserResolvedName = $portalUserName !== '' ? $portalUserName : ($slug !== '' ? self::buildPortalUsername($slug) : '');
            if ($portalUserResolvedName === '') {
                continue;
            }

            $portalUserPresetRaw = strtolower(trim((string)($portalUser['preset'] ?? 'match')));
            $portalUserPreset = in_array($portalUserPresetRaw, ['match', 'view_download', 'view_upload', 'upload_only'], true)
                ? $portalUserPresetRaw
                : 'match';

            $hasAllowDownload = array_key_exists('allowDownload', $info);
            $uploadOnly        = !empty($info['uploadOnly']);
            $allowDownload     = $hasAllowDownload ? !empty($info['allowDownload']) : true;
            $allowUploadNormalized   = $hasAllowDownload ? $uploadOnly : true;
            $allowDownloadNormalized = $hasAllowDownload ? $allowDownload : !$uploadOnly;

            $portalUserAllowUpload = $allowUploadNormalized;
            $portalUserAllowDownload = $allowDownloadNormalized;
            switch ($portalUserPreset) {
                case 'view_download':
                    $portalUserAllowUpload = false;
                    $portalUserAllowDownload = true;
                    break;
                case 'view_upload':
                    $portalUserAllowUpload = true;
                    $portalUserAllowDownload = true;
                    break;
                case 'upload_only':
                    $portalUserAllowUpload = true;
                    $portalUserAllowDownload = false;
                    break;
            }

            $allowSubfolders = !empty($info['allowSubfolders']);
            $caps = self::buildPortalUserCaps($portalUserAllowUpload, $portalUserAllowDownload, $allowSubfolders);
            $sourceKey = self::normalizePortalSourceKey((string)($info['sourceId'] ?? ''), $sourcesEnabled);
            self::recordPortalUserGrant($grants, $sourceKey, $portalUserResolvedName, $folder, $caps);
        }
        return $grants;
    }

    private static function syncPortalUserGrants(array $previous, array $desired, bool $sourcesEnabled): void
    {
        $sourceKeys = array_unique(array_merge(array_keys($previous), array_keys($desired)));
        if (empty($sourceKeys)) {
            return;
        }

        foreach ($sourceKeys as $sourceKey) {
            $prevUsers = isset($previous[$sourceKey]) && is_array($previous[$sourceKey])
                ? $previous[$sourceKey]
                : [];
            $desiredUsers = isset($desired[$sourceKey]) && is_array($desired[$sourceKey])
                ? $desired[$sourceKey]
                : [];
            $users = array_unique(array_merge(array_keys($prevUsers), array_keys($desiredUsers)));
            if (empty($users)) {
                continue;
            }

            $runner = function () use ($users, $prevUsers, $desiredUsers): void {
                foreach ($users as $user) {
                    $user = (string)$user;
                    if ($user === '') {
                        continue;
                    }
                    $prevFolders = isset($prevUsers[$user]) && is_array($prevUsers[$user]) ? $prevUsers[$user] : [];
                    $desiredFolders = isset($desiredUsers[$user]) && is_array($desiredUsers[$user]) ? $desiredUsers[$user] : [];
                    $folders = array_unique(array_merge(array_keys($prevFolders), array_keys($desiredFolders)));
                    if (empty($folders)) {
                        continue;
                    }
                    $grantMap = [];
                    foreach ($folders as $folder) {
                        $caps = isset($desiredFolders[$folder]) && is_array($desiredFolders[$folder])
                            ? $desiredFolders[$folder]
                            : [];
                        $grantMap[$folder] = $caps;
                    }

                    $res = ACL::applyUserGrantsAtomic($user, $grantMap);
                    if (!is_array($res) || empty($res['ok'])) {
                        throw new RuntimeException('Failed to apply portal user access.');
                    }
                }
            };

            if ($sourcesEnabled) {
                $targetId = $sourceKey !== '' ? $sourceKey : 'local';
                $prev = SourceContext::getActiveId();
                SourceContext::setActiveId($targetId, false, true);
                try {
                    $runner();
                } finally {
                    SourceContext::setActiveId($prev, false);
                }
            } else {
                $runner();
            }
        }
    }

    private static function isPortalExpired(string $expiresAt): bool
    {
        $expiresAt = trim($expiresAt);
        if ($expiresAt === '') {
            return false;
        }
        $ts = strtotime($expiresAt . ' 23:59:59');
        if ($ts === false) {
            return false;
        }
        return time() > $ts;
    }

    private static function buildPortalUsername(string $slug): string
    {
        $slug = trim($slug);
        $clean = preg_replace('/[^A-Za-z0-9_-]+/', '-', $slug);
        $clean = trim((string)$clean, '-_');
        $cleanLower = strtolower($clean);
        if (strpos($cleanLower, 'portal-') === 0) {
            $cleanLower = substr($cleanLower, 7);
        } elseif ($cleanLower === 'portal') {
            $cleanLower = '';
        }
        if ($cleanLower === '') {
            $cleanLower = 'user-' . substr(sha1($slug . bin2hex(random_bytes(4))), 0, 8);
        }

        $username = 'portal_' . $cleanLower;
        if (!preg_match(REGEX_USER, $username)) {
            $username = 'portal_' . substr(sha1($slug . bin2hex(random_bytes(6))), 0, 12);
        }

        return $username;
    }

    private static function deletePortalUserIfExists(string $username): void
    {
        $username = trim($username);
        if ($username === '' || !preg_match(REGEX_USER, $username)) {
            return;
        }
        $result = UserModel::removeUser($username);
        if (isset($result['error']) && $result['error'] !== 'User not found') {
            throw new RuntimeException('Failed to delete portal user ' . $username . ': ' . $result['error']);
        }
    }

    private static function portalUserExists(string $username): bool
    {
        $info = UserModel::getUser($username);
        return !empty($info);
    }

    /**
     * @param array<int,array<string,mixed>> $plans
     * @param array<int,string> $createdUsers
     */
    private static function createPortalUsers(array $plans, array &$createdUsers): void
    {
        foreach ($plans as $plan) {
            $slug = trim((string)($plan['slug'] ?? ''));
            if ($slug === '') {
                continue;
            }

            $username = trim((string)($plan['username'] ?? ''));
            $usernameExplicit = !empty($plan['usernameExplicit']);
            if ($username === '') {
                $username = self::buildPortalUsername($slug);
            }
            if ($username === '' || !preg_match(REGEX_USER, $username)) {
                throw new RuntimeException('Invalid portal user name for ' . $slug . '.');
            }
            if (self::portalUserExists($username)) {
                if ($usernameExplicit) {
                    throw new RuntimeException('Portal user already exists: ' . $username);
                }
                continue;
            }

            $password = trim((string)($plan['password'] ?? ''));
            if ($password === '') {
                throw new RuntimeException('Portal user password is required for ' . $slug . '.');
            }
            $result = UserModel::addUser($username, $password, '0', false);
            if (isset($result['error'])) {
                throw new RuntimeException('Failed to create portal user for ' . $slug . ': ' . $result['error']);
            }

            $createdUsers[] = $username;

            $folder = trim((string)($plan['folder'] ?? ''));
            if ($folder !== '') {
                self::applyPortalUserGrants(
                    $username,
                    $folder,
                    !empty($plan['allowUpload']),
                    !empty($plan['allowDownload']),
                    !empty($plan['allowSubfolders']),
                    (string)($plan['sourceId'] ?? '')
                );
            }
        }
    }

    /**
     * @param array<int,string> $users
     */
    private static function rollbackPortalUsers(array $users): void
    {
        foreach ($users as $user) {
            $user = trim((string)$user);
            if ($user === '') {
                continue;
            }
            try {
                UserModel::removeUser($user);
            } catch (\Throwable $e) {
                error_log('portal user rollback failed for ' . $user . ': ' . $e->getMessage());
            }
        }
    }

    private static function applyPortalUserGrants(
        string $username,
        string $folder,
        bool $allowUpload,
        bool $allowDownload,
        bool $allowSubfolders,
        string $sourceId
    ): void {
        $caps = [];
        if ($allowDownload) {
            $caps['view'] = true;
        }
        if ($allowUpload) {
            $caps['upload'] = true;
        }
        if ($allowSubfolders) {
            $caps['inherit'] = true;
        }
        if (empty($caps)) {
            return;
        }
        $caps['explicit'] = true;

        $runner = function () use ($username, $folder, $caps): void {
            $res = ACL::applyUserGrantsAtomic($username, [$folder => $caps]);
            if (!is_array($res) || empty($res['ok'])) {
                throw new RuntimeException('Failed to apply portal user access.');
            }
        };

        if (class_exists('SourceContext') && SourceContext::sourcesEnabled()) {
            $targetId = $sourceId !== '' ? $sourceId : 'local';
            $prev = SourceContext::getActiveId();
            SourceContext::setActiveId($targetId, false, true);
            try {
                $runner();
            } finally {
                SourceContext::setActiveId($prev, false);
            }
            return;
        }

        $runner();
    }

    public function getProGroups(): array
    {
        if (!defined('FR_PRO_ACTIVE') || !FR_PRO_ACTIVE || !defined('FR_PRO_BUNDLE_DIR') || !FR_PRO_BUNDLE_DIR) {
            throw new RuntimeException('FileRise Pro is not active.');
        }

        $proGroupsPath = rtrim((string)FR_PRO_BUNDLE_DIR, "/\\") . '/ProGroups.php';
        if (!is_file($proGroupsPath)) {
            throw new RuntimeException('ProGroups.php not found in Pro bundle.');
        }

        require_once $proGroupsPath;

        $store  = new ProGroups(FR_PRO_BUNDLE_DIR);
        $groups = $store->listGroups();

        return $groups;
    }

    /**
     * @param array $groupsPayload Raw "groups" array from JSON body
     */
    public function saveProGroups(array $groupsPayload): void
    {
        if (!defined('FR_PRO_ACTIVE') || !FR_PRO_ACTIVE || !defined('FR_PRO_BUNDLE_DIR') || !FR_PRO_BUNDLE_DIR) {
            throw new RuntimeException('FileRise Pro is not active.');
        }

        $proGroupsPath = rtrim((string)FR_PRO_BUNDLE_DIR, "/\\") . '/ProGroups.php';
        if (!is_file($proGroupsPath)) {
            throw new RuntimeException('ProGroups.php not found in Pro bundle.');
        }

        require_once $proGroupsPath;

        // Normalize / validate the payload into the canonical structure
        if (!is_array($groupsPayload)) {
            throw new InvalidArgumentException('Invalid groups format.');
        }

        $data = ['groups' => []];

        foreach ($groupsPayload as $name => $info) {
            $name = trim((string)$name);
            if ($name === '') {
                continue;
            }

            $label   = isset($info['label']) ? trim((string)$info['label']) : $name;
            $members = isset($info['members']) && is_array($info['members']) ? $info['members'] : [];
            $grants  = isset($info['grants']) && is_array($info['grants']) ? $info['grants'] : [];
            $grantsBySource = isset($info['grantsBySource']) && is_array($info['grantsBySource'])
                ? $info['grantsBySource']
                : null;

            $entry = [
                'name'    => $name,
                'label'   => $label,
                'members' => array_values(array_unique(array_map('strval', $members))),
                'grants'  => $grants,
            ];
            if ($grantsBySource !== null) {
                $entry['grantsBySource'] = $grantsBySource;
            }

            $data['groups'][$name] = $entry;
        }

        $store = new ProGroups(FR_PRO_BUNDLE_DIR);
        if (!$store->save($data)) {
            throw new RuntimeException('Could not write groups.json');
        }
    }


    public function testOidcConfig(array $payload): array
    {
        // 1) Resolve providerUrl:
        //    - Prefer payload (what you type into the Admin UI)
        //    - Fall back to saved admin config
        //    - Fall back to env/constant (if you use env-based config)
        $providerUrl = trim((string)($payload['providerUrl'] ?? ''));

        // Try admin config if not provided in payload
        if ($providerUrl === '') {
            if (!class_exists(AdminModel::class)) {
            }
            $model  = new AdminModel();
            $config = $model->getConfig();
            $providerUrl = trim((string)($config['oidc']['providerUrl'] ?? ''));
        }

        // Try constant from config.php (if you use one)
        if ($providerUrl === '' && defined('OIDC_PROVIDER_URL')) {
            $providerUrl = trim((string)OIDC_PROVIDER_URL);
        }

        if ($providerUrl === '') {
            return [
                'success' => false,
                'error'   => 'No OIDC provider URL configured.'
            ];
        }


        // 2) Normalize discovery URL
        $base = rtrim($providerUrl, '/');

        // If they pasted a .well-known URL directly, don't append anything
        if (stripos($base, '/.well-known/') !== false) {
            $discoveryUrl = $base;
        } else {
            // Works with Keycloak:
            //   https://auth.example.com/realms/yourrealm
            // → https://auth.example.com/realms/yourrealm/.well-known/openid-configuration
            $discoveryUrl = $base . '/.well-known/openid-configuration';
        }

        // 3) Fetch discovery document
        $ctx = stream_context_create([
            'http' => [
                'timeout' => 5,
            ],
            'https' => [
                'timeout' => 5,
            ],
        ]);

        $raw = @file_get_contents($discoveryUrl, false, $ctx);
        if ($raw === false) {
            $err = error_get_last();
            return [
                'success'      => false,
                'error'        => 'Failed to fetch discovery document from provider.',
                'discoveryUrl' => $discoveryUrl,
                'phpError'     => $err['message'] ?? null,
            ];
        }

        $json = json_decode($raw, true);
        if (!is_array($json)) {
            return [
                'success'      => false,
                'error'        => 'Discovery document is not valid JSON.',
                'discoveryUrl' => $discoveryUrl,
            ];
        }

        $issuer  = (string)($json['issuer'] ?? '');
        $auth    = (string)($json['authorization_endpoint'] ?? '');
        $token   = (string)($json['token_endpoint'] ?? '');
        $userinfo = (string)($json['userinfo_endpoint'] ?? '');

        if ($issuer === '' || $auth === '') {
            return [
                'success'      => false,
                'error'        => 'Discovery document is missing issuer or authorization_endpoint.',
                'discoveryUrl' => $discoveryUrl,
                'jsonSample'   => [
                    'issuer'                 => $json['issuer'] ?? null,
                    'authorization_endpoint' => $json['authorization_endpoint'] ?? null,
                ],
            ];
        }

        // Shape the response exactly how your JS expects it:
        return [
            'success'                => true,
            'providerUrl'            => $providerUrl,
            'discoveryUrl'           => $discoveryUrl,
            'issuer'                 => $issuer,
            'authorization_endpoint' => $auth,
            'token_endpoint'         => $token,
            'userinfo_endpoint'      => $userinfo,
            // optional warnings array, your JS will just log them
            'warnings'               => [],
        ];
    }

    private function installProBundleFromZip(string $zipPath, string $origName = '', ?string $workDir = null): array
    {
        $declaredVersion = null;
        $basename = '';
        $zip = null;
        $zipOpened = false;

        $cleanup = function () use ($zipPath, $workDir): void {
            if ($zipPath !== '' && @is_file($zipPath)) {
                @unlink($zipPath);
            }
            if ($workDir) {
                @rmdir($workDir);
            }
        };

        try {
            if (!@is_file($zipPath)) {
                throw new RuntimeException('ZIP bundle not found.', 400);
            }

            if ($origName !== '' && !preg_match('/\.zip$/i', $origName)) {
                throw new RuntimeException('Bundle must be a .zip file.', 400);
            }

            if ($origName !== '') {
                // Normalize to basename so C:\fakepath\FileRisePro-v1.2.1.zip works.
                $basename = str_replace('\\', '/', $origName);
                $basename = basename($basename);
            }

            // Try to parse the bundle version from the *basename*
            // Supports: FileRisePro-v1.2.3.zip or FileRisePro_1.2.3.zip (case-insensitive)
            if (
                $basename !== '' &&
                preg_match(
                    '/^FileRisePro[_-]v?([0-9]+\.[0-9]+\.[0-9]+)\.zip$/i',
                    $basename,
                    $m
                )
            ) {
                $declaredVersion = 'v' . $m[1];
            }

            $zip = new \ZipArchive();
            if ($zip->open($zipPath) !== true) {
                throw new RuntimeException('Failed to open ZIP bundle.', 400);
            }
            $zipOpened = true;

            $installed = [
                'src'    => [],
                'docs'   => [],
            ];

            // Where Pro bundle code lives (defaults to USERS_DIR . '/pro')
            $bundleRoot = defined('FR_PRO_BUNDLE_DIR')
                ? rtrim(FR_PRO_BUNDLE_DIR, DIRECTORY_SEPARATOR)
                : (rtrim(USERS_DIR, "/\\") . DIRECTORY_SEPARATOR . 'pro');

            // Put README-Pro.txt / LICENSE-Pro.txt inside the bundle dir as well
            $proDocsDir = $bundleRoot;
            if (!is_dir($proDocsDir)) {
                @mkdir($proDocsDir, 0755, true);
            }

            // Core Pro classes live inside the bundle dir (./Pro)
            $proCoreRoot = $bundleRoot . DIRECTORY_SEPARATOR . 'Pro';

            $allowedTopLevel = ['LICENSE-Pro.txt', 'README-Pro.txt'];

            // Iterate entries and selectively extract/copy expected files only
            for ($i = 0; $i < $zip->numFiles; $i++) {
                $name = $zip->getNameIndex($i);
                if ($name === false) {
                    continue;
                }

                // Normalise and guard
                $name = ltrim($name, "/\\");
                if ($name === '' || substr($name, -1) === '/') {
                    continue; // skip directories
                }
                if (strpos($name, '../') !== false || strpos($name, '..\\') !== false) {
                    continue; // path traversal guard
                }

                // Ignore macOS Finder junk: __MACOSX and "._" resource forks
                $base = basename($name);
                if (
                    str_starts_with($name, '__MACOSX/') ||
                    str_contains($name, '/__MACOSX/') ||
                    str_starts_with($base, '._')
                ) {
                    continue;
                }

                $targetPath = null;
                $category   = null;

                if (in_array($name, $allowedTopLevel, true)) {
                    // Docs → bundle dir (under /users/pro)
                    $targetPath = $proDocsDir . DIRECTORY_SEPARATOR . $name;
                    $category   = 'docs';
                } elseif (strpos($name, 'src/pro/') === 0) {
                    // e.g. src/pro/bootstrap_pro.php -> FR_PRO_BUNDLE_DIR/bootstrap_pro.php
                    $relative = substr($name, strlen('src/pro/'));
                    if ($relative === '' || substr($relative, -1) === '/') {
                        continue;
                    }
                    $targetPath = $bundleRoot . DIRECTORY_SEPARATOR . $relative;
                    $category   = 'src';
                } elseif (strpos($name, 'src/FileRisePro/') === 0) {
                    // e.g. src/FileRisePro/Pro/ProGroups.php -> <bundle>/Pro/ProGroups.php
                    $relative = substr($name, strlen('src/FileRisePro/'));
                    if ($relative === '' || substr($relative, -1) === '/') {
                        continue;
                    }
                    $targetPath = $proCoreRoot . DIRECTORY_SEPARATOR . $relative;
                    $category   = 'src';
                } else {
                    // Skip anything outside these prefixes
                    continue;
                }

                if (!$targetPath || !$category) {
                    continue;
                }

                // Track whether we're overwriting an existing file (for reporting only)
                $wasExisting = is_file($targetPath);

                // Read from ZIP entry
                $stream = $zip->getStream($name);
                if (!$stream) {
                    continue;
                }

                $dir = dirname($targetPath);
                if (!is_dir($dir) && !@mkdir($dir, 0755, true)) {
                    fclose($stream);
                    throw new RuntimeException('Failed to create destination directory for ' . $name, 500);
                }

                $data = stream_get_contents($stream);
                fclose($stream);
                if ($data === false) {
                    throw new RuntimeException('Failed to read data for ' . $name, 500);
                }

                // Keep Pro classes co-located: rewrite shims to load from ./Pro instead of ../FileRisePro/Pro
                if ($category === 'src' && strpos($name, 'src/pro/') === 0) {
                    $data = str_replace('../FileRisePro/Pro/', 'Pro/', $data);
                }

                // Always overwrite target file on install/upgrade
                if (@file_put_contents($targetPath, $data) === false) {
                    throw new RuntimeException('Failed to write ' . $name, 500);
                }

                @chmod($targetPath, 0644);

                // Track what we installed (and whether it was overwritten)
                if (!isset($installed[$category])) {
                    $installed[$category] = [];
                }
                $installed[$category][] = $targetPath . ($wasExisting ? ' (overwritten)' : '');
            }

            $zip->close();
            $zipOpened = false;

            $cleanup();

            // Ensure OPcache picks up new Pro bundle code immediately
            if (function_exists('opcache_invalidate')) {
                foreach ($installed['src'] as $pathInfo) {
                    // strip " (overwritten)" suffix if present
                    $path = preg_replace('/\s+\(overwritten\)$/', '', $pathInfo);
                    if (is_string($path) && $path !== '' && is_file($path)) {
                        @opcache_invalidate($path, true);
                    }
                }
            }

            // Reflect current Pro status in response if bootstrap was loaded
            $proActive = defined('FR_PRO_ACTIVE') && FR_PRO_ACTIVE;

            $reportedVersion = $declaredVersion;
            if ($reportedVersion === null && defined('FR_PRO_BUNDLE_VERSION')) {
                $reportedVersion = FR_PRO_BUNDLE_VERSION;
            }

            $proPayload = defined('FR_PRO_INFO') && is_array(FR_PRO_INFO)
                ? (FR_PRO_INFO['payload'] ?? null)
                : null;

            return [
                'status' => 200,
                'body'   => [
                    'success'    => true,
                    'message'    => 'Pro bundle installed.',
                    'installed'  => $installed,
                    'proActive'  => (bool)$proActive,
                    'proVersion' => $reportedVersion,
                    'proPayload' => $proPayload,
                ],
            ];
        } catch (\Throwable $e) {
            if ($zipOpened && $zip instanceof \ZipArchive) {
                $zip->close();
            }
            $cleanup();
            $code = (int)$e->getCode();
            $status = ($code >= 400 && $code < 600) ? $code : 500;
            return [
                'status' => $status,
                'body'   => [
                    'success' => false,
                    'error'   => $e->getMessage(),
                ],
            ];
        }
    }

    public function installProBundle(): void
    {
        header('Content-Type: application/json; charset=utf-8');

        try {
            // Guard rails: method + auth + CSRF
            if (($_SERVER['REQUEST_METHOD'] ?? 'GET') !== 'POST') {
                http_response_code(405);
                echo json_encode(['success' => false, 'error' => 'Method not allowed']);
                return;
            }

            self::requireAuth();
            self::requirePrimaryAdmin();
            self::requireCsrf();

            // Ensure ZipArchive is available
            if (!class_exists('\\ZipArchive')) {
                http_response_code(500);
                echo json_encode(['success' => false, 'error' => 'ZipArchive extension is required on the server.']);
                return;
            }

            // Basic upload validation
            if (empty($_FILES['bundle']) || !is_array($_FILES['bundle'])) {
                http_response_code(400);
                echo json_encode(['success' => false, 'error' => 'Missing uploaded bundle (field "bundle").']);
                return;
            }

            $f = $_FILES['bundle'];

            if (!empty($f['error']) && $f['error'] !== UPLOAD_ERR_OK) {
                $msg = 'Upload error.';
                switch ($f['error']) {
                    case UPLOAD_ERR_INI_SIZE:
                    case UPLOAD_ERR_FORM_SIZE:
                        $msg = 'Uploaded file exceeds size limit.';
                        break;
                    case UPLOAD_ERR_PARTIAL:
                        $msg = 'Uploaded file was only partially received.';
                        break;
                    case UPLOAD_ERR_NO_FILE:
                        $msg = 'No file was uploaded.';
                        break;
                    default:
                        $msg = 'Upload failed with error code ' . (int)$f['error'];
                        break;
                }
                http_response_code(400);
                echo json_encode(['success' => false, 'error' => $msg]);
                return;
            }

            $tmpName = $f['tmp_name'] ?? '';
            if ($tmpName === '' || !is_uploaded_file($tmpName)) {
                http_response_code(400);
                echo json_encode(['success' => false, 'error' => 'Invalid uploaded file.']);
                return;
            }

            // Guard against unexpectedly large bundles (e.g., >100MB)
            $size = isset($f['size']) ? (int)$f['size'] : 0;
            if ($size <= 0 || $size > 100 * 1024 * 1024) {
                http_response_code(413);
                echo json_encode(['success' => false, 'error' => 'Bundle size is invalid or too large (max 100MB).']);
                return;
            }

            // Prepare temp working dir
            $tempRoot = rtrim(sys_get_temp_dir(), DIRECTORY_SEPARATOR);
            $workDir  = $tempRoot . DIRECTORY_SEPARATOR . 'filerise_pro_' . bin2hex(random_bytes(8));
            if (!@mkdir($workDir, 0700, true)) {
                http_response_code(500);
                echo json_encode(['success' => false, 'error' => 'Failed to prepare temp dir.']);
                return;
            }

            $zipPath = $workDir . DIRECTORY_SEPARATOR . 'bundle.zip';
            if (!@move_uploaded_file($tmpName, $zipPath)) {
                http_response_code(500);
                echo json_encode(['success' => false, 'error' => 'Failed to move uploaded bundle.']);
                return;
            }
            $origName = (string)($f['name'] ?? '');
            $result = $this->installProBundleFromZip($zipPath, $origName, $workDir);
            http_response_code($result['status']);
            echo json_encode($result['body'], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
        } catch (\Throwable $e) {
            http_response_code(500);
            echo json_encode([
                'success' => false,
                'error'   => 'Exception during bundle install: ' . $e->getMessage(),
            ]);
        }
    }

    public function downloadProBundle(): void
    {
        header('Content-Type: application/json; charset=utf-8');

        try {
            if (($_SERVER['REQUEST_METHOD'] ?? 'GET') !== 'POST') {
                http_response_code(405);
                echo json_encode(['success' => false, 'error' => 'Method not allowed']);
                return;
            }

            self::requireAuth();
            self::requirePrimaryAdmin();
            self::requireCsrf();

            $licenseRaw = '';
            if (defined('FR_PRO_LICENSE') && FR_PRO_LICENSE !== '') {
                $licenseRaw = trim((string)FR_PRO_LICENSE);
            }
            if ($licenseRaw === '' && defined('PRO_LICENSE_FILE') && PRO_LICENSE_FILE && @is_file(PRO_LICENSE_FILE)) {
                $json = @file_get_contents(PRO_LICENSE_FILE);
                if ($json !== false) {
                    $decoded = json_decode($json, true);
                    if (is_array($decoded) && !empty($decoded['license'])) {
                        $licenseRaw = trim((string)$decoded['license']);
                    }
                }
            }
            if ($licenseRaw === '' && defined('FR_PRO_LICENSE_FILE') && FR_PRO_LICENSE_FILE !== '' && @is_file(FR_PRO_LICENSE_FILE)) {
                $licenseRaw = trim((string)file_get_contents(FR_PRO_LICENSE_FILE));
            }

            if ($licenseRaw === '') {
                http_response_code(400);
                echo json_encode(['success' => false, 'error' => 'No Pro license found. Save a license first.']);
                return;
            }

            if (!class_exists('\\ZipArchive')) {
                http_response_code(500);
                echo json_encode(['success' => false, 'error' => 'ZipArchive extension is required on the server.']);
                return;
            }

            $tempRoot = rtrim(sys_get_temp_dir(), DIRECTORY_SEPARATOR);
            $workDir  = $tempRoot . DIRECTORY_SEPARATOR . 'filerise_pro_dl_' . bin2hex(random_bytes(8));
            if (!@mkdir($workDir, 0700, true)) {
                http_response_code(500);
                echo json_encode(['success' => false, 'error' => 'Failed to prepare temp dir.']);
                return;
            }

            $zipPath = $workDir . DIRECTORY_SEPARATOR . 'bundle.zip';
            $remoteUrl = 'https://filerise.net/pro/download_bundle.php';
            $origName = '';

            $downloadOk = false;
            $downloadHint = '';
            $httpCode = null;
            $contentType = '';
            $headers = [];

            if (function_exists('curl_init')) {
                $fp = @fopen($zipPath, 'wb');
                if (!$fp) {
                    @rmdir($workDir);
                    http_response_code(500);
                    echo json_encode(['success' => false, 'error' => 'Failed to open temp file.']);
                    return;
                }

                $ch = curl_init($remoteUrl);
                curl_setopt($ch, CURLOPT_POST, true);
                curl_setopt($ch, CURLOPT_POSTFIELDS, http_build_query(['license' => $licenseRaw]));
                curl_setopt($ch, CURLOPT_FILE, $fp);
                curl_setopt($ch, CURLOPT_TIMEOUT, 120);
                curl_setopt($ch, CURLOPT_CONNECTTIMEOUT, 10);
                curl_setopt($ch, CURLOPT_FOLLOWLOCATION, false);
                // Cloudflare/bot protection can block default curl/PHP user agents; mimic a browser.
                curl_setopt($ch, CURLOPT_USERAGENT, 'Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36');
                curl_setopt($ch, CURLOPT_HTTPHEADER, [
                    'Accept: application/zip,application/octet-stream,*/*',
                ]);
                curl_setopt($ch, CURLOPT_HEADERFUNCTION, static function ($ch, $header) use (&$headers): int {
                    $len = strlen($header);
                    $parts = explode(':', $header, 2);
                    if (count($parts) === 2) {
                        $key = strtolower(trim($parts[0]));
                        $value = trim($parts[1]);
                        if ($key !== '') {
                            $headers[$key] = $value;
                        }
                    }
                    return $len;
                });

                $ok = curl_exec($ch);
                $httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
                $contentType = (string)curl_getinfo($ch, CURLINFO_CONTENT_TYPE);
                curl_close($ch);
                fclose($fp);

                $downloadOk = ($ok !== false && $httpCode === 200);
            } else {
                $context = stream_context_create([
                    'http' => [
                        'method'  => 'POST',
                        'header'  => implode("\r\n", [
                            "Content-Type: application/x-www-form-urlencoded",
                            "Accept: application/zip,application/octet-stream,*/*",
                            "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36",
                        ]) . "\r\n",
                        'content' => http_build_query(['license' => $licenseRaw]),
                        'timeout' => 120,
                        // Capture response bodies on non-200 statuses (useful for diagnosing CF challenges).
                        'ignore_errors' => true,
                    ],
                ]);
                $data = @file_get_contents($remoteUrl, false, $context);
                $downloadOk = ($data !== false);
                if (isset($http_response_header) && is_array($http_response_header)) {
                    foreach ($http_response_header as $line) {
                        if (preg_match('/^HTTP\/\S+\s+(\d{3})/', $line, $m)) {
                            $httpCode = (int)$m[1];
                        } elseif (strpos($line, ':') !== false) {
                            [$k, $v] = explode(':', $line, 2);
                            $headers[strtolower(trim($k))] = trim($v);
                        }
                    }
                }
                if ($downloadOk) {
                    @file_put_contents($zipPath, $data);
                }
                $downloadOk = ($downloadOk && $httpCode === 200);
            }

            if (
                isset($headers['content-disposition']) &&
                preg_match('/filename="?([^\";]+)"?/i', $headers['content-disposition'], $m)
            ) {
                $origName = $m[1];
            }

            // Sanity check: we expect a ZIP (starts with "PK"). Some proxies/bot filters return HTML.
            if ($downloadOk && is_file($zipPath)) {
                $sig = @file_get_contents($zipPath, false, null, 0, 4);
                if (!is_string($sig) || substr($sig, 0, 2) !== 'PK') {
                    $downloadOk = false;
                    $downloadHint = 'Remote server did not return a ZIP bundle.';
                }
            }

            if (!$downloadOk) {
                $msg = $downloadHint;
                $snippet = '';
                if ($msg === '' && is_file($zipPath)) {
                    $tmp = @file_get_contents($zipPath, false, null, 0, 2048);
                    $snippet = is_string($tmp) ? trim($tmp) : '';
                }
                @unlink($zipPath);
                @rmdir($workDir);
                $label = $httpCode ? "HTTP {$httpCode}" : 'Download failed';
                if ($msg === '') {
                    if ($snippet !== '' && preg_match('/^Invalid license:\\s*(.+)$/i', $snippet, $m)) {
                        $detail = trim((string)$m[1]);
                        $msg = $detail !== '' ? $detail : 'Invalid license for bundle download.';
                    }
                }
                if ($msg === '') {
                    // If we got HTML (common with Cloudflare "Just a moment..."), return a human message.
                    $looksHtml = ($snippet !== '' && preg_match('/^\\s*<(?:!doctype|html|head|body)\\b/i', $snippet));
                    $isHtmlContentType = ($contentType !== '' && stripos($contentType, 'text/html') !== false);
                    if ($looksHtml || $isHtmlContentType) {
                        $msg = 'Remote download blocked (received HTML instead of a ZIP bundle). If you see a Cloudflare \"Just a moment...\" page, use Manual upload (UI) to install the Pro ZIP.';
                    } else {
                        $msg = $label;
                    }
                }
                http_response_code(502);
                echo json_encode([
                    'success' => false,
                    'error'   => $msg,
                    'httpCode' => $httpCode,
                ]);
                return;
            }

            $size = @filesize($zipPath);
            if (!is_int($size) || $size <= 0) {
                @unlink($zipPath);
                @rmdir($workDir);
                http_response_code(500);
                echo json_encode(['success' => false, 'error' => 'Downloaded bundle is empty.']);
                return;
            }
            if ($size > 100 * 1024 * 1024) {
                @unlink($zipPath);
                @rmdir($workDir);
                http_response_code(413);
                echo json_encode(['success' => false, 'error' => 'Downloaded bundle is too large (max 100MB).']);
                return;
            }

            $result = $this->installProBundleFromZip($zipPath, $origName, $workDir);
            http_response_code($result['status']);
            echo json_encode($result['body'], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
        } catch (\Throwable $e) {
            http_response_code(500);
            echo json_encode([
                'success' => false,
                'error'   => 'Exception during bundle download: ' . $e->getMessage(),
            ]);
        }
    }

    public function updateConfig(): void
    {
        header('Content-Type: application/json');

        // —– auth & CSRF checks —–
        if (
            !isset($_SESSION['authenticated']) || $_SESSION['authenticated'] !== true ||
            !isset($_SESSION['isAdmin'])      || !$_SESSION['isAdmin']
        ) {
            http_response_code(403);
            echo json_encode(['error' => 'Unauthorized access.']);
            exit;
        }
        $headersArr    = array_change_key_case(getallheaders(), CASE_LOWER);
        $receivedToken = trim($headersArr['x-csrf-token'] ?? ($_POST['csrfToken'] ?? ''));
        if (!isset($_SESSION['csrf_token']) || $receivedToken !== $_SESSION['csrf_token']) {
            http_response_code(403);
            echo json_encode(['error' => 'Invalid CSRF token.']);
            exit;
        }

        // —– fetch payload —–
        $data = json_decode(file_get_contents('php://input'), true);
        if (!is_array($data)) {
            http_response_code(400);
            echo json_encode(['error' => 'Invalid input.']);
            exit;
        }

        // —– load existing on-disk config —–
        $existing = AdminModel::getConfig();
        if (isset($existing['error'])) {
            http_response_code(500);
            echo json_encode(['error' => $existing['error']]);
            exit;
        }

        // —– start merge with existing as base —–
        // Ensure minimal structure if the file was partially missing.
        $merged = $existing + [
            'header_title'        => '',
            'publishedUrl'        => '',
            'ffmpegPath'          => '',
            'loginOptions'        => [
                'disableFormLogin' => false,
                'disableBasicAuth' => true,
                'disableOIDCLogin' => true,
                'authBypass'       => false,
                'authHeaderName'   => 'X-Remote-User'
            ],
            'globalOtpauthUrl'    => '',
            'enableWebDAV'        => false,
            'safeUploadPolicy'    => UploadNamePolicy::MODE_STRICT,
            'sharedMaxUploadSize' => 0,
            'ignoreRegex'         => '',
            'uploads'             => [
                'resumableChunkMb' => 1.5,
                'resumableTtlHours' => 6.0,
            ],
            'oidc'                => [
                'providerUrl' => '',
                'clientId'    => '',
                'clientSecret' => '',
                'redirectUri' => '',
                'groupClaim'  => '',
                'extraScopes' => '',
                'publicClient' => false,
            ],
            'branding'            => [
                'customLogoUrl' => '',
                'headerBgLight'   => '',
                'headerBgDark'    => '',
                'appBgLight'     => '',
                'appBgDark'      => '',
                'footerHtml'    => '',
            ],
            'clamav'              => [
                'scanUploads' => false,
                'excludeDirs' => '',
            ],
            'proAudit'            => [
                'enabled' => false,
                'level' => 'standard',
                'maxFileMb' => 200,
                'maxFiles' => 10,
            ],
            'display'             => [
                'hoverPreviewMaxImageMb' => 8,
                'hoverPreviewMaxVideoMb' => 200,
                'fileListSummaryDepth' => 2,
                'defaultLanguage' => 'en',
            ],
        ];

        // header_title (cap length and strip control chars)
        if (array_key_exists('header_title', $data)) {
            $title = trim((string)$data['header_title']);
            $title = preg_replace('/[\x00-\x1F\x7F]/', '', $title);
            if (mb_strlen($title) > 100) { // hard cap
                $title = mb_substr($title, 0, 100);
            }
            $merged['header_title'] = $title;
        }

        // publishedUrl: optional advertised base URL (e.g. https://example.com/fr)
        // Env var FR_PUBLISHED_URL (if set) is the source of truth; admin value becomes read-only.
        $envPublished = getenv('FR_PUBLISHED_URL');
        $publishedLockedByEnv = ($envPublished !== false && trim((string)$envPublished) !== '');
        if (!$publishedLockedByEnv && array_key_exists('publishedUrl', $data)) {
            $u = trim((string)$data['publishedUrl']);
            if ($u === '') {
                $merged['publishedUrl'] = '';
            } else {
                $valid = filter_var($u, FILTER_VALIDATE_URL);
                $scheme = strtolower(parse_url($u, PHP_URL_SCHEME) ?: '');
                if (!$valid || ($scheme !== 'http' && $scheme !== 'https')) {
                    http_response_code(400);
                    echo json_encode(['error' => 'Invalid Published URL (must be http(s) URL).']);
                    exit;
                }
                $merged['publishedUrl'] = $u;
            }
        }

        // ffmpegPath: optional binary path for video thumbnails
        // Env var FR_FFMPEG_PATH (if set) is the source of truth; admin value becomes read-only.
        $envFfmpeg = getenv('FR_FFMPEG_PATH');
        $ffmpegLockedByEnv = ($envFfmpeg !== false && trim((string)$envFfmpeg) !== '');
        if (!$ffmpegLockedByEnv && array_key_exists('ffmpegPath', $data)) {
            $path = trim((string)$data['ffmpegPath']);
            $path = preg_replace('/[\x00-\x1F\x7F]/', '', $path);
            if (strlen($path) > 1024) {
                $path = substr($path, 0, 1024);
            }
            $merged['ffmpegPath'] = $path;
        }

        // ignoreRegex: optional ignore patterns (newline-delimited). Env FR_IGNORE_REGEX locks this field.
        $envIgnore = getenv('FR_IGNORE_REGEX');
        $ignoreLockedByEnv = ($envIgnore !== false && trim((string)$envIgnore) !== '');
        if (!$ignoreLockedByEnv && array_key_exists('ignoreRegex', $data)) {
            $raw = str_replace(["\r\n", "\r"], "\n", trim((string)$data['ignoreRegex']));
            $raw = preg_replace('/[\x00-\x08\x0B\x0C\x0E-\x1F\x7F]/', '', $raw);
            if (strlen($raw) > 2000) {
                $raw = substr($raw, 0, 2000);
            }
            $merged['ignoreRegex'] = $raw;
        }

        // loginOptions: inherit existing then override if provided
        foreach (['disableFormLogin', 'disableBasicAuth', 'disableOIDCLogin', 'authBypass'] as $flag) {
            if (isset($data['loginOptions'][$flag])) {
                $merged['loginOptions'][$flag] = filter_var(
                    $data['loginOptions'][$flag],
                    FILTER_VALIDATE_BOOLEAN
                );
            }
        }
        if (isset($data['loginOptions']['authHeaderName'])) {
            $hdr = trim((string)$data['loginOptions']['authHeaderName']);
            // very restrictive header-name pattern: letters, numbers, dashes
            if ($hdr !== '' && preg_match('/^[A-Za-z0-9\-]+$/', $hdr)) {
                $merged['loginOptions']['authHeaderName'] = $hdr;
            } else {
                http_response_code(400);
                echo json_encode(['error' => 'Invalid authHeaderName.']);
                exit;
            }
        }

        // globalOtpauthUrl
        if (array_key_exists('globalOtpauthUrl', $data)) {
            $merged['globalOtpauthUrl'] = trim((string)$data['globalOtpauthUrl']);
        }

        // enableWebDAV
        if (array_key_exists('enableWebDAV', $data)) {
            $merged['enableWebDAV'] = filter_var($data['enableWebDAV'], FILTER_VALIDATE_BOOLEAN);
        }
        if (array_key_exists('safeUploadPolicy', $data)) {
            $merged['safeUploadPolicy'] = UploadNamePolicy::normalizeMode($data['safeUploadPolicy']);
        }

        // sharedMaxUploadSize
        if (array_key_exists('sharedMaxUploadSize', $data)) {
            $sms = filter_var($data['sharedMaxUploadSize'], FILTER_VALIDATE_INT);
            if ($sms === false || $sms < 0) {
                http_response_code(400);
                echo json_encode(['error' => 'sharedMaxUploadSize must be a non-negative integer (bytes).']);
                exit;
            }
            // Clamp to PHP limits to avoid confusing UX
            $maxPost  = self::iniToBytes(ini_get('post_max_size'));
            $maxFile  = self::iniToBytes(ini_get('upload_max_filesize'));
            $phpCap   = min($maxPost ?: PHP_INT_MAX, $maxFile ?: PHP_INT_MAX);
            if ($phpCap !== PHP_INT_MAX && $sms > $phpCap) {
                $sms = $phpCap;
            }
            $merged['sharedMaxUploadSize'] = $sms;
        }

        // uploads: Resumable.js chunk size (MB)
        if (isset($data['uploads']) && is_array($data['uploads'])) {
            if (!isset($merged['uploads']) || !is_array($merged['uploads'])) {
                $merged['uploads'] = [
                    'resumableChunkMb' => 1.5,
                    'resumableTtlHours' => 6.0,
                ];
            }
            if (array_key_exists('resumableChunkMb', $data['uploads'])) {
                $raw = $data['uploads']['resumableChunkMb'];
                $num = is_numeric($raw) ? (float)$raw : 1.5;
                $merged['uploads']['resumableChunkMb'] = max(0.5, min(100, $num));
            }
            if (array_key_exists('resumableTtlHours', $data['uploads'])) {
                $raw = $data['uploads']['resumableTtlHours'];
                $num = is_numeric($raw) ? (float)$raw : 6.0;
                $merged['uploads']['resumableTtlHours'] = max(0.5, min(168, $num));
            }
        }

        $oidcPayload = (isset($data['oidc']) && is_array($data['oidc'])) ? $data['oidc'] : [];

        // OIDC group claim + extra scopes (env overrides lock these fields)
        $envGroupClaim = getenv('FR_OIDC_GROUP_CLAIM');
        $groupClaimLockedByEnv = ($envGroupClaim !== false && trim((string)$envGroupClaim) !== '');
        if (!$groupClaimLockedByEnv && array_key_exists('groupClaim', $oidcPayload)) {
            $raw = trim((string)$oidcPayload['groupClaim']);
            $raw = preg_replace('/[\x00-\x1F\x7F]/', '', $raw);
            if (strlen($raw) > 200) {
                $raw = substr($raw, 0, 200);
            }
            $merged['oidc']['groupClaim'] = $raw;
        }

        $envExtraScopes = getenv('FR_OIDC_EXTRA_SCOPES');
        $extraScopesLockedByEnv = ($envExtraScopes !== false && trim((string)$envExtraScopes) !== '');
        if (!$extraScopesLockedByEnv && array_key_exists('extraScopes', $oidcPayload)) {
            $raw = trim((string)$oidcPayload['extraScopes']);
            $raw = preg_replace('/[\x00-\x1F\x7F]/', '', $raw);
            if (strlen($raw) > 500) {
                $raw = substr($raw, 0, 500);
            }
            $merged['oidc']['extraScopes'] = $raw;
        }

        // oidc: only overwrite non-empty inputs; validate when enabling OIDC
        foreach (['providerUrl', 'clientId', 'clientSecret', 'redirectUri'] as $f) {
            if (!empty($oidcPayload[$f])) {
                $val = trim((string)$oidcPayload[$f]);
                if ($f === 'providerUrl' || $f === 'redirectUri') {
                    $val = filter_var($val, FILTER_SANITIZE_URL);
                }
                $merged['oidc'][$f] = $val;
            }
        }

        // OIDC public client flag (and optional secret wipe)
        if (array_key_exists('publicClient', $oidcPayload)) {
            $isPublic = filter_var($oidcPayload['publicClient'], FILTER_VALIDATE_BOOLEAN);
            $merged['oidc']['publicClient'] = $isPublic;
            if ($isPublic) {
                // Ensure secret is cleared when switching to public client mode
                $merged['oidc']['clientSecret'] = '';
            }
        }

        // OIDC debug logging toggle
        if (isset($oidcPayload['debugLogging'])) {
            $merged['oidc']['debugLogging'] = filter_var(
                $oidcPayload['debugLogging'],
                FILTER_VALIDATE_BOOLEAN
            );
        }

// OIDC admin demotion toggle
        if (isset($oidcPayload['allowDemote'])) {
            $merged['oidc']['allowDemote'] = filter_var(
                $oidcPayload['allowDemote'],
                FILTER_VALIDATE_BOOLEAN
            );
        }

        // If OIDC login is enabled, ensure required fields are present and sane
        $oidcEnabled = !empty($merged['loginOptions']['disableOIDCLogin']) ? false : true;

        if ($oidcEnabled) {
            $prov = $merged['oidc']['providerUrl'] ?? '';
            $rid  = $merged['oidc']['redirectUri'] ?? '';
            $cid  = $merged['oidc']['clientId'] ?? '';

            if ($prov === '' || $rid === '' || $cid === '') {
                http_response_code(400);
                echo json_encode([
                    'error' => 'OIDC is enabled but providerUrl, redirectUri, and clientId are required.'
                ]);
                exit;
            }

            // Require https except for localhost development
            $httpsOk = function (string $url): bool {
                if ($url === '') {
                    return false;
                }
                $parts = parse_url($url);
                if (!$parts || empty($parts['scheme'])) {
                    return false;
                }

                $scheme = strtolower($parts['scheme']);
                $host   = strtolower($parts['host'] ?? '');

                if ($scheme === 'https') {
                    return true;
                }

                if ($scheme === 'http' && ($host === 'localhost' || $host === '127.0.0.1')) {
                    return true;
                }

                return false;
            };
        }


        // —– persist merged config —–
        // ---- ONLYOFFICE: merge from payload (unless locked by PHP defines) ----
        $ooLockedByPhp = (
            defined('ONLYOFFICE_ENABLED') ||
            defined('ONLYOFFICE_DOCS_ORIGIN') ||
            defined('ONLYOFFICE_JWT_SECRET') ||
            defined('ONLYOFFICE_PUBLIC_ORIGIN')
        );

        if (!$ooLockedByPhp && isset($data['onlyoffice']) && is_array($data['onlyoffice'])) {
            $ooExisting = (isset($existing['onlyoffice']) && is_array($existing['onlyoffice']))
                ? $existing['onlyoffice'] : [];

            $oo = $ooExisting;

            if (array_key_exists('enabled', $data['onlyoffice'])) {
                $oo['enabled'] = filter_var($data['onlyoffice']['enabled'], FILTER_VALIDATE_BOOLEAN);
            }
            if (isset($data['onlyoffice']['docsOrigin'])) {
                $oo['docsOrigin'] = (string)$data['onlyoffice']['docsOrigin'];
            }
            if (isset($data['onlyoffice']['publicOrigin'])) {
                $oo['publicOrigin'] = (string)$data['onlyoffice']['publicOrigin'];
            }
            // Allow setting/changing the secret when NOT locked by PHP
            if (isset($data['onlyoffice']['jwtSecret'])) {
                $js = trim((string)$data['onlyoffice']['jwtSecret']);
                if ($js !== '') {
                    $oo['jwtSecret'] = $js; // stored encrypted by AdminModel
                }
                // If blank, we leave existing secret unchanged (no implicit wipe).
            }

            $merged['onlyoffice'] = $oo;
        }
        // Branding: pass through raw strings; AdminModel enforces Pro + sanitization.
        if (isset($data['branding']) && is_array($data['branding'])) {
            if (!isset($merged['branding']) || !is_array($merged['branding'])) {
                $merged['branding'] = [
                    'customLogoUrl'   => '',
                    'headerBgLight'   => '',
                    'headerBgDark'    => '',
                    'metaDescription' => '',
                    'faviconSvg'      => '',
                    'faviconPng'      => '',
                    'faviconIco'      => '',
                    'appleTouchIcon'  => '',
                    'maskIcon'        => '',
                'maskIconColor'   => '',
                'themeColorLight' => '',
                'themeColorDark'  => '',
                'loginBgLight'    => '',
                'loginBgDark'     => '',
                'appBgLight'      => '',
                'appBgDark'       => '',
                'loginTagline'    => '',
                'footerHtml'      => '',
                ];
            }
            foreach (
                [
                'customLogoUrl',
                'headerBgLight',
                'headerBgDark',
                'metaDescription',
                'faviconSvg',
                'faviconPng',
                'faviconIco',
                'appleTouchIcon',
                'maskIcon',
                'maskIconColor',
                'themeColorLight',
                'themeColorDark',
                'loginBgLight',
                'loginBgDark',
                'appBgLight',
                'appBgDark',
                'loginTagline',
                'footerHtml'
                ] as $key
            ) {
                if (array_key_exists($key, $data['branding'])) {
                    $merged['branding'][$key] = (string)$data['branding'][$key];
                }
            }
        }

        // --- ClamAV: store admin toggle only when not locked by env/constant ---
        $envScanRaw    = getenv('VIRUS_SCAN_ENABLED');
        $clamLockedEnv = ($envScanRaw !== false && $envScanRaw !== '') || defined('VIRUS_SCAN_ENABLED');
        $envExcludeRaw = getenv('VIRUS_SCAN_EXCLUDE_DIRS');
        $clamExcludeLockedEnv = ($envExcludeRaw !== false && trim((string)$envExcludeRaw) !== '');

        if (!$clamLockedEnv && isset($data['clamav']) && is_array($data['clamav'])) {
            if (array_key_exists('scanUploads', $data['clamav'])) {
                $merged['clamav']['scanUploads'] = filter_var(
                    $data['clamav']['scanUploads'],
                    FILTER_VALIDATE_BOOLEAN
                );
            }
        }
        if (!$clamExcludeLockedEnv && isset($data['clamav']) && is_array($data['clamav'])) {
            if (array_key_exists('excludeDirs', $data['clamav'])) {
                $rawExclude = (string)$data['clamav']['excludeDirs'];
                $rawExclude = preg_replace('/[\x00-\x08\x0B\x0C\x0E-\x1F\x7F]/', '', $rawExclude);
                $merged['clamav']['excludeDirs'] = trim((string)$rawExclude);
            }
        }

        // --- Pro Search Everywhere: respect env lock, otherwise persist toggle/limit ---
        $envProSearch    = getenv('FR_PRO_SEARCH_ENABLED');
        $proSearchLocked = ($envProSearch !== false && $envProSearch !== '');
        if (!$proSearchLocked && isset($data['proSearch']) && is_array($data['proSearch'])) {
            if (!isset($merged['proSearch']) || !is_array($merged['proSearch'])) {
                $merged['proSearch'] = [
                    'enabled' => true,
                    'defaultLimit' => 50,
                ];
            }
            if (array_key_exists('enabled', $data['proSearch'])) {
                $merged['proSearch']['enabled'] = filter_var(
                    $data['proSearch']['enabled'],
                    FILTER_VALIDATE_BOOLEAN
                );
            }
            if (array_key_exists('defaultLimit', $data['proSearch'])) {
                $lim = filter_var($data['proSearch']['defaultLimit'], FILTER_VALIDATE_INT);
                $merged['proSearch']['defaultLimit'] = max(1, min(200, $lim !== false ? $lim : 50));
            }
        }

        if (isset($data['proAudit']) && is_array($data['proAudit'])) {
            if (!isset($merged['proAudit']) || !is_array($merged['proAudit'])) {
                $merged['proAudit'] = [
                    'enabled' => false,
                    'level' => 'standard',
                    'maxFileMb' => 200,
                    'maxFiles' => 10,
                ];
            }
            if (array_key_exists('enabled', $data['proAudit'])) {
                $merged['proAudit']['enabled'] = filter_var(
                    $data['proAudit']['enabled'],
                    FILTER_VALIDATE_BOOLEAN
                );
            }
            if (array_key_exists('level', $data['proAudit'])) {
                $raw = trim((string)$data['proAudit']['level']);
                $merged['proAudit']['level'] = ($raw === 'standard' || $raw === 'verbose') ? $raw : 'standard';
            }
            if (array_key_exists('maxFileMb', $data['proAudit'])) {
                $lim = filter_var($data['proAudit']['maxFileMb'], FILTER_VALIDATE_INT);
                $merged['proAudit']['maxFileMb'] = max(10, min(2048, $lim !== false ? $lim : 200));
            }
            if (array_key_exists('maxFiles', $data['proAudit'])) {
                $lim = filter_var($data['proAudit']['maxFiles'], FILTER_VALIDATE_INT);
                $merged['proAudit']['maxFiles'] = max(1, min(50, $lim !== false ? $lim : 10));
            }
        }

        if (isset($data['display']) && is_array($data['display'])) {
            if (!isset($merged['display']) || !is_array($merged['display'])) {
                $merged['display'] = [
                    'hoverPreviewMaxImageMb' => 8,
                    'hoverPreviewMaxVideoMb' => 200,
                    'enablePdfThumbnails' => false,
                    'fileListSummaryDepth' => 2,
                ];
            }
            if (array_key_exists('hoverPreviewMaxImageMb', $data['display'])) {
                $lim = filter_var($data['display']['hoverPreviewMaxImageMb'], FILTER_VALIDATE_INT);
                $merged['display']['hoverPreviewMaxImageMb'] = max(1, min(50, $lim !== false ? $lim : 8));
            }
            if (array_key_exists('hoverPreviewMaxVideoMb', $data['display'])) {
                $lim = filter_var($data['display']['hoverPreviewMaxVideoMb'], FILTER_VALIDATE_INT);
                $merged['display']['hoverPreviewMaxVideoMb'] = max(1, min(2048, $lim !== false ? $lim : 200));
            }
            if (array_key_exists('enablePdfThumbnails', $data['display'])) {
                $merged['display']['enablePdfThumbnails'] = !empty($data['display']['enablePdfThumbnails']);
            }
            if (array_key_exists('fileListSummaryDepth', $data['display'])) {
                $lim = filter_var($data['display']['fileListSummaryDepth'], FILTER_VALIDATE_INT);
                $merged['display']['fileListSummaryDepth'] = max(0, min(10, $lim !== false ? $lim : 2));
            }
            if (array_key_exists('defaultLanguage', $data['display'])) {
                $raw = trim((string)$data['display']['defaultLanguage']);
                $allowed = ['en', 'es', 'fr', 'de', 'pl', 'ru', 'ja', 'zh-CN'];
                $merged['display']['defaultLanguage'] = in_array($raw, $allowed, true) ? $raw : 'en';
            }
        }

        $result = AdminModel::updateConfig($merged);
        if (isset($result['error'])) {
            http_response_code(500);
        }
        echo json_encode($result);
        exit;
    }

    /** Convert php.ini shorthand like "128M" to bytes */
    private static function iniToBytes($val)
    {
        if ($val === false || $val === null || $val === '') {
            return 0;
        }
        $val = trim((string)$val);
        $last = strtolower($val[strlen($val) - 1]);
        $num = (int)$val;
        switch ($last) {
            case 'g':
                $num *= 1024;
                // fall through
            case 'm':
                $num *= 1024;
                // fall through
            case 'k':
                $num *= 1024;
                break;
        }
        return $num;
    }
}
