<?php

namespace FileRise\Http\Controllers;

use FileRise\Support\ACL;
use FileRise\Domain\AdminModel;
use FileRise\Domain\AuthModel;
use FileRise\Domain\UserModel;

// src/controllers/UserController.php

require_once dirname(__DIR__, 4) . '/config/config.php';

/**
 * UserController
 * - Hardened CSRF/auth checks (works even when getallheaders() is unavailable)
 * - Consistent method checks without breaking existing clients (accepts POST as fallback for some endpoints)
 * - Stricter validation & safer defaults
 * - TOTP setup is restricted to fully authenticated profile sessions
 * - Standardized calls to UserModel (proper case)
 */
class UserController
{
    /* ---------- Small internal helpers to reduce repetition ---------- */

    /** 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;
    }

    /** Enforce allowed HTTP method(s); default to 405 if not allowed. */
    private static function requireMethod(array $allowed): void
    {
        $method = $_SERVER['REQUEST_METHOD'] ?? 'GET';
        if (!in_array($method, $allowed, true)) {
            http_response_code(405);
            header('Allow: ' . implode(', ', $allowed));
            header('Content-Type: application/json');
            echo json_encode(['error' => 'Method not allowed']);
            exit;
        }
    }

    /** Enforce authentication (401). */
    private 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). */
    private 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;
        }
    }

    /** Enforce CSRF using X-CSRF-Token header (or csrfToken param as fallback). */
    private 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 : [];
    }

    /** Convenience: set JSON content type + no-store. */
    private static function jsonHeaders(): void
    {
        header('Content-Type: application/json');
        header('Cache-Control: no-store, no-cache, must-revalidate');
        header('Pragma: no-cache');
    }

    /* ------------------------- End helpers -------------------------- */

    public function getUsers()
    {
        self::jsonHeaders();
        self::requireAdmin();

        // Retrieve users using the model
        $users = UserModel::getAllUsers();
        echo json_encode($users);
        exit;
    }

    public function addUser()
    {
        self::jsonHeaders();
        self::requireMethod(['POST']);

        // Initialize CSRF token if missing (useful for initial page load)
        if (empty($_SESSION['csrf_token'])) {
            $_SESSION['csrf_token'] = bin2hex(random_bytes(32));
        }

        // Setup mode detection (first-run bootstrap)
        $usersFile = USERS_DIR . USERS_FILE;
        $isSetup   = (isset($_GET['setup']) && $_GET['setup'] === '1');
        $setupMode = false;

        if (
            $isSetup && (
                !file_exists($usersFile) ||
                filesize($usersFile) === 0 ||
                trim(@file_get_contents($usersFile)) === ''
            )
        ) {
            $setupMode = true;
        } else {
            // Not setup: enforce CSRF + admin auth
            $h = self::headersLower();
            $receivedToken = trim($h['x-csrf-token'] ?? '');

            // Soft-fail CSRF: on mismatch, regenerate and return new token
            if ($receivedToken !== $_SESSION['csrf_token']) {
                $_SESSION['csrf_token'] = bin2hex(random_bytes(32));
                header('X-CSRF-Token: ' . $_SESSION['csrf_token']);
                echo json_encode([
                    'csrf_expired' => true,
                    'csrf_token'   => $_SESSION['csrf_token'],
                ]);
                exit;
            }

            self::requireAdmin();
        }

        $data        = self::readJson();
        $newUsername = trim($data['username'] ?? '');
        $newPassword = trim($data['password'] ?? '');

        $isAdmin = $setupMode ? '1' : (!empty($data['isAdmin']) ? '1' : '0');

        // Helper for validation errors (new + legacy shape)
        $fail = function (string $message, int $status = 422) {
            http_response_code($status);
            echo json_encode([
                'ok'      => false,
                'error'   => $message,
                // legacy-friendly fields:
                'success' => false,
                'message' => $message,
            ]);
            exit;
        };

        if ($newUsername === '' || $newPassword === '') {
            $fail('Username and password required');
        }

        if (!preg_match(REGEX_USER, $newUsername)) {
            $fail('Invalid username. Only letters, numbers, underscores, dashes, and spaces are allowed.');
        }

        // Enforce at least 6 chars (for new accounts)
        if (strlen($newPassword) < 6) {
            $fail('Password must be at least 6 characters.');
        }

        $result = UserModel::addUser($newUsername, $newPassword, $isAdmin, $setupMode);

        // If the model returns an error, also go through the same fail() helper
        if (isset($result['error']) && $result['error'] !== '') {
            $fail($result['error']);
        }

        // Normalize success shape: new + legacy fields
        $msg = $result['success'] ?? 'User added successfully';

        http_response_code(200);
        echo json_encode([
            'ok'      => true,
            'data'    => $result,       // keeps your new structure
            // legacy-friendly:
            'success' => true,
            'message' => $msg,
        ]);
        exit;
    }

    public function adminChangeUserPassword()
    {
        self::jsonHeaders();
        self::requireMethod(['POST']);
        self::requireAdmin();
        self::requireCsrf();

        $data = self::readJson();
        $username    = trim((string)($data['username'] ?? ''));
        $newPassword = (string)($data['newPassword'] ?? '');

        if ($username === '' || $newPassword === '') {
            http_response_code(400);
            echo json_encode(['error' => 'Username and newPassword are required.']);
            exit;
        }

        // Optional: enforce a minimum length, like addUser
        if (strlen($newPassword) < 6) {
            http_response_code(422);
            echo json_encode(['error' => 'Password must be at least 6 characters.']);
            exit;
        }

        $result = UserModel::adminResetPassword($username, $newPassword);

        if (!empty($result['error'])) {
            http_response_code(400);
        } else {
            http_response_code(200);
        }

        echo json_encode($result);
        exit;
    }

    public function removeUser()
    {
        self::jsonHeaders();
        // Accept DELETE or POST for broader compatibility
        self::requireMethod(['DELETE', 'POST']);
        self::requireAdmin();
        self::requireCsrf();

        $data = self::readJson();
        $usernameToRemove = trim($data['username'] ?? '');

        if ($usernameToRemove === '') {
            echo json_encode(["error" => "Username is required"]);
            exit;
        }
        if (!preg_match(REGEX_USER, $usernameToRemove)) {
            echo json_encode(["error" => "Invalid username format"]);
            exit;
        }
        if (!empty($_SESSION['username']) && strcasecmp($_SESSION['username'], $usernameToRemove) === 0) {
            echo json_encode(["error" => "Cannot remove yourself"]);
            exit;
        }

        $result = UserModel::removeUser($usernameToRemove);
        echo json_encode($result);
        exit;
    }

    public function getUserPermissions()
    {
        self::jsonHeaders();
        self::requireAuth();

        $permissions = UserModel::getUserPermissions();
        echo json_encode($permissions);
        exit;
    }

    public function updateUserPermissions()
    {
        self::jsonHeaders();
        // Accept PUT or POST for compatibility with clients that can't send PUT
        self::requireMethod(['PUT', 'POST']);
        self::requireAdmin();
        self::requireCsrf();

        $input = self::readJson();
        if (!isset($input['permissions']) || !is_array($input['permissions'])) {
            echo json_encode(["error" => "Invalid input"]);
            exit;
        }
        $permissions = $input['permissions'];

        $result = UserModel::updateUserPermissions($permissions);
        echo json_encode($result);
        exit;
    }

    public function changePassword()
    {
        self::jsonHeaders();
        self::requireMethod(['POST']);
        self::requireAuth();
        self::requireCsrf();

        $username = $_SESSION['username'] ?? '';
        if ($username === '') {
            echo json_encode(["error" => "No username in session"]);
            exit;
        }
        // Block changing the demo account password when in demo mode
        if (FR_DEMO_MODE && $username === 'demo') {
            header('Content-Type: application/json; charset=utf-8');
            echo json_encode([
                'success' => false,
                'error'   => 'Password changes are disabled on the public demo.'
            ]);
            exit;
        }

        $data = self::readJson();
        $oldPassword     = trim($data["oldPassword"] ?? "");
        $newPassword     = trim($data["newPassword"] ?? "");
        $confirmPassword = trim($data["confirmPassword"] ?? "");

        if ($oldPassword === '' || $newPassword === '' || $confirmPassword === '') {
            echo json_encode(["error" => "All fields are required."]);
            exit;
        }
        if ($newPassword !== $confirmPassword) {
            echo json_encode(["error" => "New passwords do not match."]);
            exit;
        }
        if (strlen($newPassword) < 6) {
            echo json_encode(["error" => "Password must be at least 6 characters."]);
            exit;
        }

        $result = UserModel::changePassword($username, $oldPassword, $newPassword);
        echo json_encode($result);
        exit;
    }

    public function updateUserPanel()
    {
        self::jsonHeaders();
        // Accept PUT or POST for compatibility
        self::requireMethod(['PUT', 'POST']);
        self::requireAuth();
        self::requireCsrf();

        $data = self::readJson();
        if (!is_array($data)) {
            http_response_code(400);
            echo json_encode(["error" => "Invalid input"]);
            exit;
        }

        $username = $_SESSION['username'] ?? '';
        if ($username === '') {
            http_response_code(400);
            echo json_encode(["error" => "No username in session"]);
            exit;
        }

        if (defined('FR_DEMO_MODE') && FR_DEMO_MODE && $username === 'demo') {
            http_response_code(403);
            echo json_encode([
                'error' => 'TOTP settings are disabled for the demo account.'
            ]);
            exit;
        }

        $totp_enabled = isset($data['totp_enabled']) ? filter_var($data['totp_enabled'], FILTER_VALIDATE_BOOLEAN) : false;
        $result = UserModel::updateUserPanel($username, $totp_enabled);
        echo json_encode($result);
        exit;
    }

    public function disableTOTP()
    {
        self::jsonHeaders();
        // Accept PUT or POST
        self::requireMethod(['PUT', 'POST']);
        self::requireAuth();
        self::requireCsrf();

        $username = $_SESSION['username'] ?? '';
        if ($username === '') {
            http_response_code(400);
            echo json_encode(["error" => "Username not found in session"]);
            exit;
        }

        if (defined('FR_DEMO_MODE') && FR_DEMO_MODE && $username === 'demo') {
            http_response_code(403);
            echo json_encode([
                'error' => 'TOTP settings are disabled for the demo account.'
            ]);
            exit;
        }

        $result = UserModel::disableTOTPSecret($username);
        if ($result) {
            echo json_encode(["success" => true, "message" => "TOTP disabled successfully."]);
        } else {
            http_response_code(500);
            echo json_encode(["error" => "Failed to disable TOTP."]);
        }
        exit;
    }

    public function recoverTOTP()
    {
        self::jsonHeaders();
        self::requireMethod(['POST']);
        self::requireCsrf();

        $userId = $_SESSION['username'] ?? ($_SESSION['pending_login_user'] ?? null);
        if (!$userId) {
            http_response_code(401);
            echo json_encode(['status' => 'error', 'message' => 'Unauthorized']);
            exit;
        }
        if (!preg_match(REGEX_USER, $userId)) {
            http_response_code(400);
            echo json_encode(['status' => 'error', 'message' => 'Invalid user identifier']);
            exit;
        }

        $inputData = self::readJson();
        $recoveryCode = $inputData['recovery_code'] ?? '';

        $result = UserModel::recoverTOTP($userId, $recoveryCode);

        if (($result['status'] ?? '') === 'ok') {
            // Finalize login
            session_regenerate_id(true);
            $_SESSION['authenticated'] = true;
            $_SESSION['username'] = $userId;
            unset($_SESSION['pending_login_user'], $_SESSION['pending_login_secret']);
            echo json_encode(['status' => 'ok']);
        } else {
            if (($result['message'] ?? '') === 'Too many attempts. Try again later.') {
                http_response_code(429);
            } else {
                http_response_code(400);
            }
            echo json_encode($result);
        }
        exit;
    }

    public function saveTOTPRecoveryCode()
    {
        self::jsonHeaders();
        self::requireMethod(['POST']);
        self::requireCsrf();

        if (empty($_SESSION['username'])) {
            http_response_code(401);
            echo json_encode(['status' => 'error', 'message' => 'Unauthorized']);
            exit;
        }

        $userId = $_SESSION['username'];

        if (defined('FR_DEMO_MODE') && FR_DEMO_MODE && $userId === 'demo') {
            http_response_code(403);
            echo json_encode([
                'status'  => 'error',
                'message' => 'TOTP settings are disabled for the demo account.',
            ]);
            exit;
        }

        if (!preg_match(REGEX_USER, $userId)) {
            http_response_code(400);
            echo json_encode(['status' => 'error', 'message' => 'Invalid user identifier']);
            exit;
        }

        $result = UserModel::saveTOTPRecoveryCode($userId);
        if (($result['status'] ?? '') === 'ok') {
            echo json_encode($result);
        } else {
            http_response_code(500);
            echo json_encode($result);
        }
        exit;
    }

    public function setupTOTP()
    {
        self::requireAuth();
        self::requireCsrf();

        $username = $_SESSION['username'] ?? '';
        if (defined('FR_DEMO_MODE') && FR_DEMO_MODE && $username === 'demo') {
            http_response_code(403);
            header('Content-Type: application/json');
            echo json_encode(['error' => 'TOTP setup is disabled for the demo account.']);
            exit;
        }

        if ($username === '') {
            http_response_code(400);
            header('Content-Type: application/json');
            echo json_encode(['error' => 'Username not available for TOTP setup']);
            exit;
        }
        if (!preg_match(REGEX_USER, $username)) {
            http_response_code(400);
            header('Content-Type: application/json');
            echo json_encode(['error' => 'Invalid user identifier']);
            exit;
        }

        header("Content-Type: image/png");
        header('X-Content-Type-Options: nosniff');

        $result = UserModel::setupTOTP($username);
        if (isset($result['error'])) {
            http_response_code((int)($result['statusCode'] ?? 500));
            header('Content-Type: application/json');
            echo json_encode(["error" => $result['error']]);
            exit;
        }

        echo $result['imageData'];
        exit;
    }

    public function verifyTOTP()
    {
        header('Content-Type: application/json');
        header("Content-Security-Policy: default-src 'self'; script-src 'self'; style-src 'self';");
        header('X-Content-Type-Options: nosniff');

        // Rate-limit
        if (!isset($_SESSION['totp_failures'])) {
            $_SESSION['totp_failures'] = 0;
        }
        if ($_SESSION['totp_failures'] >= 5) {
            http_response_code(429);
            echo json_encode(['status' => 'error', 'message' => 'Too many TOTP attempts. Please try again later.']);
            exit;
        }

        // Must be authenticated OR pending login
        if (empty($_SESSION['authenticated']) && !isset($_SESSION['pending_login_user'])) {
            http_response_code(403);
            echo json_encode(['status' => 'error', 'message' => 'Not authenticated']);
            exit;
        }

        // CSRF check
        self::requireCsrf();

        // Parse & validate input
        $inputData = self::readJson();
        $code = trim($inputData['totp_code'] ?? '');
        if (!preg_match('/^\d{6}$/', $code)) {
            http_response_code(400);
            echo json_encode(['status' => 'error', 'message' => 'A valid 6-digit TOTP code is required']);
            exit;
        }

        // TFA helper
        $tfa = new \RobThree\Auth\TwoFactorAuth(
            new \RobThree\Auth\Providers\Qr\GoogleChartsQrCodeProvider(),
            'FileRise',
            6,
            30,
            \RobThree\Auth\Algorithm::Sha1
        );

        // Pending-login flow
        if (isset($_SESSION['pending_login_user'])) {
            $username      = $_SESSION['pending_login_user'];
            $pendingSecret = $_SESSION['pending_login_secret'] ?? null;
            $rememberMe    = $_SESSION['pending_login_remember_me'] ?? false;

            if (!$pendingSecret || !$tfa->verifyCode($pendingSecret, $code)) {
                $_SESSION['totp_failures']++;
                http_response_code(400);
                echo json_encode(['status' => 'error', 'message' => 'Invalid TOTP code']);
                exit;
            }

             // Decide final admin flag (local role + optional OIDC override)
             $roleAdmin = ((int)UserModel::getUserRole($username) === 1);
             $isAdmin   = $roleAdmin;

            if (!empty($_SESSION['oidc_isAdmin'])) {
                $isAdmin = true; // IdP admin group can only elevate, never downgrade
            }

            // Issue remember-me token if requested
            if ($rememberMe) {
                $issued = AuthModel::issueRememberToken($username, (bool)$isAdmin);
                $secure = (!empty($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off');
                setcookie('remember_me_token', $issued['token'], $issued['expiry'], '/', '', $secure, true);
                setcookie(session_name(), session_id(), $issued['expiry'], '/', '', $secure, true);
            }

             // Finalize login
             session_regenerate_id(true);
             $_SESSION['authenticated']   = true;
             $_SESSION['username']        = $username;
             $_SESSION['isAdmin']         = $isAdmin;

             $perms = loadUserPermissions($username);
             $_SESSION['folderOnly']      = $perms['folderOnly']    ?? false;
             $_SESSION['readOnly']        = $perms['readOnly']      ?? false;
             $_SESSION['disableUpload']   = $perms['disableUpload'] ?? false;

             // If OIDC groups were stashed for this pending login, sync them now (after TOTP)
            if (!empty($_SESSION['oidc_groups']) && is_array($_SESSION['oidc_groups'])) {
                AuthModel::syncOidcGroupsToPro($username, $_SESSION['oidc_groups']);
            }

             unset(
                 $_SESSION['pending_login_user'],
                 $_SESSION['pending_login_secret'],
                 $_SESSION['pending_login_remember_me'],
                 $_SESSION['totp_failures'],
                 $_SESSION['oidc_isAdmin'],
                 $_SESSION['oidc_groups']
             );

            echo json_encode([
                'status'        => 'ok',
                'success'       => 'Login successful',
                'isAdmin'       => $_SESSION['isAdmin'],
                'folderOnly'    => $_SESSION['folderOnly'],
                'readOnly'      => $_SESSION['readOnly'],
                'disableUpload' => $_SESSION['disableUpload'],
                'username'      => $_SESSION['username']
            ]);
            exit;
        }

        // Setup/verification flow (not pending)
        $username = $_SESSION['username'] ?? '';
        if ($username === '') {
            http_response_code(400);
            echo json_encode(['status' => 'error', 'message' => 'Username not found in session']);
            exit;
        }

        $totpSecret = UserModel::getTOTPSecret($username);
        if (!$totpSecret) {
            http_response_code(500);
            echo json_encode(['status' => 'error', 'message' => 'TOTP secret not found. Please set up TOTP again.']);
            exit;
        }

        if (!$tfa->verifyCode($totpSecret, $code)) {
            $_SESSION['totp_failures']++;
            http_response_code(400);
            echo json_encode(['status' => 'error', 'message' => 'Invalid TOTP code']);
            exit;
        }

        unset($_SESSION['totp_failures']);
        echo json_encode(['status' => 'ok', 'message' => 'TOTP successfully verified']);
        exit;
    }

    /**
     * Upload profile picture (multipart/form-data)
     */
    public function uploadPicture()
    {
        self::jsonHeaders();

        // Auth & CSRF
        self::requireAuth();
        self::requireCsrf();

        if (defined('FR_DEMO_MODE') && FR_DEMO_MODE) {
            http_response_code(403);
            echo json_encode([
                'success' => false,
                'error'   => 'Profile picture changes are disabled in the demo environment.',
            ]);
            exit;
        }

        if (empty($_FILES['profile_picture']) || $_FILES['profile_picture']['error'] !== UPLOAD_ERR_OK) {
            http_response_code(400);
            echo json_encode(['success' => false, 'error' => 'No file uploaded or error']);
            exit;
        }
        $file = $_FILES['profile_picture'];

        // Validate MIME & size
        $allowed = ['image/jpeg' => 'jpg', 'image/png' => 'png', 'image/gif' => 'gif'];
        $finfo   = finfo_open(FILEINFO_MIME_TYPE);
        $mime    = finfo_file($finfo, $file['tmp_name']);
        finfo_close($finfo);
        if (!isset($allowed[$mime])) {
            http_response_code(400);
            echo json_encode(['success' => false, 'error' => 'Invalid file type']);
            exit;
        }
        if ($file['size'] > 2 * 1024 * 1024) {
            http_response_code(400);
            echo json_encode(['success' => false, 'error' => 'File too large']);
            exit;
        }

        // Destination
        $uploadDir = rtrim(UPLOAD_DIR, '/\\') . '/profile_pics';
        if (!is_dir($uploadDir) && !mkdir($uploadDir, 0755, true)) {
            http_response_code(500);
            echo json_encode(['success' => false, 'error' => 'Cannot create upload folder']);
            exit;
        }

        $ext      = $allowed[$mime];
        $user     = preg_replace('/[^a-zA-Z0-9_\-]/', '', $_SESSION['username']);
        $filename = $user . '_' . bin2hex(random_bytes(8)) . '.' . $ext;
        $dest     = $uploadDir . '/' . $filename;
        if (!move_uploaded_file($file['tmp_name'], $dest)) {
            http_response_code(500);
            echo json_encode(['success' => false, 'error' => 'Failed to save file']);
            exit;
        }

        $url = fr_profile_pic_url($filename);
        if ($url === '') {
            @unlink($dest);
            http_response_code(500);
            echo json_encode([
                'success' => false,
                'error'   => 'Failed to generate profile picture URL'
            ]);
            exit;
        }

        $result = UserModel::setProfilePicture($_SESSION['username'], $url);
        if (!($result['success'] ?? false)) {
            @unlink($dest);
            http_response_code(500);
            echo json_encode([
                'success' => false,
                'error'   => 'Failed to save profile picture setting'
            ]);
            exit;
        }

        echo json_encode(['success' => true, 'url' => $url]);
        exit;
    }

    /**
     * Upload branding logo (Pro-only; admin, CSRF).
     * Reuses the profile_pics directory but does NOT change the user's avatar.
     */
    public function uploadBrandLogo()
    {
        self::jsonHeaders();

        // Auth, admin & CSRF
        self::requireAuth();
        self::requireAdmin();
        self::requireCsrf();

        if (empty($_FILES['brand_logo']) || $_FILES['brand_logo']['error'] !== UPLOAD_ERR_OK) {
            http_response_code(400);
            echo json_encode(['success' => false, 'error' => 'No file uploaded or error']);
            exit;
        }
        $file = $_FILES['brand_logo'];

        // Validate MIME & size (same rules as uploadPicture)
        $allowed = ['image/jpeg' => 'jpg', 'image/png' => 'png', 'image/gif' => 'gif'];
        $finfo   = finfo_open(FILEINFO_MIME_TYPE);
        $mime    = finfo_file($finfo, $file['tmp_name']);
        finfo_close($finfo);
        if (!isset($allowed[$mime])) {
            http_response_code(400);
            echo json_encode(['success' => false, 'error' => 'Invalid file type']);
            exit;
        }
        if ($file['size'] > 2 * 1024 * 1024) {
            http_response_code(400);
            echo json_encode(['success' => false, 'error' => 'File too large']);
            exit;
        }

        // Destination: reuse profile_pics directory
        $uploadDir = rtrim(UPLOAD_DIR, '/\\') . '/profile_pics';
        if (!is_dir($uploadDir) && !mkdir($uploadDir, 0755, true)) {
            http_response_code(500);
            echo json_encode(['success' => false, 'error' => 'Cannot create upload folder']);
            exit;
        }

        $ext      = $allowed[$mime];
        $user     = preg_replace('/[^a-zA-Z0-9_\-]/', '', $_SESSION['username'] ?? 'logo');
        $filename = 'branding_' . $user . '_' . bin2hex(random_bytes(8)) . '.' . $ext;
        $dest     = $uploadDir . '/' . $filename;
        if (!move_uploaded_file($file['tmp_name'], $dest)) {
            http_response_code(500);
            echo json_encode(['success' => false, 'error' => 'Failed to save file']);
            exit;
        }


        $url = fr_profile_pic_url($filename);
        if ($url === '') {
            @unlink($dest);
            http_response_code(500);
            echo json_encode(['success' => false, 'error' => 'Failed to generate logo URL']);
            exit;
        }

        echo json_encode(['success' => true, 'url' => $url]);
        exit;
    }

        /**
     * Upload a logo for a specific client portal (Pro-only; admin, CSRF).
     * Stores the file in UPLOAD_DIR/profile_pics and returns filename + URL.
     */
    public function uploadPortalLogo(): void
    {
        self::jsonHeaders();

        // Auth, admin & CSRF
        self::requireAuth();
        self::requireAdmin();
        self::requireCsrf();

        if (empty($_FILES['portal_logo']) || $_FILES['portal_logo']['error'] !== UPLOAD_ERR_OK) {
            http_response_code(400);
            echo json_encode(['success' => false, 'error' => 'No file uploaded or error']);
            exit;
        }

        $file = $_FILES['portal_logo'];

        // Optional: which portal (used only for filename prefix)
        $slugRaw = isset($_POST['slug']) ? (string)$_POST['slug'] : '';
        $slug    = preg_replace('/[^a-zA-Z0-9_\-]/', '', $slugRaw) ?: 'portal';

        // Validate MIME & size (same rules as uploadPicture / uploadBrandLogo)
        $allowed = [
            'image/jpeg' => 'jpg',
            'image/png'  => 'png',
            'image/gif'  => 'gif',
        ];

        $finfo = finfo_open(FILEINFO_MIME_TYPE);
        $mime  = finfo_file($finfo, $file['tmp_name']);
        finfo_close($finfo);

        if (!isset($allowed[$mime])) {
            http_response_code(400);
            echo json_encode(['success' => false, 'error' => 'Invalid file type']);
            exit;
        }

        if ($file['size'] > 2 * 1024 * 1024) { // 2MB
            http_response_code(400);
            echo json_encode(['success' => false, 'error' => 'File too large']);
            exit;
        }

        // Destination: reuse profile_pics directory
        $uploadDir = rtrim(UPLOAD_DIR, '/\\') . '/profile_pics';
        if (!is_dir($uploadDir) && !mkdir($uploadDir, 0755, true)) {
            http_response_code(500);
            echo json_encode(['success' => false, 'error' => 'Cannot create upload folder']);
            exit;
        }

        $ext      = $allowed[$mime];
        $filename = 'portal_' . $slug . '_' . bin2hex(random_bytes(8)) . '.' . $ext;
        $dest     = $uploadDir . '/' . $filename;

        if (!move_uploaded_file($file['tmp_name'], $dest)) {
            http_response_code(500);
            echo json_encode(['success' => false, 'error' => 'Failed to save file']);
            exit;
        }

        $url = fr_profile_pic_url($filename);
        if ($url === '') {
            @unlink($dest);
            http_response_code(500);
            echo json_encode(['success' => false, 'error' => 'Failed to generate logo URL']);
            exit;
        }

        echo json_encode([
            'success'  => true,
            'fileName' => $filename,
            'url'      => $url,
        ]);
        exit;
    }

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

        $usersDir     = rtrim(USERS_DIR, '/\\') . DIRECTORY_SEPARATOR;
        $publicPath   = $usersDir . 'siteConfig.json';
        $adminEncPath = $usersDir . 'adminConfig.json';

        $publicMtime = is_file($publicPath) ? (int)@filemtime($publicPath) : 0;
        $adminMtime  = is_file($adminEncPath) ? (int)@filemtime($adminEncPath) : 0;

        // If public cache is present and fresh enough, serve it (unless missing new keys)
        if ($publicMtime > 0 && $publicMtime >= $adminMtime) {
            $raw = @file_get_contents($publicPath);
            $data = is_string($raw) ? json_decode($raw, true) : null;

            // Pro search toggle is new; if it’s missing, fall through and regenerate.
            // Also ensure the Pro block carries the new "active" flag.
            if (is_array($data) && array_key_exists('proSearch', $data)) {
                $proBlock = isset($data['pro']) && is_array($data['pro']) ? $data['pro'] : [];
                $hasActive = array_key_exists('active', $proBlock);
                $cachedProActive = !empty($proBlock['active']);
                $cachedProVerRaw = isset($proBlock['version']) ? (string)$proBlock['version'] : '';
                $hasApiLevel = array_key_exists('apiLevel', $proBlock);
                $cachedProApiLevel = $hasApiLevel ? (int)$proBlock['apiLevel'] : 0;
                $cachedEnabled = !empty($data['proSearch']['enabled']);
                $cachedExplicitDisabled = array_key_exists('enabled', $data['proSearch']) && !$data['proSearch']['enabled'];

                // Current runtime Pro state (can differ from cached values after an upgrade/activation)
                $currentProActive = defined('FR_PRO_ACTIVE') && FR_PRO_ACTIVE;
                $currentProVerRaw = defined('FR_PRO_BUNDLE_VERSION') ? (string)FR_PRO_BUNDLE_VERSION : '';
                $currentProApiLevel = defined('FR_PRO_API_LEVEL') ? (int)FR_PRO_API_LEVEL : 0;
                $searchApiRequired = defined('FR_PRO_API_REQUIRE_SEARCH') ? (int)FR_PRO_API_REQUIRE_SEARCH : 3;
                $runtimeProOk = $currentProActive && $currentProApiLevel >= $searchApiRequired;
                // If runtime API level is missing, fall back to cached values when Pro shows active
                $cachedProOk = $cachedProActive && $cachedProApiLevel >= $searchApiRequired;
                $currentProOk = $runtimeProOk || $cachedProOk;

                $shouldRegenerate = false;
                // Missing pro.active flag in cache
                if (!$hasActive) {
                    $shouldRegenerate = true;
                }
                // Missing pro.apiLevel flag in cache
                if (!$hasApiLevel) {
                    $shouldRegenerate = true;
                }
                // Pro activation state changed since cache was written
                if ($currentProActive !== $cachedProActive) {
                    $shouldRegenerate = true;
                }
                // Pro version changed (keep UI hints in sync)
                if ($currentProActive && $currentProVerRaw !== '' && $cachedProVerRaw !== $currentProVerRaw) {
                    $shouldRegenerate = true;
                }
                // Pro API level changed (feature gating)
                if ($currentProActive && $currentProApiLevel !== $cachedProApiLevel) {
                    $shouldRegenerate = true;
                }
                // Auto-enable after API-level upgrade if cache still shows disabled
                if ($currentProOk && !$cachedEnabled && !$cachedExplicitDisabled) {
                    $shouldRegenerate = true;
                }

                if (!$shouldRegenerate) {
                    if (isset($data['branding']) && is_array($data['branding'])) {
                        $keys = [
                            'customLogoUrl',
                            'faviconSvg',
                            'faviconPng',
                            'faviconIco',
                            'appleTouchIcon',
                            'maskIcon',
                        ];
                        foreach ($keys as $k) {
                            if (!empty($data['branding'][$k])) {
                                $data['branding'][$k] = fr_normalize_profile_pic_url((string)$data['branding'][$k]);
                            }
                        }
                    }
                    echo json_encode($data);
                    return;
                }
            }
        }

        // Otherwise regenerate from decrypted admin config
        $cfg = AdminModel::getConfig();
        if (isset($cfg['error'])) {
            http_response_code(500);
            echo json_encode(['error' => $cfg['error']]);
            return;
        }

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

        // Ensure Search Everywhere auto-enables in siteConfig after Pro API-level upgrades
        $envProSearchRaw = getenv('FR_PRO_SEARCH_ENABLED');
        $proSearchLocked = ($envProSearchRaw !== false && $envProSearchRaw !== '');
        $proSearchOptOut = !empty($cfg['proSearch']['optOut']);
        $proSearchExplicitDisabled = array_key_exists('proSearch', $cfg)
            && is_array($cfg['proSearch'])
            && array_key_exists('enabled', $cfg['proSearch'])
            && !$cfg['proSearch']['enabled'];
        $proApiLevel = defined('FR_PRO_API_LEVEL') ? (int)FR_PRO_API_LEVEL : 0;
        $proSearchApiOk = (defined('FR_PRO_ACTIVE') && FR_PRO_ACTIVE)
            && $proApiLevel >= (defined('FR_PRO_API_REQUIRE_SEARCH') ? (int)FR_PRO_API_REQUIRE_SEARCH : 3);
        if ($proSearchApiOk && !$proSearchLocked && !$proSearchOptOut && !$proSearchExplicitDisabled) {
            $public['proSearch']['enabled'] = true;
        }

        $w = AdminModel::writeSiteConfig($public); // best effort
        echo json_encode($public);
    }
}
