<?php

namespace FileRise\Http\Controllers;

use FileRise\Support\ACL;
use FileRise\Support\AuditHook;
use FileRise\Support\CryptoAtRest;
use FileRise\Support\EventBus;
use FileRise\Support\FS;
use FileRise\Support\WorkerLauncher;
use FileRise\Storage\SourceContext;
use FileRise\Storage\StorageRegistry;
use FileRise\Domain\AdminModel;
use FileRise\Domain\FolderCrypto;
use FileRise\Domain\FolderMeta;
use FileRise\Domain\FolderModel;
use FileRise\Domain\TransferJobManager;
use FileRise\Domain\UploadModel;
use FileRise\Domain\UserModel as userModel;
use FilesystemIterator;
use RecursiveDirectoryIterator;
use RecursiveIteratorIterator;
use Throwable;

// src/controllers/FolderController.php

require_once dirname(__DIR__, 4) . '/config/config.php';
require_once PROJECT_ROOT . '/src/lib/ACL.php';
require_once PROJECT_ROOT . '/src/lib/FS.php';
require_once PROJECT_ROOT . '/src/lib/AuditHook.php';
require_once PROJECT_ROOT . '/src/lib/CryptoAtRest.php';
require_once PROJECT_ROOT . '/src/lib/StorageRegistry.php';
require_once PROJECT_ROOT . '/src/lib/SourceContext.php';

class FolderController
{
    private const SHARE_RATE_WINDOW_SECONDS = 60;
    private const SHARE_RATE_MAX_REQUESTS_PER_WINDOW = 180;
    private const SHARE_RATE_MAX_CONCURRENT = 4;
    private const SHARE_RATE_ACTIVE_TTL_SECONDS = 300;
    private const SHARE_DAILY_STATE_KEEP_DAYS = 14;
    private const SHARE_UPLOAD_LOG_MAX_BYTES = 5242880;
    private const SHARE_UPLOAD_LOG_MAX_FILES = 3;

    private ?array $jsonBodyOverride = null;

    /* -------------------- Session / Header helpers -------------------- */
    private static function ensureSession(): void
    {
        if (session_status() !== PHP_SESSION_ACTIVE) {
            session_start();
        }
    }

    private static function getHeadersLower(): array
    {
        if (function_exists('getallheaders')) {
            $h = getallheaders();
            if (is_array($h)) {
                return array_change_key_case($h, CASE_LOWER);
            }
        }
        $headers = [];
        foreach ($_SERVER as $k => $v) {
            if (strpos($k, 'HTTP_') === 0) {
                $name = strtolower(str_replace('_', '-', substr($k, 5)));
                $headers[$name] = $v;
            }
        }
        return $headers;
    }

    private static function getQueryString(string $key): string
    {
        $value = $_GET[$key] ?? '';
        if (is_array($value)) {
            return '';
        }
        $clean = preg_replace('/[\x00-\x1F\x7F]/', '', trim((string)$value));
        return is_string($clean) ? $clean : '';
    }

    private static function getQueryInt(string $key): ?int
    {
        $value = $_GET[$key] ?? null;
        if ($value === null || $value === '') {
            return null;
        }
        if (is_array($value)) {
            return null;
        }
        $raw = trim((string)$value);
        if (!preg_match('/^-?\d+$/', $raw)) {
            return null;
        }
        return (int)$raw;
    }

    private function normalizeSourceId($id): string
    {
        $id = trim((string)$id);
        if ($id === '' || !preg_match('/^[A-Za-z0-9_-]{1,64}$/', $id)) {
            return '';
        }
        return $id;
    }

    public function setJsonBodyOverride(?array $payload): void
    {
        $this->jsonBodyOverride = $payload;
    }

    private function readJsonBody(): array
    {
        if (is_array($this->jsonBodyOverride)) {
            return $this->jsonBodyOverride;
        }
        $raw = file_get_contents('php://input') ?: '';
        $in = json_decode($raw, true);
        return is_array($in) ? $in : [];
    }

    private function truthy($value): bool
    {
        if (is_bool($value)) {
            return $value;
        }
        if (is_int($value) || is_float($value)) {
            return ((int)$value) !== 0;
        }
        $s = strtolower(trim((string)$value));
        return in_array($s, ['1', 'true', 'yes', 'on'], true);
    }

    private static function shareTokenFingerprint(string $token): string
    {
        return substr(hash('sha256', $token), 0, 24);
    }

    private static function defaultSharedAllowedTypes(): array
    {
        return [
            'jpg', 'jpeg', 'png', 'gif', 'pdf', 'doc', 'docx', 'txt', 'xls', 'xlsx',
            'ppt', 'pptx', 'mp4', 'webm', 'mp3', 'mkv', 'csv', 'json', 'xml', 'md'
        ];
    }

    private static function normalizeSharedAllowedTypes($raw): array
    {
        $items = [];
        if (is_string($raw)) {
            $items = preg_split('/[\s,;]+/', $raw) ?: [];
        } elseif (is_array($raw)) {
            $items = $raw;
        }

        $out = [];
        foreach ($items as $item) {
            $ext = strtolower(trim((string)$item));
            $ext = ltrim($ext, '.');
            if ($ext === '') {
                continue;
            }
            if (!preg_match('/^[a-z0-9][a-z0-9._+-]{0,31}$/', $ext)) {
                continue;
            }
            $out[$ext] = $ext;
        }
        return array_values($out);
    }

    private static function validateSharedUploadRules(array $record, string $filename, int $sizeBytes): ?string
    {
        $ext = strtolower(pathinfo($filename, PATHINFO_EXTENSION));
        if ($ext === 'svg' || $ext === 'svgz') {
            return 'Upload blocked: SVG files are not allowed in shared folders.';
        }

        $maxBytes = 50 * 1024 * 1024;
        $maxFileSizeMb = isset($record['maxFileSizeMb']) && is_numeric($record['maxFileSizeMb'])
            ? (int)$record['maxFileSizeMb']
            : 0;
        if ($maxFileSizeMb > 0) {
            $maxBytes = $maxFileSizeMb * 1024 * 1024;
        } else {
            $adminConfig = AdminModel::getConfig();
            if (isset($adminConfig['sharedMaxUploadSize']) && is_numeric($adminConfig['sharedMaxUploadSize'])) {
                $cfgBytes = (int)$adminConfig['sharedMaxUploadSize'];
                if ($cfgBytes > 0) {
                    $maxBytes = $cfgBytes;
                }
            }
        }
        if ($sizeBytes > 0 && $sizeBytes > $maxBytes) {
            return 'File size exceeds allowed limit.';
        }

        $allowedTypes = self::normalizeSharedAllowedTypes($record['allowedTypes'] ?? []);
        if (empty($allowedTypes)) {
            $allowedTypes = self::defaultSharedAllowedTypes();
        }
        if ($ext === '' || !in_array($ext, $allowedTypes, true)) {
            return 'File type not allowed.';
        }

        return null;
    }

    private static function getShareStateDir(): string
    {
        $metaRoot = class_exists('SourceContext')
            ? SourceContext::metaRoot()
            : rtrim((string)META_DIR, '/\\') . DIRECTORY_SEPARATOR;
        $dir = rtrim($metaRoot, '/\\') . DIRECTORY_SEPARATOR . 'share_upload_state';
        if (!is_dir($dir)) {
            @mkdir($dir, 0775, true);
        }
        return $dir;
    }

    private static function withLockedJsonState(string $path, callable $mutator): void
    {
        $fp = @fopen($path, 'c+');
        if ($fp === false) {
            return;
        }
        try {
            if (!@flock($fp, LOCK_EX)) {
                return;
            }
            $raw = stream_get_contents($fp);
            $state = [];
            if (is_string($raw) && trim($raw) !== '') {
                $decoded = json_decode($raw, true);
                if (is_array($decoded)) {
                    $state = $decoded;
                }
            }
            $next = $mutator($state);
            if (!is_array($next)) {
                $next = $state;
            }
            @ftruncate($fp, 0);
            @rewind($fp);
            @fwrite($fp, json_encode($next, JSON_PRETTY_PRINT));
            @fflush($fp);
            @flock($fp, LOCK_UN);
        } finally {
            @fclose($fp);
        }
    }

    private static function applySharedUploadRateLimit(string $tokenHash, string $ip): ?array
    {
        $dir = self::getShareStateDir();
        $path = rtrim($dir, '/\\') . DIRECTORY_SEPARATOR . 'rate_limits.json';
        $now = time();
        $windowStart = $now - self::SHARE_RATE_WINDOW_SECONDS;
        $activeCutoff = $now - self::SHARE_RATE_ACTIVE_TTL_SECONDS;
        $key = $tokenHash . '|' . $ip;
        try {
            $slotId = bin2hex(random_bytes(8));
        } catch (\Throwable $e) {
            $slotId = substr(hash('sha256', uniqid('', true)), 0, 16);
        }

        $decision = ['ok' => true, 'retryAfter' => 0];
        self::withLockedJsonState($path, function (array $state) use ($key, $slotId, $now, $windowStart, $activeCutoff, &$decision) {
            $hits = isset($state['hits']) && is_array($state['hits']) ? $state['hits'] : [];
            $active = isset($state['active']) && is_array($state['active']) ? $state['active'] : [];

            foreach ($hits as $k => $arr) {
                if (!is_array($arr)) {
                    unset($hits[$k]);
                    continue;
                }
                $hits[$k] = array_values(array_filter($arr, function ($ts) use ($windowStart) {
                    return is_numeric($ts) && (int)$ts >= $windowStart;
                }));
                if (empty($hits[$k])) {
                    unset($hits[$k]);
                }
            }
            foreach ($active as $k => $slots) {
                if (!is_array($slots)) {
                    unset($active[$k]);
                    continue;
                }
                foreach ($slots as $sid => $ts) {
                    if (!is_numeric($ts) || (int)$ts < $activeCutoff) {
                        unset($slots[$sid]);
                    }
                }
                if (empty($slots)) {
                    unset($active[$k]);
                } else {
                    $active[$k] = $slots;
                }
            }

            $keyHits = $hits[$key] ?? [];
            if (count($keyHits) >= self::SHARE_RATE_MAX_REQUESTS_PER_WINDOW) {
                $oldest = min(array_map(static fn($v) => (int)$v, $keyHits));
                $retryAfter = max(1, self::SHARE_RATE_WINDOW_SECONDS - max(0, $now - $oldest));
                $decision = ['ok' => false, 'retryAfter' => $retryAfter];
                $state['hits'] = $hits;
                $state['active'] = $active;
                return $state;
            }

            $keyActive = $active[$key] ?? [];
            if (count($keyActive) >= self::SHARE_RATE_MAX_CONCURRENT) {
                $decision = ['ok' => false, 'retryAfter' => 1];
                $state['hits'] = $hits;
                $state['active'] = $active;
                return $state;
            }

            $keyHits[] = $now;
            $hits[$key] = $keyHits;
            $keyActive[$slotId] = $now;
            $active[$key] = $keyActive;

            $state['hits'] = $hits;
            $state['active'] = $active;
            return $state;
        });

        if (empty($decision['ok'])) {
            return [
                'error' => 'Too many upload requests for this link. Please retry shortly.',
                'retryAfter' => max(1, (int)($decision['retryAfter'] ?? 1)),
            ];
        }

        register_shutdown_function(function () use ($path, $key, $slotId): void {
            self::withLockedJsonState($path, function (array $state) use ($key, $slotId) {
                if (!isset($state['active']) || !is_array($state['active'])) {
                    return $state;
                }
                if (isset($state['active'][$key]) && is_array($state['active'][$key])) {
                    unset($state['active'][$key][$slotId]);
                    if (empty($state['active'][$key])) {
                        unset($state['active'][$key]);
                    }
                }
                return $state;
            });
        });

        return null;
    }

    private static function checkSharedDailyQuota(array $record, string $tokenHash, int $sizeBytes): ?string
    {
        $dailyFileLimit = isset($record['dailyFileLimit']) && is_numeric($record['dailyFileLimit'])
            ? max(0, (int)$record['dailyFileLimit'])
            : 0;
        $maxTotalMbPerDay = isset($record['maxTotalMbPerDay']) && is_numeric($record['maxTotalMbPerDay'])
            ? max(0, (int)$record['maxTotalMbPerDay'])
            : 0;
        if ($dailyFileLimit <= 0 && $maxTotalMbPerDay <= 0) {
            return null;
        }

        $path = rtrim(self::getShareStateDir(), '/\\') . DIRECTORY_SEPARATOR . 'daily_quota.json';
        $today = gmdate('Y-m-d');
        $maxTotalBytes = ($maxTotalMbPerDay > 0) ? ($maxTotalMbPerDay * 1024 * 1024) : 0;
        $error = null;

        self::withLockedJsonState($path, function (array $state) use ($today, $tokenHash, $dailyFileLimit, $maxTotalBytes, $sizeBytes, &$error) {
            $tokens = isset($state['tokens']) && is_array($state['tokens']) ? $state['tokens'] : [];
            $keepDays = self::SHARE_DAILY_STATE_KEEP_DAYS;
            $cutoff = gmdate('Y-m-d', strtotime('-' . max(1, $keepDays) . ' days'));

            foreach ($tokens as $tk => $bucket) {
                if (!is_array($bucket)) {
                    unset($tokens[$tk]);
                    continue;
                }
                $days = isset($bucket['days']) && is_array($bucket['days']) ? $bucket['days'] : [];
                foreach ($days as $day => $row) {
                    if (!is_string($day) || $day < $cutoff) {
                        unset($days[$day]);
                    }
                }
                if (empty($days)) {
                    unset($tokens[$tk]);
                } else {
                    $tokens[$tk] = ['days' => $days];
                }
            }

            $entry = $tokens[$tokenHash]['days'][$today] ?? ['files' => 0, 'bytes' => 0];
            $files = is_numeric($entry['files'] ?? null) ? (int)$entry['files'] : 0;
            $bytes = is_numeric($entry['bytes'] ?? null) ? (int)$entry['bytes'] : 0;

            if ($dailyFileLimit > 0 && ($files + 1) > $dailyFileLimit) {
                $error = 'Daily upload file limit reached for this share.';
            } elseif ($maxTotalBytes > 0 && ($bytes + max(0, $sizeBytes)) > $maxTotalBytes) {
                $error = 'Daily upload size limit reached for this share.';
            }

            $state['tokens'] = $tokens;
            return $state;
        });

        return $error;
    }

    private static function incrementSharedDailyQuota(string $tokenHash, int $sizeBytes): void
    {
        $path = rtrim(self::getShareStateDir(), '/\\') . DIRECTORY_SEPARATOR . 'daily_quota.json';
        $today = gmdate('Y-m-d');
        self::withLockedJsonState($path, function (array $state) use ($today, $tokenHash, $sizeBytes) {
            $tokens = isset($state['tokens']) && is_array($state['tokens']) ? $state['tokens'] : [];
            $bucket = isset($tokens[$tokenHash]['days'][$today]) && is_array($tokens[$tokenHash]['days'][$today])
                ? $tokens[$tokenHash]['days'][$today]
                : ['files' => 0, 'bytes' => 0];
            $bucket['files'] = (int)($bucket['files'] ?? 0) + 1;
            $bucket['bytes'] = (int)($bucket['bytes'] ?? 0) + max(0, $sizeBytes);

            if (!isset($tokens[$tokenHash]) || !is_array($tokens[$tokenHash])) {
                $tokens[$tokenHash] = ['days' => []];
            }
            if (!isset($tokens[$tokenHash]['days']) || !is_array($tokens[$tokenHash]['days'])) {
                $tokens[$tokenHash]['days'] = [];
            }
            $tokens[$tokenHash]['days'][$today] = $bucket;
            $state['tokens'] = $tokens;
            return $state;
        });
    }

    private static function normalizeHeaderServerKey(string $header): string
    {
        $key = strtoupper(str_replace('-', '_', trim($header)));
        if ($key === 'REMOTE_ADDR') {
            return $key;
        }
        if (strpos($key, 'HTTP_') !== 0) {
            $key = 'HTTP_' . $key;
        }
        return $key;
    }

    private static function trustedProxies(): array
    {
        $raw = defined('FR_TRUSTED_PROXIES') ? (string)FR_TRUSTED_PROXIES : '';
        if ($raw === '') {
            return [];
        }
        $parts = array_map('trim', explode(',', $raw));
        return array_values(array_filter($parts, static fn($part) => $part !== ''));
    }

    private static function ipInCidr(string $ip, string $cidr): bool
    {
        $cidr = trim($cidr);
        if ($cidr === '' || strpos($cidr, '/') === false) {
            return false;
        }
        [$subnet, $maskRaw] = explode('/', $cidr, 2);
        $subnet = trim($subnet);
        $mask = (int)trim($maskRaw);

        if (!filter_var($ip, FILTER_VALIDATE_IP) || !filter_var($subnet, FILTER_VALIDATE_IP)) {
            return false;
        }

        if (strpos($ip, ':') !== false || strpos($subnet, ':') !== false) {
            if ($mask < 0 || $mask > 128) {
                return false;
            }
            $ipBin = inet_pton($ip);
            $netBin = inet_pton($subnet);
            if ($ipBin === false || $netBin === false) {
                return false;
            }
            $bytes = intdiv($mask, 8);
            $bits = $mask % 8;
            if ($bytes > 0 && substr($ipBin, 0, $bytes) !== substr($netBin, 0, $bytes)) {
                return false;
            }
            if ($bits > 0) {
                $maskByte = (~((1 << (8 - $bits)) - 1)) & 0xFF;
                if ((ord($ipBin[$bytes]) & $maskByte) !== (ord($netBin[$bytes]) & $maskByte)) {
                    return false;
                }
            }
            return true;
        }

        if ($mask < 0 || $mask > 32) {
            return false;
        }
        $ipLong = ip2long($ip);
        $netLong = ip2long($subnet);
        if ($ipLong === false || $netLong === false) {
            return false;
        }
        $maskLong = $mask === 0 ? 0 : (-1 << (32 - $mask));
        return (($ipLong & $maskLong) === ($netLong & $maskLong));
    }

    private static function isTrustedProxy(string $ip, array $trusted): bool
    {
        foreach ($trusted as $entry) {
            $entry = trim((string)$entry);
            if ($entry === '') {
                continue;
            }
            if (strpos($entry, '/') === false) {
                if ($ip === $entry) {
                    return true;
                }
                continue;
            }
            if (self::ipInCidr($ip, $entry)) {
                return true;
            }
        }
        return false;
    }

    private static function detectSharedClientIp(): string
    {
        $remote = trim((string)($_SERVER['REMOTE_ADDR'] ?? ''));
        if ($remote === '') {
            $remote = '0.0.0.0';
        }

        $trusted = self::trustedProxies();
        if (empty($trusted) || !self::isTrustedProxy($remote, $trusted)) {
            return $remote;
        }

        $headerName = defined('FR_IP_HEADER') ? (string)FR_IP_HEADER : 'X-Forwarded-For';
        $serverKey = self::normalizeHeaderServerKey($headerName);
        $raw = (string)($_SERVER[$serverKey] ?? '');
        if ($raw !== '') {
            $parts = explode(',', $raw);
            foreach ($parts as $part) {
                $candidate = trim($part);
                if ($candidate !== '' && filter_var($candidate, FILTER_VALIDATE_IP)) {
                    return $candidate;
                }
            }
        }

        if (!empty($_SERVER['HTTP_X_REAL_IP'])) {
            $candidate = trim((string)$_SERVER['HTTP_X_REAL_IP']);
            if ($candidate !== '' && filter_var($candidate, FILTER_VALIDATE_IP)) {
                return $candidate;
            }
        }

        return $remote;
    }

    private static function rotateJsonLog(string $path, int $maxBytes, int $maxFiles): void
    {
        if ($maxBytes <= 0 || $maxFiles <= 1 || !is_file($path)) {
            return;
        }
        $size = @filesize($path);
        if ($size === false || $size < $maxBytes) {
            return;
        }

        $maxRotated = $maxFiles - 1;
        for ($i = $maxRotated; $i >= 1; $i--) {
            $src = $path . '.' . $i;
            if ($i === $maxRotated) {
                if (is_file($src)) {
                    @unlink($src);
                }
                continue;
            }
            $dst = $path . '.' . ($i + 1);
            if (is_file($src)) {
                @rename($src, $dst);
            }
        }
        @rename($path, $path . '.1');
    }

    private static function logSharedUploadSubmission(string $tokenHash, string $ip, string $path, int $bytes): void
    {
        $metaRoot = class_exists('SourceContext')
            ? SourceContext::metaRoot()
            : rtrim((string)META_DIR, '/\\') . DIRECTORY_SEPARATOR;
        $logPath = rtrim($metaRoot, '/\\') . DIRECTORY_SEPARATOR . 'share_upload_submissions.log';
        self::rotateJsonLog($logPath, self::SHARE_UPLOAD_LOG_MAX_BYTES, self::SHARE_UPLOAD_LOG_MAX_FILES);

        $entry = [
            'createdAt' => gmdate('c'),
            'tokenHash' => $tokenHash,
            'ip' => $ip,
            'path' => $path,
            'bytes' => max(0, $bytes),
            'userAgent' => (string)($_SERVER['HTTP_USER_AGENT'] ?? ''),
        ];

        @file_put_contents(
            $logPath,
            json_encode($entry, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES) . PHP_EOL,
            FILE_APPEND | LOCK_EX
        );
    }

    private static function parseUploadRelativePath(string $raw): array
    {
        $raw = rawurldecode((string)$raw);
        $raw = str_replace('\\', '/', trim($raw));
        $raw = preg_replace('/[\x00-\x1F\x7F]/', '', $raw);
        $raw = ltrim($raw, '/');
        if ($raw === '' || $raw === '.') {
            return ['', '', null];
        }
        if (preg_match('~(^|/)\.\.(?:/|$)~', $raw) || preg_match('~(^|/)\.(?:/|$)~', $raw)) {
            return ['', '', 'Invalid relative path.'];
        }

        $file = basename($raw);
        if ($file === '' || !preg_match(REGEX_FILE_NAME, $file)) {
            return ['', '', 'Invalid file name.'];
        }

        $dir = dirname($raw);
        if ($dir === '.' || $dir === '') {
            return ['', $file, null];
        }
        if (!preg_match(REGEX_FOLDER_NAME, $dir)) {
            return ['', '', 'Invalid folder name.'];
        }
        return [$dir, $file, null];
    }

    private static function wantsJsonUploadResponse(bool $isChunk): bool
    {
        if ($isChunk) {
            return true;
        }
        if (isset($_POST['response']) && strtolower(trim((string)$_POST['response'])) === 'json') {
            return true;
        }
        $accept = strtolower((string)($_SERVER['HTTP_ACCEPT'] ?? ''));
        if ($accept !== '' && strpos($accept, 'application/json') !== false) {
            return true;
        }
        $xrw = strtolower((string)($_SERVER['HTTP_X_REQUESTED_WITH'] ?? ''));
        return $xrw === 'xmlhttprequest';
    }

    private function isAsyncRequested(array $payload): bool
    {
        return $this->truthy($payload['async'] ?? false)
            || $this->truthy($payload['queue'] ?? false)
            || $this->truthy($payload['asyncJob'] ?? false);
    }

    private function enqueueTransferJob(array $jobSpec): array
    {
        try {
            $user = trim((string)($jobSpec['user'] ?? ''));
            if ($user === '') {
                return ['error' => 'Missing transfer job user.'];
            }
            $jobSpec['user'] = $user;

            $selectedFiles = (int)($jobSpec['selectedFiles'] ?? 0);
            $selectedBytes = (int)($jobSpec['selectedBytes'] ?? 0);
            if ($selectedFiles < 0) {
                $selectedFiles = 0;
            }
            if ($selectedBytes < 0) {
                $selectedBytes = 0;
            }
            $jobSpec['selectedFiles'] = $selectedFiles;
            $jobSpec['selectedBytes'] = $selectedBytes;

            $created = TransferJobManager::create($jobSpec);
            $jobId = (string)($created['id'] ?? '');
            if ($jobId === '') {
                return ['error' => 'Failed to create transfer job.'];
            }

            if (WorkerLauncher::prefersSync() && WorkerLauncher::allowsForegroundFallback() && TransferJobManager::canRunWorkerForeground()) {
                $run = TransferJobManager::runWorkerForeground($jobId);
                if (empty($run['ok'])) {
                    $job = TransferJobManager::load($jobId) ?: [];
                    $job['status'] = 'error';
                    $job['phase'] = 'error';
                    $job['error'] = 'Worker foreground run failed: ' . (string)($run['error'] ?? 'Unknown error');
                    $job['endedAt'] = time();
                    TransferJobManager::save($jobId, $job);
                    return ['error' => 'Failed to run transfer worker: ' . (string)($run['error'] ?? 'Unknown error')];
                }

                $fresh = TransferJobManager::load($jobId) ?: [];
                return [
                    'ok' => true,
                    'jobId' => $jobId,
                    'status' => (string)($fresh['status'] ?? 'done'),
                    'statusUrl' => '/api/file/transferJobStatus.php?jobId=' . urlencode($jobId),
                ];
            }

            $spawn = TransferJobManager::spawnWorker($jobId);
            if (empty($spawn['ok'])) {
                if (WorkerLauncher::allowsForegroundFallback() && TransferJobManager::canRunWorkerForeground()) {
                    $run = TransferJobManager::runWorkerForeground($jobId);
                    if (!empty($run['ok'])) {
                        $fresh = TransferJobManager::load($jobId) ?: [];
                        return [
                            'ok' => true,
                            'jobId' => $jobId,
                            'status' => (string)($fresh['status'] ?? 'done'),
                            'statusUrl' => '/api/file/transferJobStatus.php?jobId=' . urlencode($jobId),
                        ];
                    }
                }

                $job = TransferJobManager::load($jobId) ?: [];
                $job['status'] = 'error';
                $job['phase'] = 'error';
                $job['error'] = 'Worker spawn failed: ' . (string)($spawn['error'] ?? 'Unknown error');
                $job['endedAt'] = time();
                TransferJobManager::save($jobId, $job);
                return ['error' => 'Failed to start transfer worker: ' . (string)($spawn['error'] ?? 'Unknown error')];
            }

            return [
                'ok' => true,
                'jobId' => $jobId,
                'status' => 'queued',
                'statusUrl' => '/api/file/transferJobStatus.php?jobId=' . urlencode($jobId),
            ];
        } catch (\Throwable $e) {
            return ['error' => 'Failed to queue transfer job.'];
        }
    }

    private function withSourceContext(string $sourceId, callable $fn, bool $allowDisabled = false)
    {
        if (!class_exists('SourceContext') || $sourceId === '') {
            return $fn();
        }
        $prev = SourceContext::getActiveId();
        SourceContext::setActiveId($sourceId, false, $allowDisabled);
        try {
            return $fn();
        } finally {
            SourceContext::setActiveId($prev, false);
        }
    }

    private function crossSourceEncryptedError(
        string $sourceId,
        string $sourceFolder,
        string $destSourceId,
        string $destFolder
    ): ?string {
        if (!class_exists('SourceContext')) {
            return null;
        }
        $srcEncrypted = (bool)$this->withSourceContext($sourceId, function () use ($sourceFolder) {
            try {
                return FolderCrypto::isEncryptedOrAncestor($sourceFolder);
            } catch (\Throwable $e) {
                return false;
            }
        });
        $dstEncrypted = (bool)$this->withSourceContext($destSourceId, function () use ($destFolder) {
            try {
                return FolderCrypto::isEncryptedOrAncestor($destFolder);
            } catch (\Throwable $e) {
                return false;
            }
        });
        if ($srcEncrypted || $dstEncrypted) {
            return 'Encrypted folders are not supported for cross-source copy/move.';
        }
        return null;
    }

    public static function listChildren(string $folder, string $user, array $perms, ?string $cursor = null, int $limit = 500, bool $probe = true): array
    {
        return FolderModel::listChildren($folder, $user, $perms, $cursor, $limit, $probe);
    }

    /** Stats for a folder (folders/files/bytes; deep totals are opt-in). */
    public static function stats(string $folder, string $user, array $perms, bool $deep = false, ?int $maxDepth = null): array
    {
        // Normalize inside model; this is a thin action
        if (!$deep) {
            return FolderModel::countVisible($folder, $user, $perms);
        }

        if ($maxDepth !== null) {
            $maxDepth = (int)$maxDepth;
            if ($maxDepth <= 0) {
                $maxDepth = null;
            } else {
                $maxDepth = min($maxDepth, 10);
            }
        }

        return FolderModel::countVisibleDeep($folder, $user, $perms, 20000, $maxDepth);
    }

    /** Capabilities for UI buttons/menus (unchanged semantics; just centralized). */
    public static function capabilities(string $folder, string $username): array
    {
        $folder = ACL::normalizeFolder($folder);
        $perms  = self::loadPermsFor($username);

        $isAdmin       = ACL::isAdmin($perms);
        $folderOnly    = self::boolFrom($perms, 'folderOnly', 'userFolderOnly', 'UserFolderOnly');
        $readOnly      = !empty($perms['readOnly']) || (class_exists('SourceContext') && SourceContext::isReadOnly());
        $disableUpload = !empty($perms['disableUpload']);

        $isOwner = ACL::isOwner($username, $perms, $folder);

        $inScope = self::inUserFolderScope($folder, $username, $perms, $isAdmin, $folderOnly);

        $canViewBase   = $isAdmin || ACL::canRead($username, $perms, $folder);
        $canViewOwn    = $isAdmin || ACL::canReadOwn($username, $perms, $folder);
        $canShareBase  = $isAdmin || ACL::canShare($username, $perms, $folder);

        $gCreateBase   = $isAdmin || ACL::canCreate($username, $perms, $folder);
        $gRenameBase   = $isAdmin || ACL::canRename($username, $perms, $folder);
        $gDeleteBase   = $isAdmin || ACL::canDelete($username, $perms, $folder);
        $gMoveBase     = $isAdmin || ACL::canMove($username, $perms, $folder);
        $gUploadBase   = $isAdmin || ACL::canUpload($username, $perms, $folder);
        $gEditBase     = $isAdmin || ACL::canEdit($username, $perms, $folder);
        $gCopyBase     = $isAdmin || ACL::canCopy($username, $perms, $folder);
        $gExtractBase  = $isAdmin || ACL::canExtract($username, $perms, $folder);
        $gShareFile    = $isAdmin || ACL::canShareFile($username, $perms, $folder);
        $gShareFolder  = $isAdmin || ACL::canShareFolder($username, $perms, $folder);

        $canView       = $canViewBase && $inScope;

        $canUpload     = $gUploadBase && !$readOnly && !$disableUpload && $inScope;
        $canCreate     = $gCreateBase && !$readOnly && $inScope;
        $canRename     = $gRenameBase && !$readOnly && $inScope;
        $canDelete     = $gDeleteBase && !$readOnly && $inScope;
        $canDeleteFile = $gDeleteBase && !$readOnly && $inScope;

        $canDeleteFolder = !$readOnly && $inScope && (
            $isAdmin ||
            $isOwner ||
            ACL::canManage($username, $perms, $folder) ||
            $gDeleteBase // if your ACL::canDelete should also allow folder deletes
        );

        $canReceive    = ($gUploadBase || $gCreateBase || $isAdmin) && !$readOnly && !$disableUpload && $inScope;
        $canMoveIn     = $canReceive;

        $canEdit       = $gEditBase && !$readOnly && $inScope;
        $canCopy       = $gCopyBase && !$readOnly && $inScope;
        $canExtract    = $gExtractBase && !$readOnly && $inScope;

        $canShareEff   = $canShareBase && $inScope;
        $canShareFile  = $gShareFile   && $inScope;
        $canShareFold  = $gShareFolder && $inScope;

        $canAudit      = $isAdmin || self::ownsFolderOrAncestor($folder, $username, $perms);

        // Encryption-at-rest status for this folder (and descendants)
        $enc = [
            'supported' => false,
            'hasMasterKey' => false,
            'encrypted' => false,
            'rootEncrypted' => false,
            'inherited' => false,
            'root' => null,
            'job' => [
                'active' => false,
                'root' => null,
                'id' => null,
                'type' => null,
                'state' => null,
                'error' => null,
            ],
            'canEncrypt' => false,
            'canDecrypt' => false,
        ];
        try {
            $enc['supported'] = CryptoAtRest::isAvailable();
            $enc['hasMasterKey'] = CryptoAtRest::masterKeyIsConfigured();
            $st = FolderCrypto::getStatus($folder);
            $enc['encrypted'] = !empty($st['encrypted']);
            $enc['rootEncrypted'] = !empty($st['rootEncrypted']);
            $enc['inherited'] = !empty($st['inherited']);
            $enc['root'] = $st['root'] ?? null;

            $jobSt = FolderCrypto::getJobStatus($folder);
            if (!empty($jobSt['active']) && !empty($jobSt['job']) && is_array($jobSt['job'])) {
                $enc['job'] = [
                    'active' => true,
                    'root' => $jobSt['root'] ?? null,
                    'id' => $jobSt['job']['id'] ?? null,
                    'type' => $jobSt['job']['type'] ?? null,
                    'state' => $jobSt['job']['state'] ?? null,
                    'error' => $jobSt['job']['error'] ?? null,
                ];
            }
        } catch (\Throwable $e) {
            // keep defaults
        }

        // Treat folders as restricted during an active crypto job (even if not fully encrypted yet).
        if (!empty($enc['job']['active'])) {
            $enc['encrypted'] = true;
        }

        if (!empty($enc['encrypted'])) {
            // v1 enforcement: no shares / no ZIP operations inside encrypted folders
            $canShareEff = false;
            $canShareFile = false;
            $canShareFold = false;
            $canExtract = false;
        }

        $isRoot = ($folder === 'root');
        $canMoveFolder = false;
        if ($isRoot) {
            $canRename     = false;
            $canDelete     = false;
            $canShareFold  = false;
        } else {
            $canMoveFolder = (ACL::canManage($username, $perms, $folder) || ACL::isOwner($username, $perms, $folder))
                && !$readOnly;
        }

        $owner = null;
        try {
            if (class_exists(FolderModel::class) && method_exists(FolderModel::class, 'getOwnerFor')) {
                $owner = FolderModel::getOwnerFor($folder);
            }
        } catch (\Throwable $e) {
        }

        // Allow folder encryption toggles for admins and folder-managers/owners (not within inherited encrypted trees)
        $canManageForEncryption = $isAdmin
            || ACL::canManage($username, $perms, $folder)
            || $isOwner;
        if ($isRoot && !$isAdmin) {
            $canManageForEncryption = false;
        }

        if (!empty($enc['supported']) && !empty($enc['hasMasterKey']) && $canManageForEncryption && empty($enc['inherited'])) {
            $enc['canEncrypt'] = empty($enc['encrypted']) && !$readOnly;
            $enc['canDecrypt'] = !empty($enc['rootEncrypted']) && !$readOnly;
        }

        // During a crypto job, disable toggles to avoid multiple concurrent operations.
        if (!empty($enc['job']['active'])) {
            $enc['canEncrypt'] = false;
            $enc['canDecrypt'] = false;
        }

        return [
            'user'    => $username,
            'folder'  => $folder,
            'isAdmin' => $isAdmin,
            'flags'   => [
                'folderOnly'    => $folderOnly,
                'readOnly'      => $readOnly,
                'disableUpload' => $disableUpload,
            ],
            'owner'          => $owner,

            'canView'        => $canView,
            'canViewOwn'     => $canViewOwn,

            'canUpload'      => $canUpload,
            'canCreate'      => $canCreate,
            'canRename'      => $canRename,
            'canDelete'      => $canDeleteFile,
            'canDeleteFolder' => $canDeleteFolder,

            'canMoveIn'      => $canMoveIn,
            'canMove'        => $canMoveIn,         // legacy alias
            'canMoveFolder'  => $canMoveFolder,

            'canEdit'        => $canEdit,
            'canCopy'        => $canCopy,
            'canExtract'     => $canExtract,

            'canShare'       => $canShareEff,       // legacy umbrella
            'canShareFile'   => $canShareFile,
            'canShareFolder' => $canShareFold,
            'canAudit'       => $canAudit,

            'encryption'     => $enc,
        ];
    }

    /* ---------------------------
       Private helpers (caps)
    ----------------------------*/
    private static function loadPermsFor(string $u): array
    {
        try {
            if (function_exists('loadUserPermissions')) {
                $p = loadUserPermissions($u);
                return is_array($p) ? $p : [];
            }
            if (class_exists(userModel::class) && method_exists(userModel::class, 'getUserPermissions')) {
                $all = userModel::getUserPermissions();
                if (is_array($all)) {
                    if (isset($all[$u])) {
                        return (array)$all[$u];
                    }
                    $lk = strtolower($u);
                    if (isset($all[$lk])) {
                        return (array)$all[$lk];
                    }
                }
            }
        } catch (\Throwable $e) {
        }
        return [];
    }

    private static function boolFrom(array $a, string ...$keys): bool
    {
        foreach ($keys as $k) {
            if (!empty($a[$k])) {
                return true;
            }
        }
        return false;
    }

    private static function isOwnerOrAncestorOwner(string $user, array $perms, string $folder): bool
    {
        $f = ACL::normalizeFolder($folder);
        if (ACL::isOwner($user, $perms, $f)) {
            return true;
        }
        while ($f !== '' && strcasecmp($f, 'root') !== 0) {
            $pos = strrpos($f, '/');
            if ($pos === false) {
                break;
            }
            $f = substr($f, 0, $pos);
            if ($f === '' || strcasecmp($f, 'root') === 0) {
                break;
            }
            if (ACL::isOwner($user, $perms, $f)) {
                return true;
            }
        }
        return false;
    }

    private static function inUserFolderScope(string $folder, string $u, array $perms, bool $isAdmin, bool $folderOnly): bool
    {
        if ($isAdmin) {
            return true;
        }
        if (!$folderOnly) {
            return true; // normal users: global scope
        }

        $f = ACL::normalizeFolder($folder);
        if ($f === 'root' || $f === '') {
            return self::isOwnerOrAncestorOwner($u, $perms, $f);
        }
        if ($f === $u || str_starts_with($f, $u . '/')) {
            return true;
        }
        return self::isOwnerOrAncestorOwner($u, $perms, $f);
    }

    private static function requireCsrf(): void
    {
        self::ensureSession();
        $headers  = self::getHeadersLower();
        $received = trim($headers['x-csrf-token'] ?? ($_POST['csrfToken'] ?? ''));
        if (!isset($_SESSION['csrf_token']) || $received !== $_SESSION['csrf_token']) {
            http_response_code(403);
            header('Content-Type: application/json');
            echo json_encode(['error' => 'Invalid CSRF token']);
            exit;
        }
    }

    private static function requireAuth(): void
    {
        self::ensureSession();
        if (empty($_SESSION['authenticated']) || $_SESSION['authenticated'] !== true) {
            http_response_code(401);
            header('Content-Type: application/json');
            echo json_encode(['error' => 'Unauthorized']);
            exit;
        }
    }
    private static function releaseSessionLock(): void
    {
        if (session_status() === PHP_SESSION_ACTIVE) {
            @session_write_close();
        }
    }

    /* -------------------- Permissions helpers -------------------- */
    private static function loadPerms(string $username): array
    {
        try {
            if (function_exists('loadUserPermissions')) {
                $p = loadUserPermissions($username);
                return is_array($p) ? $p : [];
            }
            if (class_exists(userModel::class) && method_exists(userModel::class, 'getUserPermissions')) {
                $all = userModel::getUserPermissions();
                if (is_array($all)) {
                    if (isset($all[$username])) {
                        return (array)$all[$username];
                    }
                    $lk = strtolower($username);
                    if (isset($all[$lk])) {
                        return (array)$all[$lk];
                    }
                }
            }
        } catch (\Throwable $e) { /* ignore */
        }
        return [];
    }

    private static function migrateFolderColors(string $source, string $target): array
    {
        // PHP 8 polyfill
        if (!function_exists('str_starts_with')) {
            function str_starts_with(string $haystack, string $needle): bool
            {
                return $needle === '' || strncmp($haystack, $needle, strlen($needle)) === 0;
            }
        }

        $metaDir = class_exists('SourceContext')
            ? rtrim(SourceContext::metaRoot(), '/\\')
            : rtrim((string)META_DIR, '/\\');
        $file = $metaDir . '/folder_colors.json';

        // Read current map (treat unreadable/invalid as empty)
        $raw = @file_get_contents($file);
        $map = is_string($raw) ? json_decode($raw, true) : [];
        if (!is_array($map)) {
            $map = [];
        }

        // Nothing to do fast-path
        $prefixSrc  = $source;
        $prefixNeed = $source . '/';
        $changed = false;
        $new = $map;
        $movedCount = 0;

        foreach ($map as $key => $hex) {
            if ($key === $prefixSrc || str_starts_with($key . '/', $prefixNeed)) {
                unset($new[$key]);
                $suffix = substr($key, strlen($prefixSrc)); // '' or '/sub/...'
                $newKey = ($target === 'root') ? ltrim($suffix, '/\\') : rtrim($target, '/\\') . $suffix;
                $new[$newKey] = $hex;
                $changed = true;
                $movedCount++;
            }
        }

        if ($changed) {
            // Write back (atomic-ish). Ignore failures (don’t block the move).
            $json = json_encode($new, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES);
            if (is_string($json)) {
                @file_put_contents($file, $json, LOCK_EX);
                @chmod($file, 0664);
            }
        }

        return ['changed' => $changed, 'moved' => $movedCount];
    }

    private static function getPerms(): array
    {
        self::ensureSession();
        $u = $_SESSION['username'] ?? '';
        return $u ? self::loadPerms($u) : [];
    }

    private static function isAdmin(array $perms = []): bool
    {
        self::ensureSession();
        if (!empty($_SESSION['isAdmin'])) {
            return true;
        }
        if (!empty($perms['admin']) || !empty($perms['isAdmin'])) {
            return true;
        }

        // Fallback: role from users.txt (role "1" means admin)
        $u = $_SESSION['username'] ?? '';
        if ($u && class_exists(userModel::class) && method_exists(userModel::class, 'getUserRole')) {
            $roleStr = userModel::getUserRole($u);
            if ($roleStr === '1') {
                return true;
            }
        }
        return false;
    }

    private static function isFolderOnly(array $perms): bool
    {
        return !empty($perms['folderOnly']) || !empty($perms['userFolderOnly']) || !empty($perms['UserFolderOnly']);
    }

    private static function requireNotReadOnly(): void
    {
        if (class_exists('SourceContext') && SourceContext::isReadOnly()) {
            http_response_code(403);
            header('Content-Type: application/json');
            echo json_encode(['error' => 'Source is read-only.']);
            exit;
        }
        $perms = self::getPerms();
        if (!empty($perms['readOnly'])) {
            http_response_code(403);
            header('Content-Type: application/json');
            echo json_encode(['error' => 'Read-only users are not allowed to perform this action.']);
            exit;
        }
    }

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

    private static function formatBytes(int $bytes): string
    {
        if ($bytes < 1024) {
            return $bytes . " B";
        }
        if ($bytes < 1048576) {
            return round($bytes / 1024, 2) . " KB";
        }
        if ($bytes < 1073741824) {
            return round($bytes / 1048576, 2) . " MB";
        }
        return round($bytes / 1073741824, 2) . " GB";
    }

    /** Return true if user is explicit owner of the folder or any of its ancestors (admins also true). */
    private static function ownsFolderOrAncestor(string $folder, string $username, array $perms): bool
    {
        if (self::isAdmin($perms)) {
            return true;
        }
        $folder = ACL::normalizeFolder($folder);
        $f = $folder;
        while ($f !== '' && strtolower($f) !== 'root') {
            if (ACL::isOwner($username, $perms, $f)) {
                return true;
            }
            $pos = strrpos($f, '/');
            $f = ($pos === false) ? '' : substr($f, 0, $pos);
        }
        return false;
    }

    /**
     * Enforce per-folder scope for folder-only accounts.
     * $need: 'read' | 'write' | 'manage' | 'share' | 'read_own' (default 'read')
     * Returns null if allowed, or an error string if forbidden.
     */
    // In FolderController.php
    private static function enforceFolderScope(
        string $folder,
        string $username,
        array $perms,
        string $need = 'read'
    ): ?string {
        // Admins bypass scope
        if (self::isAdmin($perms)) {
            return null;
        }

        // If this account isn't folder-scoped, don't gate here
        if (!self::isFolderOnly($perms)) {
            return null;
        }

        $folder = ACL::normalizeFolder($folder);

        // If user owns folder or an ancestor, allow
        $f = $folder;
        while ($f !== '' && strtolower($f) !== 'root') {
            if (ACL::isOwner($username, $perms, $f)) {
                return null;
            }
            $pos = strrpos($f, '/');
            $f = ($pos === false) ? '' : substr($f, 0, $pos);
        }

        // Normalize aliases so callers can pass either camelCase or snake_case
        switch ($need) {
            case 'manage':
                $ok = ACL::canManage($username, $perms, $folder);
                break;

            // legacy:
            case 'write':
                $ok = ACL::canWrite($username, $perms, $folder);
                break;
            case 'share':
                $ok = ACL::canShare($username, $perms, $folder);
                break;

            // read flavors:
            case 'read_own':
                $ok = ACL::canReadOwn($username, $perms, $folder);
                break;
            case 'read':
                $ok = ACL::canRead($username, $perms, $folder);
                break;

            // granular write-ish:
            case 'create':
                $ok = ACL::canCreate($username, $perms, $folder);
                break;
            case 'upload':
                $ok = ACL::canUpload($username, $perms, $folder);
                break;
            case 'edit':
                $ok = ACL::canEdit($username, $perms, $folder);
                break;
            case 'rename':
                $ok = ACL::canRename($username, $perms, $folder);
                break;
            case 'copy':
                $ok = ACL::canCopy($username, $perms, $folder);
                break;
            case 'move':
                $ok = ACL::canMove($username, $perms, $folder);
                break;
            case 'delete':
                $ok = ACL::canDelete($username, $perms, $folder);
                break;
            case 'extract':
                $ok = ACL::canExtract($username, $perms, $folder);
                break;

            // granular share (support both key styles)
            case 'shareFile':
            case 'share_file':
                $ok = ACL::canShareFile($username, $perms, $folder);
                break;
            case 'shareFolder':
            case 'share_folder':
                $ok = ACL::canShareFolder($username, $perms, $folder);
                break;

            default:
                // Default to full read if unknown need was passed
                $ok = ACL::canRead($username, $perms, $folder);
        }

        return $ok ? null : "Forbidden: folder scope violation.";
    }

    /** Returns true if caller can ignore ownership (admin or bypassOwnership/default). */
    private static function canBypassOwnership(array $perms): bool
    {
        if (self::isAdmin($perms)) {
            return true;
        }
        return (bool)($perms['bypassOwnership'] ?? (defined('DEFAULT_BYPASS_OWNERSHIP') ? DEFAULT_BYPASS_OWNERSHIP : false));
    }

    /** ACL-aware folder owner check (explicit). */
    private static function isFolderOwner(string $folder, string $username, array $perms): bool
    {
        return ACL::isOwner($username, $perms, $folder);
    }

    /* -------------------- API: Create Folder -------------------- */
    public function createFolder(): void
    {
        header('Content-Type: application/json');
        self::requireAuth();
        if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
            http_response_code(405);
            echo json_encode(['error' => 'Method not allowed.']);
            return;
        }
        self::requireCsrf();
        self::requireNotReadOnly();

        try {
            $input = json_decode(file_get_contents('php://input'), true) ?? [];
            if (!isset($input['folderName'])) {
                http_response_code(400);
                echo json_encode(['error' => 'Folder name not provided.']);
                return;
            }

            $folderName = trim((string)$input['folderName']);
            $parentIn   = isset($input['parent']) ? trim((string)$input['parent']) : 'root';

            if (!preg_match(REGEX_FOLDER_NAME, $folderName)) {
                http_response_code(400);
                echo json_encode(['error' => 'Invalid folder name.']);
                return;
            }
            if ($parentIn !== '' && strcasecmp($parentIn, 'root') !== 0 && !preg_match(REGEX_FOLDER_NAME, $parentIn)) {
                http_response_code(400);
                echo json_encode(['error' => 'Invalid parent folder name.']);
                return;
            }

            $parent = ($parentIn === '' ? 'root' : $parentIn);

            $username = $_SESSION['username'] ?? '';
            $perms    = self::getPerms();

            // Need create on parent OR ownership on parent/ancestor
            if (!(ACL::canCreateFolder($username, $perms, $parent) || self::ownsFolderOrAncestor($parent, $username, $perms))) {
                http_response_code(403);
                echo json_encode(['error' => 'Forbidden: manager/owner required on parent.']);
                exit;
            }

            // Folder-scope gate for folder-only accounts (need create on parent)
            if ($msg = self::enforceFolderScope($parent, $username, $perms, 'manage')) {
                http_response_code(403);
                echo json_encode(['error' => $msg]);
                return;
            }

            $result = FolderModel::createFolder($folderName, $parent, $username);
            if (empty($result['success'])) {
                http_response_code(400);
                echo json_encode($result);
                return;
            }

            $newFolder = ($parent === 'root') ? $folderName : ($parent . '/' . $folderName);
            AuditHook::log('folder.create', [
                'user'   => $username,
                'folder' => $newFolder,
                'path'   => $newFolder,
            ]);
            $eventPayload = [
                'user' => $username,
                'parent' => $parent,
                'folder' => $newFolder,
            ];
            if (class_exists('SourceContext') && SourceContext::sourcesEnabled()) {
                $activeSourceId = $this->normalizeSourceId(SourceContext::getActiveId());
                if ($activeSourceId !== '') {
                    $eventPayload['sourceId'] = $activeSourceId;
                }
            }
            EventBus::emit('folder.create', $eventPayload);

            echo json_encode($result);
        } catch (Throwable $e) {
            error_log('createFolder fatal: ' . $e->getMessage() . ' @ ' . $e->getFile() . ':' . $e->getLine());
            http_response_code(500);
            echo json_encode(['error' => 'Internal error creating folder.']);
        }
    }

    /* -------------------- API: Delete Folder -------------------- */
    public function deleteFolder(): void
    {
        header('Content-Type: application/json');
        self::requireAuth();
        if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
            http_response_code(405);
            echo json_encode(["error" => "Method not allowed."]);
            exit;
        }
        self::requireCsrf();
        self::requireNotReadOnly();

        $input = json_decode(file_get_contents('php://input'), true);
        $sourceId = is_array($input) && isset($input['sourceId']) ? trim((string)$input['sourceId']) : '';
        if ($sourceId !== '' && class_exists('SourceContext') && SourceContext::sourcesEnabled()) {
            if (!preg_match('/^[A-Za-z0-9_-]{1,64}$/', $sourceId)) {
                http_response_code(400);
                echo json_encode(["error" => "Invalid source id."]);
                exit;
            }
            $src = SourceContext::getSourceById($sourceId);
            if (!$src || empty($src['enabled'])) {
                http_response_code(400);
                echo json_encode(["error" => "Invalid source."]);
                exit;
            }
            SourceContext::setActiveId($sourceId, false, true);
        }
        if (!isset($input['folder'])) {
            http_response_code(400);
            echo json_encode(["error" => "Folder name not provided."]);
            exit;
        }

        $folder = trim((string)$input['folder']);
        if (strcasecmp($folder, 'root') === 0) {
            http_response_code(400);
            echo json_encode(["error" => "Cannot delete root folder."]);
            exit;
        }
        if (!preg_match(REGEX_FOLDER_NAME, $folder)) {
            http_response_code(400);
            echo json_encode(["error" => "Invalid folder name."]);
            exit;
        }

        $username = $_SESSION['username'] ?? '';
        $perms    = self::getPerms();

        // Folder-scope: need manage (owner) OR explicit manage grant
        if ($msg = self::enforceFolderScope($folder, $username, $perms, 'manage')) {
            http_response_code(403);
            echo json_encode(["error" => $msg]);
            exit;
        }

        // Require either manage permission or ancestor ownership (strong gate)
        $canManage = ACL::canManage($username, $perms, $folder) || self::ownsFolderOrAncestor($folder, $username, $perms);
        if (!$canManage) {
            http_response_code(403);
            echo json_encode(["error" => "Forbidden: you lack manage rights for this folder."]);
            exit;
        }

        // If not bypassing ownership, require ownership (direct or ancestor) as an extra safeguard
        if (!self::canBypassOwnership($perms) && !self::ownsFolderOrAncestor($folder, $username, $perms)) {
            http_response_code(403);
            echo json_encode(["error" => "Forbidden: you are not the folder owner."]);
            exit;
        }

        $result = FolderModel::deleteFolder($folder);
        if (!empty($result['success'])) {
            AuditHook::log('folder.delete', [
                'user'   => $username,
                'folder' => $folder,
                'path'   => $folder,
            ]);
            $eventPayload = [
                'user' => $username,
                'folder' => $folder,
            ];
            if ($sourceId !== '') {
                $eventPayload['sourceId'] = $sourceId;
            }
            EventBus::emit('folder.delete', $eventPayload);
        }
        echo json_encode($result);
        exit;
    }

    /* -------------------- API: Rename Folder -------------------- */
    public function renameFolder(): void
    {
        header('Content-Type: application/json');
        self::requireAuth();
        if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
            http_response_code(405);
            echo json_encode(['error' => 'Method not allowed.']);
            exit;
        }
        self::requireCsrf();
        self::requireNotReadOnly();

        $input = json_decode(file_get_contents('php://input'), true);
        if (!isset($input['oldFolder']) || !isset($input['newFolder'])) {
            http_response_code(400);
            echo json_encode(['error' => 'Required folder names not provided.']);
            exit;
        }

        $oldFolder = trim((string)$input['oldFolder']);
        $newFolder = trim((string)$input['newFolder']);

        if (!preg_match(REGEX_FOLDER_NAME, $oldFolder) || !preg_match(REGEX_FOLDER_NAME, $newFolder)) {
            http_response_code(400);
            echo json_encode(['error' => 'Invalid folder name(s).']);
            exit;
        }

        $username = $_SESSION['username'] ?? '';
        $perms    = self::getPerms();

        // Must be allowed to manage the old folder
        if ($msg = self::enforceFolderScope($oldFolder, $username, $perms, 'manage')) {
            http_response_code(403);
            echo json_encode(["error" => $msg]);
            exit;
        }
        // For the new folder path, require write scope (we're "creating" a path)
        if ($msg = self::enforceFolderScope($newFolder, $username, $perms, 'manage')) {
            http_response_code(403);
            echo json_encode(["error" => "New path not allowed: " . $msg]);
            exit;
        }

        // Strong gates: need manage on old OR ancestor owner; need manage on new parent OR ancestor owner
        $canManageOld = ACL::canManage($username, $perms, $oldFolder) || self::ownsFolderOrAncestor($oldFolder, $username, $perms);
        if (!$canManageOld) {
            http_response_code(403);
            echo json_encode(['error' => 'Forbidden: you lack manage rights on the source folder.']);
            exit;
        }

        // If not bypassing ownership, require ownership (direct or ancestor) on the old folder
        if (!self::canBypassOwnership($perms) && !self::ownsFolderOrAncestor($oldFolder, $username, $perms)) {
            http_response_code(403);
            echo json_encode(['error' => 'Forbidden: you are not the folder owner.']);
            exit;
        }

        $result = FolderModel::renameFolder($oldFolder, $newFolder);
        if (!empty($result['success'])) {
            AuditHook::log('folder.rename', [
                'user'   => $username,
                'folder' => $newFolder,
                'from'   => $oldFolder,
                'to'     => $newFolder,
            ]);
            EventBus::emit('folder.rename', [
                'user' => $username,
                'from' => $oldFolder,
                'to' => $newFolder,
            ]);
        }
        echo json_encode($result);
        exit;
    }

    /* -------------------- API: Get Folder List -------------------- */
    public function getFolderList(): void
    {
        header('Content-Type: application/json');
        self::requireAuth();

        $countsRaw = $_GET['counts'] ?? null;
        $includeCounts = true;
        if ($countsRaw !== null) {
            $cv = strtolower((string)$countsRaw);
            if ($cv === '0' || $cv === 'false' || $cv === 'no') {
                $includeCounts = false;
            }
        }

        // Optional "folder" filter (supports nested like "team/reports")
        $parent = $_GET['folder'] ?? null;
        if ($parent !== null && $parent !== '' && strcasecmp($parent, 'root') !== 0) {
            $parts = array_filter(explode('/', trim($parent, "/\\ ")), fn($p) => $p !== '');
            if (empty($parts)) {
                http_response_code(400);
                echo json_encode(["error" => "Invalid folder name."]);
                exit;
            }
            foreach ($parts as $seg) {
                if (!preg_match(REGEX_FOLDER_NAME, $seg)) {
                    http_response_code(400);
                    echo json_encode(["error" => "Invalid folder name."]);
                    exit;
                }
            }
            $parent = implode('/', $parts);
        }

        $username = $_SESSION['username'] ?? '';
        $perms    = self::getPerms();
        $isAdmin  = self::isAdmin($perms);

        $sourceId = '';
        if (class_exists('SourceContext') && SourceContext::sourcesEnabled()) {
            $rawSourceId = trim((string)($_GET['sourceId'] ?? ''));
            if ($rawSourceId !== '') {
                $sourceId = $this->normalizeSourceId($rawSourceId);
                if ($sourceId === '') {
                    http_response_code(400);
                    echo json_encode(['error' => 'Invalid source id.']);
                    exit;
                }
                $info = SourceContext::getSourceById($sourceId);
                if (!$info) {
                    http_response_code(400);
                    echo json_encode(['error' => 'Invalid source.']);
                    exit;
                }
            }
        }

        $runner = function () use ($includeCounts, $isAdmin, $username, $perms, $parent) {
            // 1) Full list from model
            $all = FolderModel::getFolderList($parent, null, [], $includeCounts); // each row: ["folder","fileCount","metadataFile"]
            if (!is_array($all)) {
                return [];
            }

            // 2) Filter by view rights
            if (!$isAdmin) {
                $all = array_values(array_filter($all, function ($row) use ($username, $perms) {
                    $f = $row['folder'] ?? '';
                    if ($f === '') {
                        return false;
                    }

                    // Full view if canRead OR owns ancestor; otherwise allow if read_own granted
                    $fullView = ACL::canRead($username, $perms, $f) || FolderController::ownsFolderOrAncestor($f, $username, $perms);
                    $ownOnly  = ACL::hasGrant($username, $f, 'read_own');

                    return $fullView || $ownOnly;
                }));
            }

            // 3) Optional parent filter (applies to both admin and non-admin)
            if ($parent && strcasecmp($parent, 'root') !== 0) {
                $pref = $parent . '/';
                $all = array_values(array_filter($all, function ($row) use ($parent, $pref) {
                    $f = $row['folder'] ?? '';
                    return ($f === $parent) || (strpos($f, $pref) === 0);
                }));
            }

            return $all;
        };

        $all = ($sourceId !== '')
            ? $this->withSourceContext($sourceId, $runner, $isAdmin)
            : $runner();

        echo json_encode($all);
        exit;
    }

    /* -------------------- API: Download Shared File -------------------- */
    public function downloadSharedFile(): void
    {
        $token = self::getQueryString('token');
        $file  = self::getQueryString('file');
        $providedPass = self::getQueryString('pass');
        $path  = (string)($_GET['path'] ?? '');
        $inlineRequested = ((string)($_GET['inline'] ?? '') === '1');

        if (empty($token) || (empty($file) && $path === '')) {
            http_response_code(400);
            header('Content-Type: application/json; charset=utf-8');
            echo json_encode(["error" => "Missing token or file parameter."]);
            exit;
        }

        $relPath = $path !== '' ? $path : (string)$file;
        if ($path === '') {
            $basename = basename($relPath);
            if (!preg_match(REGEX_FILE_NAME, $basename)) {
                http_response_code(400);
                header('Content-Type: application/json; charset=utf-8');
                echo json_encode(["error" => "Invalid file name."]);
                exit;
            }
            $relPath = $basename;
        }

        $result = FolderModel::getSharedFileInfo($token, $relPath, $providedPass);
        if (isset($result['needs_password'])) {
            http_response_code(403);
            header('Content-Type: application/json; charset=utf-8');
            echo json_encode(["error" => "Password required."]);
            exit;
        }
        if (isset($result['error'])) {
            $forbiddenErrors = [
                'Invalid password.',
                'Password required.',
                'Downloads are disabled for this upload-only share.',
            ];
            $code = in_array((string)$result['error'], $forbiddenErrors, true) ? 403 : 404;
            http_response_code($code);
            header('Content-Type: application/json; charset=utf-8');
            echo json_encode(["error" => $result['error']]);
            exit;
        }

        $storage = StorageRegistry::getAdapter();
        $filePath = (string)($result['filePath'] ?? '');
        $downloadName = (string)($result['downloadName'] ?? basename($filePath));
        $fallbackName = basename($relPath);
        if ($downloadName === '') {
            $downloadName = $fallbackName;
        }
        $ext = strtolower(pathinfo($downloadName, PATHINFO_EXTENSION));

        // Ensure clean binary response (only on the file-stream path)
        if (headers_sent($hf, $hl)) {
            error_log("downloadSharedFile headers already sent at {$hf}:{$hl}");
        }
        while (ob_get_level() > 0) {
            ob_end_clean();
        }

        // Harden against sniffing
        header('X-Content-Type-Options: nosniff');

        // Safer filename handling
        $downloadName = str_replace(["\r", "\n"], '', $downloadName);
        $downloadNameStar = rawurlencode($downloadName);

        // Explicit raster map (so PNG/JPG render correctly when inline is requested)
        $rasterMime = [
            'jpg'  => 'image/jpeg',
            'jpeg' => 'image/jpeg',
            'png'  => 'image/png',
            'gif'  => 'image/gif',
            'bmp'  => 'image/bmp',
            'webp' => 'image/webp',
            'ico'  => 'image/x-icon',
        ];

        $inlineMime = [
            'mp4'  => 'video/mp4',
            'mkv'  => 'video/x-matroska',
            'webm' => 'video/webm',
            'mov'  => 'video/quicktime',
            'ogv'  => 'video/ogg',
            'mp3'  => 'audio/mpeg',
            'wav'  => 'audio/wav',
            'm4a'  => 'audio/mp4',
            'ogg'  => 'audio/ogg',
            'flac' => 'audio/flac',
            'aac'  => 'audio/aac',
            'wma'  => 'audio/x-ms-wma',
            'opus' => 'audio/opus',
            'pdf'  => 'application/pdf',
        ];

        $mimeType = $result['mimeType'] ?? 'application/octet-stream';
        if (!is_string($mimeType) || $mimeType === '') {
            $mimeType = 'application/octet-stream';
        }
        if (isset($rasterMime[$ext])) {
            $mimeType = $rasterMime[$ext];
        }

        // SVG / SVGZ: NEVER render inline on shared/public links
        if ($ext === 'svg' || $ext === 'svgz') {
            header('Content-Type: application/octet-stream');
            header("Content-Disposition: attachment; filename=\"{$downloadName}\"; filename*=UTF-8''{$downloadNameStar}");
            // defense-in-depth if something opens it anyway
            header("Content-Security-Policy: sandbox; default-src 'none'; base-uri 'none'; form-action 'none'");
        } else {
            $inlineOk = false;
            if ($inlineRequested) {
                $lowerMime = strtolower($mimeType);
                $inlineOk = isset($rasterMime[$ext])
                    || isset($inlineMime[$ext])
                    || str_starts_with($lowerMime, 'video/')
                    || str_starts_with($lowerMime, 'audio/')
                    || $lowerMime === 'application/pdf';
                if (isset($inlineMime[$ext])) {
                    $mimeType = $inlineMime[$ext];
                }
            }
            header('Content-Type: ' . $mimeType);
            $disposition = $inlineOk ? 'inline' : 'attachment';
            header("Content-Disposition: {$disposition}; filename=\"{$downloadName}\"; filename*=UTF-8''{$downloadNameStar}");
        }

        AuditHook::log('file.download', [
            'user'   => 'share:' . $token,
            'source' => 'share',
            'folder' => $result['folder'] ?? 'root',
            'path'   => !empty($result['folder']) && $result['folder'] !== 'root'
                ? ($result['folder'] . '/' . ($result['file'] ?? $fallbackName))
                : ($result['file'] ?? $fallbackName),
            'meta'   => [
                'token' => $token,
            ],
        ]);

        if ($storage->isLocal()) {
            $size = @filesize($filePath);
            if (is_int($size)) {
                header('Content-Length: ' . $size);
            }
            readfile($filePath);
            exit;
        }

        $size = (int)($result['size'] ?? 0);
        if ($size <= 0 && empty($result['sizeUnknown'])) {
            $stat = $storage->stat($filePath);
            $size = (int)($stat['size'] ?? 0);
        }
        if ($size > 0) {
            header('Content-Length: ' . $size);
        }

        $stream = $storage->openReadStream($filePath, null, 0);
        if ($stream === false) {
            http_response_code(404);
            header('Content-Type: application/json; charset=utf-8');
            echo json_encode(['error' => 'File not found']);
            exit;
        }

        $chunkSize = 8192;
        while (true) {
            if (is_resource($stream)) {
                $buffer = fread($stream, $chunkSize);
            } elseif (is_object($stream) && method_exists($stream, 'read')) {
                $buffer = $stream->read($chunkSize);
            } elseif (is_object($stream) && method_exists($stream, 'getContents')) {
                $buffer = $stream->getContents();
            } else {
                $buffer = false;
            }
            if ($buffer === false || $buffer === '') {
                break;
            }
            echo $buffer;
            flush();
            if (connection_aborted()) {
                break;
            }
        }

        if (is_resource($stream)) {
            fclose($stream);
        } elseif (is_object($stream) && method_exists($stream, 'close')) {
            $stream->close();
        }
        exit;
    }

    /* -------------------- API: Download Shared Folder (ZIP) -------------------- */
    public function downloadSharedFolder(): void
    {
        $token = self::getQueryString('token');
        $providedPass = self::getQueryString('pass');
        $path  = (string)($_GET['path'] ?? '');

        $accept = (string)($_SERVER['HTTP_ACCEPT'] ?? '');
        $wantsHtml = stripos($accept, 'text/html') !== false;

        $renderError = function (int $status, string $message) use ($wantsHtml, $token, $providedPass, $path): void {
            http_response_code($status);
            header('X-Content-Type-Options: nosniff');
            header('Cache-Control: no-store, no-cache, must-revalidate, max-age=0');
            header('Pragma: no-cache');
            header('X-Frame-Options: DENY');
            header("Content-Security-Policy: frame-ancestors 'none';");

            if (!$wantsHtml) {
                header('Content-Type: application/json; charset=utf-8');
                echo json_encode(["error" => $message]);
                exit;
            }

            header('Content-Type: text/html; charset=utf-8');
            $backUrl = '';
            if (!empty($token)) {
                $backUrl = fr_with_base_path('/api/folder/shareFolder.php?token=' . urlencode($token));
                if (!empty($providedPass)) {
                    $backUrl .= '&pass=' . urlencode($providedPass);
                }
                if (!empty($path)) {
                    $backUrl .= '&path=' . urlencode($path);
                }
            }
            $safeMessage = htmlspecialchars($message, ENT_QUOTES, 'UTF-8');
            ?>
            <!DOCTYPE html>
            <html lang="en">
            <head>
                <meta charset="UTF-8">
                <meta name="viewport" content="width=device-width, initial-scale=1">
                <title>Shared Folder</title>
                <link rel="stylesheet" href="<?php echo htmlspecialchars(fr_with_base_path('/css/vendor/roboto.css?v={{APP_QVER}}'), ENT_QUOTES, 'UTF-8'); ?>">
                <link rel="stylesheet" href="<?php echo htmlspecialchars(fr_with_base_path('/css/share.css?v={{APP_QVER}}'), ENT_QUOTES, 'UTF-8'); ?>">
            </head>
            <body class="fr-share-body">
                <div class="fr-share-shell">
                    <div class="fr-share-card">
                        <div class="fr-share-card-header">
                            <img id="shareLogo" class="fr-share-logo" src="<?php echo htmlspecialchars(fr_with_base_path('/assets/logo.svg?v={{APP_QVER}}'), ENT_QUOTES, 'UTF-8'); ?>" alt="FileRise">
                            <div>
                                <div class="fr-share-title">Unable to download</div>
                                <div class="fr-share-subtitle">Please review the message below.</div>
                            </div>
                        </div>
                        <div class="fr-share-alert fr-share-alert-error"><?php echo $safeMessage; ?></div>
                        <?php if ($backUrl !== '') : ?>
                            <div class="fr-share-actions">
                                <a class="fr-share-btn" href="<?php echo htmlspecialchars($backUrl, ENT_QUOTES, 'UTF-8'); ?>">Back to shared folder</a>
                            </div>
                        <?php endif; ?>
                    </div>
                </div>
                <script src="<?php echo htmlspecialchars(fr_with_base_path('/js/shareBranding.js?v={{APP_QVER}}'), ENT_QUOTES, 'UTF-8'); ?>" defer></script>
            </body>
            </html>
            <?php
            exit;
        };

        if (empty($token)) {
            $renderError(400, "Missing share token.");
        }

        $ctx = FolderModel::getSharedFolderEntries($token, $providedPass, $path);
        if (isset($ctx['needs_password'])) {
            $renderError(403, "Password required.");
        }
        if (isset($ctx['error'])) {
            $renderError(404, (string)$ctx['error']);
        }
        if (!empty($ctx['hideListing']) || (isset($ctx['mode']) && (string)$ctx['mode'] === 'drop')) {
            $renderError(403, "Downloads are disabled for this upload-only share.");
        }

        $storage = StorageRegistry::getAdapter();
        if (!$storage->isLocal()) {
            $renderError(400, "Archive downloads are not supported for remote storage.");
        }

        $realFolderPath = (string)($ctx['realFolderPath'] ?? '');
        if ($realFolderPath === '' || !is_dir($realFolderPath)) {
            $renderError(404, "Shared folder not found.");
        }

        if (!class_exists('\\ZipArchive')) {
            $renderError(400, "ZipArchive extension is required on the server.");
        }

        $maxFiles = 2000;
        $maxBytes = 2 * 1024 * 1024 * 1024; // 2 GB

        $metaRoot = class_exists('SourceContext')
            ? SourceContext::metaRoot()
            : rtrim((string)META_DIR, '/\\') . DIRECTORY_SEPARATOR;
        $work = rtrim($metaRoot, '/\\') . DIRECTORY_SEPARATOR . 'ziptmp';
        if (!is_dir($work)) {
            @mkdir($work, 0775, true);
        }
        if (!is_dir($work) || !is_writable($work)) {
            $renderError(500, "ZIP temp dir not writable.");
        }

        $rateWindow = 30;
        $safeToken = preg_replace('/[^a-f0-9]/i', '', (string)$token);
        $lockPath = $work . DIRECTORY_SEPARATOR . 'share-zip-' . ($safeToken !== '' ? $safeToken : 'unknown') . '.lock';
        if (is_file($lockPath)) {
            $age = time() - (int)@filemtime($lockPath);
            if ($age >= 0 && $age < $rateWindow) {
                $retry = max(1, $rateWindow - $age);
                header('Retry-After: ' . $retry);
                $renderError(429, "Please wait a moment before requesting another archive download.");
            }
        }
        @file_put_contents($lockPath, (string)time(), LOCK_EX);
        register_shutdown_function(function () use ($lockPath) {
            if (is_file($lockPath)) {
                @unlink($lockPath);
            }
        });

        $allowSubfolders = !empty($ctx['allowSubfolders']);

        $files = [];
        $totalBytes = 0;
        $baseLen = strlen($realFolderPath);

        $iter = $allowSubfolders
            ? new RecursiveIteratorIterator(
                new RecursiveDirectoryIterator($realFolderPath, FilesystemIterator::SKIP_DOTS)
            )
            : new FilesystemIterator($realFolderPath, FilesystemIterator::SKIP_DOTS);
        foreach ($iter as $info) {
            if (!$info->isFile() || $info->isLink()) {
                continue;
            }
            $fullPath = $info->getPathname();
            $rel = substr($fullPath, $baseLen + 1);
            if ($rel === '' || $rel === false) {
                continue;
            }
            $rel = str_replace('\\', '/', $rel);
            $parts = explode('/', $rel);
            $bad = false;
            $lastIdx = count($parts) - 1;
            foreach ($parts as $idx => $seg) {
                if ($seg === '' || $seg[0] === '.') {
                    $bad = true;
                    break;
                }
                if ($idx < $lastIdx) {
                    if (!preg_match(REGEX_FOLDER_NAME, $seg)) {
                        $bad = true;
                        break;
                    }
                } else {
                    if (!preg_match(REGEX_FILE_NAME, $seg)) {
                        $bad = true;
                        break;
                    }
                }
            }
            if ($bad) {
                continue;
            }

            $files[] = ['path' => $fullPath, 'rel' => $rel];
            $size = $info->getSize();
            if (is_int($size)) {
                $totalBytes += $size;
            }
            if (count($files) > $maxFiles || ($maxBytes > 0 && $totalBytes > $maxBytes)) {
                $renderError(413, "Shared folder is too large to download as a ZIP.");
            }
        }

        if (empty($files)) {
            $renderError(400, "No files found to archive.");
        }

        // Light cleanup of old zips (> 6h)
        $now = time();
        foreach ((glob($work . DIRECTORY_SEPARATOR . 'download-*.zip') ?: []) as $zp) {
            if (is_file($zp) && ($now - (int)@filemtime($zp)) > 21600) {
                @unlink($zp);
            }
        }

        // Ensure enough free space (best-effort)
        $free = @disk_free_space($work);
        if ($free !== false && $totalBytes > 0) {
            $needed = (int)ceil($totalBytes * 1.05) + (20 * 1024 * 1024);
            if ($free < $needed) {
                $renderError(507, "Insufficient free space to build archive.");
            }
        }

        @set_time_limit(0);
        @ignore_user_abort(true);
        if (session_status() === PHP_SESSION_ACTIVE) {
            @session_write_close();
        }

        $zipName = 'download-' . date('Ymd-His') . '-' . bin2hex(random_bytes(4)) . '.zip';
        $zipPath = $work . DIRECTORY_SEPARATOR . $zipName;

        $zip = new \ZipArchive();
        if ($zip->open($zipPath, \ZipArchive::CREATE | \ZipArchive::OVERWRITE) !== true) {
            $renderError(500, "Could not create zip archive.");
        }

        foreach ($files as $item) {
            $zip->addFile($item['path'], $item['rel']);
        }
        if (!$zip->close()) {
            @unlink($zipPath);
            $renderError(500, "Failed to finalize ZIP.");
        }

        $folderKey = (string)($ctx['folder'] ?? 'root');
        AuditHook::log('file.download_zip', [
            'user'   => 'share:' . $token,
            'source' => 'share',
            'folder' => $folderKey,
            'meta'   => [
                'token' => $token,
                'files' => count($files),
            ],
        ]);

        $nameBase = 'shared-folder';
        $pathForName = (string)($ctx['path'] ?? '');
        $shareRoot = (string)($ctx['shareRoot'] ?? '');
        if ($pathForName !== '') {
            $nameBase = basename($pathForName);
        } elseif ($shareRoot !== '' && strtolower($shareRoot) !== 'root') {
            $nameBase = basename($shareRoot);
        }
        $nameBase = preg_replace('/[^A-Za-z0-9._-]/', '_', $nameBase);
        if ($nameBase === '' || $nameBase === '.' || $nameBase === '..') {
            $nameBase = 'shared-folder';
        }
        $downloadName = $nameBase . '.zip';

        $size = (int)@filesize($zipPath);
        header('X-Accel-Buffering: no');
        header('X-Content-Type-Options: nosniff');
        header('Content-Type: application/zip');
        header('Content-Disposition: attachment; filename="' . $downloadName . '"');
        if ($size > 0) {
            header('Content-Length: ' . $size);
        }
        header('Cache-Control: no-store, no-cache, must-revalidate');
        header('Pragma: no-cache');

        readfile($zipPath);
        @unlink($zipPath);
        exit;
    }

    /* -------------------- Public Shared Folder HTML -------------------- */
    public function shareFolder(): void
    {
        $token        = self::getQueryString('token');
        $providedPass = self::getQueryString('pass');
        $page         = self::getQueryInt('page');
        $path         = (string)($_GET['path'] ?? '');
        if ($page === null || $page < 1) {
            $page = 1;
        }

        if (empty($token)) {
            http_response_code(400);
            header('Content-Type: application/json');
            echo json_encode(["error" => "Missing token."]);
            exit;
        }

        $data = FolderModel::getSharedFolderData($token, $providedPass, $page, 10, $path);

        if (isset($data['needs_password']) && $data['needs_password'] === true) {
            header('Cache-Control: no-store, no-cache, must-revalidate, max-age=0');
            header('Pragma: no-cache');
            header('X-Frame-Options: DENY');
            header("Content-Security-Policy: frame-ancestors 'none';");
            header("Content-Type: text/html; charset=utf-8"); ?>
            <!DOCTYPE html>
            <html lang="en">

            <head>
                <meta charset="UTF-8">
                <meta name="viewport" content="width=device-width, initial-scale=1">
                <title>Enter Password</title>
                <link rel="stylesheet" href="<?php echo htmlspecialchars(fr_with_base_path('/css/vendor/roboto.css?v={{APP_QVER}}'), ENT_QUOTES, 'UTF-8'); ?>">
                <link rel="stylesheet" href="<?php echo htmlspecialchars(fr_with_base_path('/css/share.css?v={{APP_QVER}}'), ENT_QUOTES, 'UTF-8'); ?>">
            </head>

            <body class="fr-share-body">
                <div class="fr-share-shell">
                    <div class="fr-share-card">
                        <div class="fr-share-card-header">
                            <img id="shareLogo" class="fr-share-logo" src="<?php echo htmlspecialchars(fr_with_base_path('/assets/logo.svg?v={{APP_QVER}}'), ENT_QUOTES, 'UTF-8'); ?>" alt="FileRise">
                            <div>
                                <div class="fr-share-title">This folder is protected</div>
                                <div class="fr-share-subtitle">Enter the password to continue.</div>
                            </div>
                        </div>
                        <form class="fr-share-form" method="get" action="<?php echo htmlspecialchars(fr_with_base_path('/api/folder/shareFolder.php'), ENT_QUOTES, 'UTF-8'); ?>">
                            <input type="hidden" name="token" value="<?php echo htmlspecialchars($token, ENT_QUOTES, 'UTF-8'); ?>">
                            <?php if (!empty($path)) :
                                ?><input type="hidden" name="path" value="<?php echo htmlspecialchars($path, ENT_QUOTES, 'UTF-8'); ?>"><?php
                            endif; ?>
                            <label for="pass" class="fr-share-label">Password</label>
                            <input type="password" name="pass" id="pass" class="fr-share-input" required>
                            <button type="submit" class="fr-share-btn">Unlock</button>
                        </form>
                    </div>
                </div>
                <script src="<?php echo htmlspecialchars(fr_with_base_path('/js/shareBranding.js?v={{APP_QVER}}'), ENT_QUOTES, 'UTF-8'); ?>" defer></script>
            </body>

            </html>
            <?php exit;
        }

        if (isset($data['error'])) {
            http_response_code(403);
            header('Content-Type: application/json');
            echo json_encode(["error" => $data['error']]);
            exit;
        }
        $adminConfig = AdminModel::getConfig();
        $sharedMaxUploadSize = (isset($adminConfig['sharedMaxUploadSize']) && is_numeric($adminConfig['sharedMaxUploadSize']))
            ? (int)$adminConfig['sharedMaxUploadSize']
            : null;
        $headerTitle = trim((string)($adminConfig['header_title'] ?? 'FileRise'));
        if ($headerTitle === '') {
            $headerTitle = 'FileRise';
        }

        $record = is_array($data['record'] ?? null) ? $data['record'] : [];
        $entries = is_array($data['entries'] ?? null) ? $data['entries'] : [];
        $currentPage = (int)($data['currentPage'] ?? 1);
        $totalPages = (int)($data['totalPages'] ?? 1);
        $totalEntries = (int)($data['totalEntries'] ?? 0);
        $shareRoot = (string)($data['shareRoot'] ?? 'root');
        $currentPath = (string)($data['path'] ?? '');
        $allowSubfolders = !empty($data['allowSubfolders']);
        $allowUpload = isset($record['allowUpload']) && (int)$record['allowUpload'] === 1;
        $isDropMode = (isset($record['mode']) && (string)$record['mode'] === 'drop')
            || !empty($record['hideListing'])
            || !empty($data['hideListing']);
        if ($isDropMode) {
            $allowUpload = true;
            $entries = [];
            $currentPage = 1;
            $totalPages = 1;
            $totalEntries = 0;
        }
        $hideListing = $isDropMode || !empty($data['hideListing']);
        $aiEnabled = !empty($record['aiEnabled']);
        $preserveFolderStructure = !isset($record['preserveFolderStructure']) || !empty($record['preserveFolderStructure']);

        $displayName = $isDropMode ? 'Upload files' : 'Shared Folder';
        if (!$isDropMode) {
            if ($currentPath !== '') {
                $displayName = basename($currentPath);
            } elseif ($shareRoot !== '' && strtolower($shareRoot) !== 'root') {
                $displayName = basename($shareRoot);
            }
        }
        $pageTitle = $headerTitle . ($isDropMode ? ' File Request' : ' Share');
        if ($displayName !== '') {
            $pageTitle .= ': ' . $displayName;
        }

        $storage = StorageRegistry::getAdapter();
        $canDownloadAll = !$hideListing && $storage->isLocal();

        $effectiveMaxFileSizeMb = (isset($record['maxFileSizeMb']) && is_numeric($record['maxFileSizeMb']) && (int)$record['maxFileSizeMb'] > 0)
            ? (int)$record['maxFileSizeMb']
            : (($sharedMaxUploadSize !== null && $sharedMaxUploadSize > 0) ? (int)ceil($sharedMaxUploadSize / (1024 * 1024)) : 0);
        $allowedTypes = self::normalizeSharedAllowedTypes($record['allowedTypes'] ?? []);
        $dailyFileLimit = (isset($record['dailyFileLimit']) && is_numeric($record['dailyFileLimit'])) ? (int)$record['dailyFileLimit'] : 0;
        $maxTotalMbPerDay = (isset($record['maxTotalMbPerDay']) && is_numeric($record['maxTotalMbPerDay'])) ? (int)$record['maxTotalMbPerDay'] : 0;

        $uploadToken = '';
        if ($allowUpload) {
            $secret = (string)($GLOBALS['encryptionKey'] ?? '');
            if ($secret !== '') {
                $seed = $token . '|' . (string)$providedPass;
                $uploadToken = hash_hmac('sha256', $seed, $secret);
            }
        }

        $shareBaseUrl = fr_with_base_path('/api/folder/shareFolder.php');
        $queryBase = 'token=' . urlencode($token);
        if (!empty($providedPass)) {
            $queryBase .= '&pass=' . urlencode($providedPass);
        }
        if ($currentPath !== '') {
            $queryBase .= '&path=' . urlencode($currentPath);
        }

        header('Cache-Control: no-store, no-cache, must-revalidate, max-age=0');
        header('Pragma: no-cache');
        header('X-Frame-Options: DENY');
        header("Content-Security-Policy: frame-ancestors 'none';");
        header("Content-Type: text/html; charset=utf-8"); ?>
        <!DOCTYPE html>
        <html lang="en">

        <head>
            <meta charset="UTF-8">
            <meta name="viewport" content="width=device-width, initial-scale=1">
            <title><?php echo htmlspecialchars($pageTitle, ENT_QUOTES, 'UTF-8'); ?></title>
            <link rel="stylesheet" href="<?php echo htmlspecialchars(fr_with_base_path('/css/vendor/roboto.css?v={{APP_QVER}}'), ENT_QUOTES, 'UTF-8'); ?>">
            <link rel="stylesheet" href="<?php echo htmlspecialchars(fr_with_base_path('/css/share.css?v={{APP_QVER}}'), ENT_QUOTES, 'UTF-8'); ?>">
        </head>

        <body class="fr-share-body">
            <div class="fr-share-shell">
                <div class="fr-share-card fr-share-card-wide">
                    <div class="fr-share-card-header">
                        <img id="shareLogo" class="fr-share-logo" src="<?php echo htmlspecialchars(fr_with_base_path('/assets/logo.svg?v={{APP_QVER}}'), ENT_QUOTES, 'UTF-8'); ?>" alt="FileRise">
                        <div class="fr-share-header-text">
                            <div class="fr-share-kicker"><?php echo $isDropMode ? 'File request' : 'Shared folder'; ?></div>
                            <div id="shareTitle" class="fr-share-title"><?php echo htmlspecialchars($displayName, ENT_QUOTES, 'UTF-8'); ?></div>
                            <div id="shareBreadcrumbs" class="fr-share-breadcrumbs"></div>
                        </div>
                        <div class="fr-share-actions">
                            <?php if (!$hideListing) : ?>
                                <button type="button" id="downloadAllBtn" class="fr-share-btn" <?php echo $canDownloadAll ? '' : 'disabled'; ?>>Download all</button>
                                <button type="button" id="toggleViewBtn" class="fr-share-btn fr-share-btn-ghost">Gallery</button>
                            <?php endif; ?>
                            <button type="button" id="shareThemeToggle" class="fr-share-btn fr-share-btn-ghost">Dark mode</button>
                        </div>
                    </div>

                    <?php if (!$hideListing) : ?>
                        <div class="fr-share-toolbar">
                            <div class="fr-share-search-wrap">
                                <input type="text" id="shareSearchInput" class="fr-share-search" placeholder="Search this folder">
                            </div>
                            <div id="shareCount" class="fr-share-count"></div>
                        </div>
                    <?php endif; ?>

                    <?php if ($allowUpload && !$isDropMode) : ?>
                        <div class="fr-share-card fr-share-upload">
                            <div class="fr-share-upload-title">
                                Upload file
                                <?php if ($sharedMaxUploadSize !== null) : ?>
                                    (<?php echo self::formatBytes($sharedMaxUploadSize); ?> max)
                                <?php endif; ?>
                            </div>
                            <form action="<?php echo htmlspecialchars(fr_with_base_path('/api/folder/uploadToSharedFolder.php'), ENT_QUOTES, 'UTF-8'); ?>" method="post" enctype="multipart/form-data" class="fr-share-upload-form">
                                <input type="hidden" name="token" value="<?php echo htmlspecialchars($token, ENT_QUOTES, 'UTF-8'); ?>">
                                <?php if (!empty($providedPass)) : ?>
                                    <input type="hidden" name="pass" value="<?php echo htmlspecialchars($providedPass, ENT_QUOTES, 'UTF-8'); ?>">
                                <?php endif; ?>
                                <?php if ($currentPath !== '') : ?>
                                    <input type="hidden" name="path" value="<?php echo htmlspecialchars($currentPath, ENT_QUOTES, 'UTF-8'); ?>">
                                <?php endif; ?>
                                <?php if ($uploadToken !== '') : ?>
                                    <input type="hidden" name="share_upload_token" value="<?php echo htmlspecialchars($uploadToken, ENT_QUOTES, 'UTF-8'); ?>">
                                <?php endif; ?>
                                <input type="file" name="fileToUpload" required>
                                <button type="submit" class="fr-share-btn">Upload</button>
                            </form>
                            <div id="shareUploadProgress" class="fr-share-upload-progress" hidden>
                                <div class="fr-share-upload-progress-track">
                                    <div class="fr-share-upload-progress-fill"></div>
                                </div>
                                <div id="shareUploadProgressText" class="fr-share-upload-progress-text">Uploading...</div>
                            </div>
                        </div>
                    <?php elseif ($allowUpload && $isDropMode) : ?>
                        <div class="fr-share-card fr-share-upload fr-share-upload-drop">
                            <div class="fr-share-upload-title">Upload files</div>
                            <div class="fr-share-upload-subtitle">
                                Uploaders can't see existing files<?php echo $allowSubfolders ? '.' : ', and folder uploads are disabled for this link.'; ?>
                            </div>
                            <form id="shareDropUploadForm" action="<?php echo htmlspecialchars(fr_with_base_path('/api/folder/uploadToSharedFolder.php'), ENT_QUOTES, 'UTF-8'); ?>" method="post" enctype="multipart/form-data" class="fr-share-upload-form fr-share-upload-form-drop">
                                <input type="hidden" name="token" value="<?php echo htmlspecialchars($token, ENT_QUOTES, 'UTF-8'); ?>">
                                <?php if (!empty($providedPass)) : ?>
                                    <input type="hidden" name="pass" value="<?php echo htmlspecialchars($providedPass, ENT_QUOTES, 'UTF-8'); ?>">
                                <?php endif; ?>
                                <?php if ($currentPath !== '') : ?>
                                    <input type="hidden" name="path" value="<?php echo htmlspecialchars($currentPath, ENT_QUOTES, 'UTF-8'); ?>">
                                <?php endif; ?>
                                <?php if ($uploadToken !== '') : ?>
                                    <input type="hidden" name="share_upload_token" value="<?php echo htmlspecialchars($uploadToken, ENT_QUOTES, 'UTF-8'); ?>">
                                <?php endif; ?>
                                <input type="hidden" name="response" value="json">
                                <input type="file" id="shareDropFileInput" name="fileToUpload" multiple hidden>
                                <input type="file" id="shareDropFolderInput" name="fileToUpload" multiple webkitdirectory directory hidden>
                                <div id="shareDropzone" class="fr-share-dropzone" tabindex="0" role="button" aria-label="Upload files">
                                    <div class="fr-share-dropzone-title">Drag and drop files<?php echo $allowSubfolders ? ' or folders' : ''; ?></div>
                                    <div class="fr-share-dropzone-subtitle">Or choose files from your device.</div>
                                    <div class="fr-share-dropzone-actions">
                                        <button type="button" id="shareChooseFilesBtn" class="fr-share-btn">Choose files</button>
                                        <?php if ($allowSubfolders) : ?>
                                            <button type="button" id="shareChooseFolderBtn" class="fr-share-btn fr-share-btn-ghost">Choose folder</button>
                                        <?php endif; ?>
                                    </div>
                                </div>
                            </form>
                            <div id="shareDropRules" class="fr-share-drop-rules"></div>
                            <div id="shareDropQueue" class="fr-share-drop-queue"></div>
                        </div>
                    <?php endif; ?>

                    <?php if (!$hideListing) : ?>
                        <div id="shareListView" class="fr-share-list"></div>
                        <div id="shareGalleryView" class="fr-share-gallery" style="display:none;"></div>
                        <div id="shareEmptyState" class="fr-share-empty" style="display:none;">This folder is empty.</div>

                        <?php if ($totalPages > 1) : ?>
                            <div class="fr-share-pagination">
                                <?php if ($currentPage > 1) : ?>
                                    <a href="<?php echo htmlspecialchars($shareBaseUrl . '?' . $queryBase . '&page=' . ($currentPage - 1), ENT_QUOTES, 'UTF-8'); ?>">Prev</a>
                                <?php else : ?>
                                    <span>Prev</span>
                                <?php endif; ?>
                                <?php
                                $startPage = max(1, $currentPage - 2);
                                $endPage = min($totalPages, $currentPage + 2);
                                for ($i = $startPage; $i <= $endPage; $i++) :
                                    if ($i == $currentPage) :
                                        ?><span class="current"><?php echo $i; ?></span><?php
                                    else :
                                        ?><a href="<?php echo htmlspecialchars($shareBaseUrl . '?' . $queryBase . '&page=' . $i, ENT_QUOTES, 'UTF-8'); ?>"><?php echo $i; ?></a><?php
                                    endif;
                                endfor;
                                ?>
                                <?php if ($currentPage < $totalPages) : ?>
                                    <a href="<?php echo htmlspecialchars($shareBaseUrl . '?' . $queryBase . '&page=' . ($currentPage + 1), ENT_QUOTES, 'UTF-8'); ?>">Next</a>
                                <?php else : ?>
                                    <span>Next</span>
                                <?php endif; ?>
                            </div>
                        <?php endif; ?>
                    <?php endif; ?>
                </div>

                <div id="shareFooter" class="fr-share-footer">&copy; <?php echo date("Y"); ?> FileRise. All rights reserved.</div>
            </div>
            <script type="application/json" id="shared-data"><?php echo json_encode([
                'token' => $token,
                'entries' => $entries,
                'shareRoot' => $shareRoot,
                'path' => $currentPath,
                'allowSubfolders' => $allowSubfolders ? 1 : 0,
                'canDownloadAll' => $canDownloadAll ? 1 : 0,
                'totalEntries' => $totalEntries,
                'currentPage' => $currentPage,
                'totalPages' => $totalPages,
                'mode' => $isDropMode ? 'drop' : 'browse',
                'hideListing' => $hideListing ? 1 : 0,
                'aiEnabled' => $aiEnabled ? 1 : 0,
                'allowUpload' => $allowUpload ? 1 : 0,
                'preserveFolderStructure' => $preserveFolderStructure ? 1 : 0,
                'maxFileSizeMb' => $effectiveMaxFileSizeMb,
                'allowedTypes' => $allowedTypes,
                'dailyFileLimit' => max(0, $dailyFileLimit),
                'maxTotalMbPerDay' => max(0, $maxTotalMbPerDay),
            ], JSON_HEX_TAG | JSON_HEX_AMP); ?></script>
            <script src="<?php echo htmlspecialchars(fr_with_base_path('/js/shareBranding.js?v={{APP_QVER}}'), ENT_QUOTES, 'UTF-8'); ?>" defer></script>
            <?php if ($isDropMode) : ?>
                <script type="module" src="<?php echo htmlspecialchars(fr_with_base_path('/js/sharedDropView.js?v={{APP_QVER}}'), ENT_QUOTES, 'UTF-8'); ?>"></script>
            <?php else : ?>
                <script type="module" src="<?php echo htmlspecialchars(fr_with_base_path('/js/sharedFolderView.js?v={{APP_QVER}}'), ENT_QUOTES, 'UTF-8'); ?>"></script>
            <?php endif; ?>
        </body>

        </html>
        <?php
        exit;
    }

    /* -------------------- API: Create Share Folder Link -------------------- */
    public function createShareFolderLink(): void
    {
        header('Content-Type: application/json');
        self::requireAuth();
        self::requireCsrf();
        self::requireNotReadOnly();

        $in = json_decode(file_get_contents("php://input"), true);
        if (!$in || !isset($in['folder'])) {
            http_response_code(400);
            echo json_encode(["error" => "Invalid input."]);
            exit;
        }

        $folder      = trim((string)$in['folder']);
        $value       = isset($in['expirationValue']) ? intval($in['expirationValue']) : 60;
        $unit        = $in['expirationUnit'] ?? 'minutes';
        $password    = (string)($in['password'] ?? '');
        $allowUpload = intval($in['allowUpload'] ?? 0);
        $allowSubfolders = intval($in['allowSubfolders'] ?? 0);
        $mode = strtolower(trim((string)($in['mode'] ?? 'browse')));
        $fileDrop = $this->truthy($in['fileDrop'] ?? false) || $this->truthy($in['fileDropMode'] ?? false);
        if ($fileDrop || $mode === 'drop') {
            $mode = 'drop';
            $allowUpload = 1;
        } else {
            $mode = 'browse';
        }
        $hideListing = array_key_exists('hideListing', $in)
            ? ($this->truthy($in['hideListing']) ? 1 : 0)
            : ($mode === 'drop' ? 1 : 0);
        $aiEnabled = array_key_exists('aiEnabled', $in)
            ? ($this->truthy($in['aiEnabled']) ? 1 : 0)
            : 0;
        $preserveFolderStructure = array_key_exists('preserveFolderStructure', $in)
            ? ($this->truthy($in['preserveFolderStructure']) ? 1 : 0)
            : 1;
        $maxFileSizeMb = isset($in['maxFileSizeMb']) && is_numeric($in['maxFileSizeMb'])
            ? max(0, min(102400, (int)$in['maxFileSizeMb']))
            : 0;
        $dailyFileLimit = isset($in['dailyFileLimit']) && is_numeric($in['dailyFileLimit'])
            ? max(0, min(2000000, (int)$in['dailyFileLimit']))
            : 0;
        $maxTotalMbPerDay = isset($in['maxTotalMbPerDay']) && is_numeric($in['maxTotalMbPerDay'])
            ? max(0, min(2000000, (int)$in['maxTotalMbPerDay']))
            : 0;
        $allowedTypes = self::normalizeSharedAllowedTypes($in['allowedTypes'] ?? []);

        if ($folder !== 'root' && !preg_match(REGEX_FOLDER_NAME, $folder)) {
            http_response_code(400);
            echo json_encode(["error" => "Invalid folder name."]);
            exit;
        }

        $username = $_SESSION['username'] ?? '';
        $perms    = self::getPerms();
        $isAdmin  = self::isAdmin($perms);

        // Must have share on this folder OR be ancestor owner
        if (!(ACL::canShare($username, $perms, $folder) || self::ownsFolderOrAncestor($folder, $username, $perms))) {
            http_response_code(403);
            echo json_encode(["error" => "Sharing is not permitted for your account."]);
            exit;
        }

        // Folder-scope: need share capability within scope
        if ($msg = self::enforceFolderScope($folder, $username, $perms, 'share')) {
            http_response_code(403);
            echo json_encode(["error" => $msg]);
            exit;
        }

        // Ownership requirement unless bypassed (allow ancestor owners)
        if (!self::canBypassOwnership($perms) && !self::ownsFolderOrAncestor($folder, $username, $perms)) {
            http_response_code(403);
            echo json_encode(["error" => "Forbidden: you are not the owner of this folder."]);
            exit;
        }

        try {
            if (FolderCrypto::isEncryptedOrAncestor($folder)) {
                http_response_code(403);
                echo json_encode(["error" => "Sharing is disabled inside encrypted folders."]);
                exit;
            }
        } catch (\Throwable $e) {
/* ignore */
        }

        if ($allowUpload === 1 && !empty($perms['disableUpload']) && !$isAdmin) {
            http_response_code(403);
            echo json_encode(["error" => "You cannot enable uploads on shared folders."]);
            exit;
        }

        if ($value < 1) {
            $value = 1;
        }
        switch ($unit) {
            case 'seconds':
                $seconds = $value;
                break;
            case 'hours':
                $seconds = $value * 3600;
                break;
            case 'days':
                $seconds = $value * 86400;
                break;
            case 'minutes':
            default:
                $seconds = $value * 60;
                break;
        }
        $seconds = min($seconds, 31536000);

        $res = FolderModel::createShareFolderLink(
            $folder,
            $seconds,
            $password,
            $allowUpload,
            $allowSubfolders,
            [
                'mode' => $mode,
                'fileDrop' => $fileDrop ? 1 : 0,
                'hideListing' => $hideListing,
                'aiEnabled' => $aiEnabled,
                'preserveFolderStructure' => $preserveFolderStructure,
                'maxFileSizeMb' => $maxFileSizeMb,
                'allowedTypes' => $allowedTypes,
                'dailyFileLimit' => $dailyFileLimit,
                'maxTotalMbPerDay' => $maxTotalMbPerDay,
                'createdBy' => $username,
                'createdAt' => time(),
            ]
        );
        if (is_array($res) && !empty($res['token'])) {
            AuditHook::log('share.link.create', [
                'user'   => $username,
                'folder' => $folder,
                'path'   => $folder,
                'meta'   => [
                    'token' => $res['token'],
                ],
            ]);
            EventBus::emit('share.link.create', [
                'user' => $username,
                'shareType' => 'folder',
                'folder' => $folder,
                'hasPassword' => ($password !== ''),
                'expirationSeconds' => $seconds,
                'allowUpload' => ($allowUpload === 1),
                'allowSubfolders' => ($allowSubfolders === 1),
                'mode' => $mode,
                'aiEnabled' => ($aiEnabled === 1),
            ]);
        }
        echo json_encode($res);
        exit;
    }

    /* -------------------- API: Upload to Shared Folder -------------------- */
    public function uploadToSharedFolder(): void
    {
        if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
            http_response_code(405);
            header('Content-Type: application/json; charset=utf-8');
            echo json_encode(["error" => "Method not allowed."]);
            exit;
        }

        $token = trim((string)($_POST['token'] ?? ''));
        if ($token === '') {
            http_response_code(400);
            header('Content-Type: application/json; charset=utf-8');
            echo json_encode(["error" => "Missing share token."]);
            exit;
        }

        $subPath = (string)($_POST['path'] ?? '');
        $providedPass = (string)($_POST['pass'] ?? '');
        $uploadToken = (string)($_POST['share_upload_token'] ?? '');
        $isChunkUpload = isset($_POST['resumableChunkNumber']) || isset($_POST['resumableIdentifier']);
        $wantsJson = self::wantsJsonUploadResponse($isChunkUpload);

        $respondError = static function (int $status, string $message) {
            http_response_code($status);
            header('Content-Type: application/json; charset=utf-8');
            echo json_encode(['error' => $message]);
            exit;
        };

        $secret = (string)($GLOBALS['encryptionKey'] ?? '');
        if ($secret !== '') {
            $expectedScoped = hash_hmac('sha256', $token . '|' . $subPath . '|' . $providedPass, $secret);
            $expectedGlobal = hash_hmac('sha256', $token . '|' . $providedPass, $secret);
            $okToken = ($uploadToken !== '')
                && (hash_equals($expectedScoped, $uploadToken) || hash_equals($expectedGlobal, $uploadToken));
            if (!$okToken) {
                $respondError(403, "Upload token missing or invalid.");
            }
        }

        $ctx = FolderModel::getSharedUploadContext($token, $providedPass, $subPath);
        if (isset($ctx['needs_password'])) {
            $respondError(403, "Password required.");
        }
        if (isset($ctx['error'])) {
            $respondError(403, (string)$ctx['error']);
        }

        $record = is_array($ctx['record'] ?? null) ? $ctx['record'] : [];
        $targetFolder = (string)($ctx['folder'] ?? 'root');
        if ($targetFolder === '') {
            $targetFolder = 'root';
        }
        $allowSubfolders = !empty($ctx['allowSubfolders']);
        $preserveFolderStructure = !isset($record['preserveFolderStructure']) || !empty($record['preserveFolderStructure']);

        $clientIp = self::detectSharedClientIp();
        $tokenHash = self::shareTokenFingerprint($token);
        $rateErr = self::applySharedUploadRateLimit($tokenHash, $clientIp);
        if (is_array($rateErr) && !empty($rateErr['error'])) {
            header('Retry-After: ' . max(1, (int)($rateErr['retryAfter'] ?? 1)));
            $respondError(429, (string)$rateErr['error']);
        }

        $filename = '';
        $sizeBytes = 0;
        $relativePath = '';
        $filesForModel = [];
        $requestParams = ['folder' => $targetFolder, 'source' => 'shared'];

        if ($isChunkUpload) {
            $chunkNo = isset($_POST['resumableChunkNumber']) ? (int)$_POST['resumableChunkNumber'] : 0;
            $totalChunks = isset($_POST['resumableTotalChunks']) ? (int)$_POST['resumableTotalChunks'] : 0;
            if ($chunkNo < 1 || $totalChunks < 1 || $chunkNo > $totalChunks) {
                $respondError(400, 'Invalid upload chunk parameters.');
            }

            $identifier = trim((string)($_POST['resumableIdentifier'] ?? ''));
            if ($identifier === '' || !preg_match('/^[A-Za-z0-9_-]{1,120}$/', $identifier)) {
                $respondError(400, 'Invalid upload identifier.');
            }

            $filename = basename(trim((string)($_POST['resumableFilename'] ?? '')));
            if ($filename === '' || !preg_match(REGEX_FILE_NAME, $filename)) {
                $respondError(400, 'Invalid file name.');
            }

            $sizeBytes = isset($_POST['resumableTotalSize']) && is_numeric($_POST['resumableTotalSize'])
                ? max(0, (int)$_POST['resumableTotalSize'])
                : 0;

            $relativePath = (string)($_POST['resumableRelativePath'] ?? '');
            if (!$preserveFolderStructure) {
                $relativePath = '';
            }
            if ($relativePath !== '') {
                [$subDir, $relFile, $relErr] = self::parseUploadRelativePath($relativePath);
                if ($relErr !== null) {
                    $respondError(400, $relErr);
                }
                if ($subDir !== '' && !$allowSubfolders) {
                    $respondError(403, 'Subfolder uploads are not enabled for this share.');
                }
                if ($relFile !== '') {
                    $filename = $relFile;
                }
            }

            if (!isset($_FILES['file'])) {
                if (isset($_FILES['fileToUpload'])) {
                    $_FILES['file'] = $_FILES['fileToUpload'];
                } else {
                    $respondError(400, 'Missing upload chunk payload.');
                }
            }
            $chunkUpload = $_FILES['file'];
            if (($chunkUpload['error'] ?? UPLOAD_ERR_NO_FILE) !== UPLOAD_ERR_OK) {
                $respondError(400, 'Chunk upload failed.');
            }
            $chunkTmp = (string)($chunkUpload['tmp_name'] ?? '');
            if ($chunkTmp === '' || !is_uploaded_file($chunkTmp)) {
                $respondError(400, 'Invalid upload chunk.');
            }

            $requestParams['resumableChunkNumber'] = $chunkNo;
            $requestParams['resumableTotalChunks'] = $totalChunks;
            $requestParams['resumableIdentifier'] = $identifier;
            $requestParams['resumableFilename'] = $filename;
            $requestParams['resumableTotalSize'] = $sizeBytes;
            if ($relativePath !== '') {
                $requestParams['resumableRelativePath'] = $relativePath;
            }
            $filesForModel['file'] = $chunkUpload;
        } else {
            $fileUpload = $_FILES['fileToUpload'] ?? ($_FILES['file'] ?? null);
            if (!is_array($fileUpload)) {
                $respondError(400, 'No file was uploaded.');
            }
            if (($fileUpload['error'] ?? UPLOAD_ERR_NO_FILE) !== UPLOAD_ERR_OK) {
                $map = [
                    UPLOAD_ERR_INI_SIZE   => 'The uploaded file exceeds the upload_max_filesize directive.',
                    UPLOAD_ERR_FORM_SIZE  => 'The uploaded file exceeds the MAX_FILE_SIZE directive.',
                    UPLOAD_ERR_PARTIAL    => 'The uploaded file was only partially uploaded.',
                    UPLOAD_ERR_NO_FILE    => 'No file was uploaded.',
                    UPLOAD_ERR_NO_TMP_DIR => 'Missing a temporary folder.',
                    UPLOAD_ERR_CANT_WRITE => 'Failed to write file to disk.',
                    UPLOAD_ERR_EXTENSION  => 'A PHP extension stopped the file upload.'
                ];
                $msg = $map[$fileUpload['error']] ?? 'Upload error.';
                $respondError(400, $msg);
            }

            $tmp = (string)($fileUpload['tmp_name'] ?? '');
            if ($tmp === '' || !is_uploaded_file($tmp)) {
                $respondError(400, 'Invalid upload.');
            }

            $filename = basename((string)($fileUpload['name'] ?? ''));
            if ($filename === '' || !preg_match(REGEX_FILE_NAME, $filename)) {
                $respondError(400, 'Invalid file name.');
            }
            $sizeBytes = isset($fileUpload['size']) && is_numeric($fileUpload['size'])
                ? max(0, (int)$fileUpload['size'])
                : 0;

            $relativePath = (string)($_POST['relativePath'] ?? '');
            if (!$preserveFolderStructure) {
                $relativePath = '';
            }
            if ($relativePath !== '') {
                [$subDir, $relFile, $relErr] = self::parseUploadRelativePath($relativePath);
                if ($relErr !== null) {
                    $respondError(400, $relErr);
                }
                if ($subDir !== '' && !$allowSubfolders) {
                    $respondError(403, 'Subfolder uploads are not enabled for this share.');
                }
                if ($relFile !== '') {
                    $filename = $relFile;
                }
            }

            if ($relativePath !== '') {
                $requestParams['relativePath'] = $relativePath;
            }
            $filesForModel['file'] = $fileUpload;
        }

        $ruleError = self::validateSharedUploadRules($record, $filename, $sizeBytes);
        if ($ruleError !== null) {
            $respondError(400, $ruleError);
        }

        $quotaError = self::checkSharedDailyQuota($record, $tokenHash, $sizeBytes);
        if ($quotaError !== null) {
            header('Retry-After: 3600');
            $respondError(429, $quotaError);
        }

        $result = UploadModel::handleUpload($requestParams, $filesForModel);
        if (isset($result['error'])) {
            $respondError(400, (string)$result['error']);
        }

        $isChunkIntermediate = isset($result['status']) && (string)$result['status'] === 'chunk uploaded';
        $isSuccess = isset($result['success']) && !$isChunkIntermediate;

        if ($isSuccess) {
            self::incrementSharedDailyQuota($tokenHash, $sizeBytes);
            $folderKey = ACL::normalizeFolder($targetFolder);
            $effectiveRelPath = $relativePath !== '' ? str_replace('\\', '/', ltrim($relativePath, '/')) : $filename;
            $loggedPath = ($folderKey === 'root' || $folderKey === '')
                ? $effectiveRelPath
                : ($folderKey . '/' . $effectiveRelPath);
            self::logSharedUploadSubmission($tokenHash, $clientIp, $loggedPath, $sizeBytes);
        }

        if ($isSuccess && !$wantsJson && !$isChunkUpload) {
            $_SESSION['upload_message'] = "File uploaded successfully.";
            $redirectUrl = fr_with_base_path("/api/folder/shareFolder.php?token=" . urlencode($token));
            if ($providedPass !== '') {
                $redirectUrl .= "&pass=" . urlencode($providedPass);
            }
            if ($subPath !== '') {
                $redirectUrl .= "&path=" . urlencode($subPath);
            }
            header("Location: " . $redirectUrl);
            exit;
        }

        header('Content-Type: application/json; charset=utf-8');
        echo json_encode($result);
        exit;
    }



    /* -------------------- Admin: List/Delete Share Folder Links -------------------- */
    public function getAllShareFolderLinks(): void
    {
        header('Content-Type: application/json');
        self::requireAuth();
        self::requireAdmin(); // exposing all share folder links is an admin operation

        $metaRoot = class_exists('SourceContext')
            ? SourceContext::metaRoot()
            : rtrim((string)META_DIR, '/\\') . DIRECTORY_SEPARATOR;
        $shareFile = rtrim($metaRoot, '/\\') . DIRECTORY_SEPARATOR . 'share_folder_links.json';
        $links     = file_exists($shareFile) ? json_decode(file_get_contents($shareFile), true) ?? [] : [];
        $now       = time();
        $cleaned   = [];

        foreach ($links as $token => $record) {
            if (!empty($record['expires']) && $record['expires'] < $now) {
                continue;
            }
            $cleaned[$token] = $record;
        }

        if (count($cleaned) !== count($links)) {
            file_put_contents($shareFile, json_encode($cleaned, JSON_PRETTY_PRINT));
        }

        echo json_encode($cleaned);
    }

    public function deleteShareFolderLink()
    {
        header('Content-Type: application/json');
        self::requireAuth();
        self::requireAdmin();
        self::requireCsrf();

        $token = $_POST['token'] ?? '';
        if (!$token) {
            http_response_code(400);
            echo json_encode(['success' => false, 'error' => 'No token provided']);
            return;
        }
        $sourceId = $this->normalizeSourceId($_POST['sourceId'] ?? '');
        if ($sourceId !== '' && class_exists('SourceContext') && SourceContext::sourcesEnabled()) {
            $info = SourceContext::getSourceById($sourceId);
            if (!$info) {
                http_response_code(400);
                echo json_encode(['success' => false, 'error' => 'Invalid source id']);
                return;
            }
            $deleted = $this->withSourceContext($sourceId, function () use ($token) {
                return FolderModel::deleteShareFolderLink($token);
            }, true);
        } else {
            $deleted = FolderModel::deleteShareFolderLink($token);
        }
        if ($deleted) {
            AuditHook::log('share.link.delete', [
                'user' => $_SESSION['username'] ?? 'Unknown',
                'meta' => [
                    'token' => $token,
                ],
            ]);
            $eventPayload = [
                'user' => $_SESSION['username'] ?? 'Unknown',
                'shareType' => 'folder',
            ];
            if ($sourceId !== '') {
                $eventPayload['sourceId'] = $sourceId;
            }
            EventBus::emit('share.link.delete', $eventPayload);
            echo json_encode(['success' => true]);
        } else {
            http_response_code(404);
            echo json_encode(['success' => false, 'error' => 'Not found']);
        }
    }

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

        $user  = $_SESSION['username'] ?? '';
        $perms = $this->loadPerms($user);

        if (session_status() === PHP_SESSION_ACTIVE) {
            @session_write_close();
        }

        $map = FolderMeta::getMap();
        $out = [];
        foreach ($map as $folder => $hex) {
            $folder = FolderMeta::normalizeFolder((string)$folder);
            if ($folder === 'root') {
                continue; // don’t bother exposing root
            }
            if (ACL::canRead($user, $perms, $folder) || ACL::canReadOwn($user, $perms, $folder)) {
                $out[$folder] = $hex;
            }
        }
        echo json_encode($out, JSON_UNESCAPED_SLASHES);
    }

    public function saveFolderColor(): void
    {
        header('Content-Type: application/json; charset=utf-8');
        self::requireAuth();
        if (($_SERVER['REQUEST_METHOD'] ?? 'GET') !== 'POST') {
            http_response_code(405);
            echo json_encode(['error' => 'Method not allowed']);
            return;
        }

        // CSRF
        $hdr = $_SERVER['HTTP_X_CSRF_TOKEN'] ?? '';
        $tok = $_SESSION['csrf_token'] ?? '';
        if (!$hdr || !$tok || !hash_equals((string)$tok, (string)$hdr)) {
            http_response_code(403);
            echo json_encode(['error' => 'Invalid CSRF token']);
            return;
        }

        $user  = $_SESSION['username'] ?? '';
        $perms = $this->loadPerms($user);

        $body   = json_decode(file_get_contents('php://input') ?: "{}", true) ?: [];
        $folder = FolderMeta::normalizeFolder((string)($body['folder'] ?? 'root'));
        $raw    = array_key_exists('color', $body) ? (string)$body['color'] : '';

        if ($folder === 'root') {
            http_response_code(400);
            echo json_encode(['error' => 'Cannot set color on root']);
            return;
        }

        // >>> Require canEdit (not canRename) <<<
        if (!ACL::canEdit($user, $perms, $folder) && !ACL::isAdmin($perms)) {
            http_response_code(403);
            echo json_encode(['error' => 'Forbidden']);
            return;
        }

        try {
            // empty string clears; non-empty must be valid #RGB or #RRGGBB
            $hex = ($raw === '') ? null : FolderMeta::normalizeHex($raw);
            $res = FolderMeta::setColor($folder, $hex);
            echo json_encode(['success' => true] + $res, JSON_UNESCAPED_SLASHES);
        } catch (\InvalidArgumentException $e) {
            http_response_code(400);
            echo json_encode(['error' => 'Invalid color']);
        }
    }

    /* -------------------- API: Move Folder -------------------- */
    public function moveFolder(): void
    {
        header('Content-Type: application/json; charset=utf-8');
        self::requireAuth();
        if (($_SERVER['REQUEST_METHOD'] ?? 'GET') !== 'POST') {
            http_response_code(405);
            echo json_encode(['error' => 'Method not allowed']);
            return;
        }
        // CSRF: accept header or form field
        $hdr = $_SERVER['HTTP_X_CSRF_TOKEN'] ?? '';
        $tok = $_SESSION['csrf_token'] ?? '';
        if (!$hdr || !$tok || !hash_equals((string)$tok, (string)$hdr)) {
            http_response_code(403);
            echo json_encode(['error' => 'Invalid CSRF token']);
            return;
        }

        $input = $this->readJsonBody();
        $source = trim((string)($input['source'] ?? ''));
        $destination = trim((string)($input['destination'] ?? ''));
        $mode = strtolower(trim((string)($input['mode'] ?? 'move')));
        $asyncRequested = $this->isAsyncRequested($input);
        if ($mode !== 'move' && $mode !== 'copy') {
            http_response_code(400);
            echo json_encode(['error' => 'Invalid mode']);
            return;
        }

        $useSources = (class_exists('SourceContext') && SourceContext::sourcesEnabled());
        $rawSourceId = $useSources ? ($input['sourceId'] ?? '') : '';
        $rawDestId = $useSources ? ($input['destSourceId'] ?? '') : '';
        $sourceId = $useSources
            ? $this->normalizeSourceId($rawSourceId !== '' ? $rawSourceId : SourceContext::getActiveId())
            : '';
        $destSourceId = $useSources
            ? $this->normalizeSourceId($rawDestId !== '' ? $rawDestId : $sourceId)
            : '';
        if ($useSources && (($rawSourceId !== '' && $sourceId === '') || ($rawDestId !== '' && $destSourceId === ''))) {
            http_response_code(400);
            echo json_encode(['error' => 'Invalid source id.']);
            return;
        }
        $crossSource = ($sourceId !== '' && $destSourceId !== '' && $sourceId !== $destSourceId);

        if ($source === '' || strcasecmp($source, 'root') === 0) {
            http_response_code(400);
            echo json_encode(['error' => 'Invalid source folder']);
            return;
        }
        if ($destination === '') {
            $destination = 'root';
        }

        // basic segment validation
        foreach ([$source, $destination] as $f) {
            if ($f === 'root') {
                continue;
            }
            $parts = array_filter(explode('/', trim($f, "/\\ ")), fn($p) => $p !== '');
            foreach ($parts as $seg) {
                if (!preg_match(REGEX_FOLDER_NAME, $seg)) {
                    http_response_code(400);
                    echo json_encode(['error' => 'Invalid folder segment']);
                    return;
                }
            }
        }

        $srcNorm = trim($source, "/\\ ");
        $dstNorm = $destination === 'root' ? '' : trim($destination, "/\\ ");

        // prevent move/copy into self/descendant (same source only)
        if (!$crossSource && $dstNorm !== '' && (strcasecmp($dstNorm, $srcNorm) === 0 || strpos($dstNorm . '/', $srcNorm . '/') === 0)) {
            http_response_code(400);
            echo json_encode(['error' => 'Destination cannot be the source or its descendant']);
            return;
        }

        $username = $_SESSION['username'] ?? '';
        $perms = $this->loadPerms($username);
        $isAdmin = self::isAdmin($perms);

        if ($mode === 'copy' || $crossSource) {
            $allowDisabled = $isAdmin;
            if ($sourceId !== '' && $destSourceId !== '') {
                $sourceInfo = SourceContext::getSourceById($sourceId);
                $destInfo = SourceContext::getSourceById($destSourceId);
                if (!$sourceInfo || !$destInfo) {
                    http_response_code(400);
                    echo json_encode(['error' => 'Invalid source.']);
                    return;
                }
                if (!$isAdmin && (empty($sourceInfo['enabled']) || empty($destInfo['enabled']))) {
                    http_response_code(403);
                    echo json_encode(['error' => 'Source is disabled.']);
                    return;
                }
                if (!empty($destInfo['readOnly'])) {
                    http_response_code(403);
                    echo json_encode(['error' => 'Destination source is read-only.']);
                    return;
                }
            } elseif (class_exists('SourceContext') && SourceContext::isReadOnly()) {
                http_response_code(403);
                echo json_encode(['error' => 'Source is read-only.']);
                return;
            }

            if (!empty($perms['readOnly'])) {
                http_response_code(403);
                echo json_encode(['error' => 'Account is read-only.']);
                return;
            }
            if (!empty($perms['disableUpload'])) {
                http_response_code(403);
                echo json_encode(['error' => 'Uploads are disabled for your account.']);
                return;
            }

            $srcErr = $this->withSourceContext($sourceId, function () use ($username, $perms, $source) {
                $canManageSource = ACL::canManage($username, $perms, $source) || ACL::isOwner($username, $perms, $source);
                if (!$canManageSource) {
                    return 'Forbidden: manage rights required on source';
                }
                $sv = self::enforceFolderScope($source, $username, $perms, 'manage');
                if ($sv) {
                    return $sv;
                }
                return null;
            }, $allowDisabled);
            if ($srcErr) {
                http_response_code(403);
                echo json_encode(['error' => $srcErr]);
                return;
            }

            $dstCtx = ($destSourceId !== '' ? $destSourceId : $sourceId);
            $dstErr = $this->withSourceContext($dstCtx, function () use ($username, $perms, $destination) {
                $canCreate = ACL::canCreate($username, $perms, $destination)
                    || FolderController::ownsFolderOrAncestor($destination, $username, $perms);
                if (!$canCreate) {
                    return 'Forbidden: no write access to destination';
                }
                $dv = self::enforceFolderScope($destination, $username, $perms, 'create');
                if ($dv) {
                    return $dv;
                }
                return null;
            }, $allowDisabled);
            if ($dstErr) {
                http_response_code(403);
                echo json_encode(['error' => $dstErr]);
                return;
            }

            if ($crossSource) {
                $encErr = $this->crossSourceEncryptedError($sourceId, $source, $destSourceId, $destination);
                if ($encErr) {
                    http_response_code(400);
                    echo json_encode(['error' => $encErr]);
                    return;
                }
            }

            $baseName = basename(str_replace('\\', '/', $srcNorm));
            $target   = $destination === 'root' ? $baseName : rtrim($destination, "/\\ ") . '/' . $baseName;

            if ($asyncRequested) {
                $queued = $this->enqueueTransferJob([
                    'user' => $username,
                    'kind' => ($mode === 'move') ? 'folder_move' : 'folder_copy',
                    'itemType' => 'folder',
                    'mode' => $mode,
                    'sourceFolder' => $source,
                    'destinationFolder' => $destination,
                    'targetFolder' => $target,
                    'sourceId' => $sourceId,
                    'destSourceId' => $destSourceId,
                    'crossSource' => $crossSource,
                    'selectedFiles' => is_numeric($input['totalFiles'] ?? null) ? (int)$input['totalFiles'] : 1,
                    'selectedBytes' => is_numeric($input['totalBytes'] ?? null) ? (int)$input['totalBytes'] : 0,
                ]);
                if (isset($queued['error'])) {
                    http_response_code(500);
                    echo json_encode(['error' => $queued['error']]);
                    return;
                }
                http_response_code(202);
                echo json_encode($queued, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
                return;
            }

            self::releaseSessionLock();
            if ($crossSource) {
                $result = ($mode === 'move')
                    ? FolderModel::moveFolderAcrossSources($sourceId, $destSourceId, $source, $target)
                    : FolderModel::copyFolderAcrossSources($sourceId, $destSourceId, $source, $target);
            } else {
                $result = $this->withSourceContext($sourceId, function () use ($source, $target) {
                    return FolderModel::copyFolderSameSource($source, $target);
                }, $allowDisabled);
            }

            if (is_array($result) && (!isset($result['success']) || $result['success'])) {
                $event = ($mode === 'move') ? 'folder.move' : 'folder.copy';
                AuditHook::log($event, [
                    'user'   => $username,
                    'folder' => $target,
                    'from'   => $source,
                    'to'     => $target,
                ]);
                $eventPayload = [
                    'user' => $username,
                    'from' => $source,
                    'to' => $target,
                    'mode' => $mode,
                ];
                if ($sourceId !== '') {
                    $eventPayload['sourceId'] = $sourceId;
                }
                if ($destSourceId !== '') {
                    $eventPayload['destSourceId'] = $destSourceId;
                }
                EventBus::emit($event, $eventPayload);
            }

            echo json_encode($result, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
            return;
        }

        if ($sourceId !== '' && class_exists('SourceContext') && SourceContext::sourcesEnabled()) {
            SourceContext::setActiveId($sourceId, false, $isAdmin);
        }

        // enforce scopes (source manage-ish, dest write-ish)
        if ($msg = self::enforceFolderScope($source, $username, $perms, 'manage')) {
            http_response_code(403);
            echo json_encode(['error' => $msg]);
            return;
        }
        if ($msg = self::enforceFolderScope($destination, $username, $perms, 'write')) {
            http_response_code(403);
            echo json_encode(['error' => $msg]);
            return;
        }

        // Check capabilities using ACL helpers
        $canManageSource = ACL::canManage($username, $perms, $source) || ACL::isOwner($username, $perms, $source);
        $canMoveIntoDest = ACL::canMove($username, $perms, $destination) || ($destination === 'root' ? self::isAdmin($perms) : ACL::isOwner($username, $perms, $destination));
        if (!$canManageSource) {
            http_response_code(403);
            echo json_encode(['error' => 'Forbidden: manage rights required on source']);
            return;
        }
        if (!$canMoveIntoDest) {
            http_response_code(403);
            echo json_encode(['error' => 'Forbidden: move rights required on destination']);
            return;
        }

        // Non-admin: enforce same owner between source and destination tree (if any)
        $isAdmin = self::isAdmin($perms);
        if (!$isAdmin) {
            try {
                $ownerSrc = FolderModel::getOwnerFor($source) ?? '';
                $ownerDst = $destination === 'root' ? '' : (FolderModel::getOwnerFor($destination) ?? '');
                if ($ownerSrc !== $ownerDst) {
                    http_response_code(403);
                    echo json_encode(['error' => 'Source and destination must have the same owner']);
                    return;
                }
            } catch (\Throwable $e) { /* ignore – fall through */
            }
        }

        // Compute final target "destination/basename(source)"
        $baseName = basename(str_replace('\\', '/', $srcNorm));
        $target   = $destination === 'root' ? $baseName : rtrim($destination, "/\\ ") . '/' . $baseName;

        if ($asyncRequested) {
            $queued = $this->enqueueTransferJob([
                'user' => $username,
                'kind' => 'folder_move',
                'itemType' => 'folder',
                'mode' => 'move',
                'sourceFolder' => $source,
                'destinationFolder' => $destination,
                'targetFolder' => $target,
                'sourceId' => $sourceId,
                'destSourceId' => $destSourceId,
                'crossSource' => false,
                'selectedFiles' => is_numeric($input['totalFiles'] ?? null) ? (int)$input['totalFiles'] : 1,
                'selectedBytes' => is_numeric($input['totalBytes'] ?? null) ? (int)$input['totalBytes'] : 0,
            ]);
            if (isset($queued['error'])) {
                http_response_code(500);
                echo json_encode(['error' => $queued['error']]);
                return;
            }
            http_response_code(202);
            echo json_encode($queued, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
            return;
        }

        try {
            self::releaseSessionLock();
            $result = FolderModel::renameFolder($source, $target);
            $moveSucceeded = !is_array($result) || !isset($result['success']) || !empty($result['success']);
            if ($moveSucceeded) {
                AuditHook::log('folder.move', [
                    'user'   => $username,
                    'folder' => $target,
                    'from'   => $source,
                    'to'     => $target,
                ]);
                $eventPayload = [
                    'user' => $username,
                    'from' => $source,
                    'to' => $target,
                    'mode' => 'move',
                ];
                if ($sourceId !== '') {
                    $eventPayload['sourceId'] = $sourceId;
                }
                if ($destSourceId !== '') {
                    $eventPayload['destSourceId'] = $destSourceId;
                }
                EventBus::emit('folder.move', $eventPayload);
            }

            // migrate ACL subtree (best-effort; never block the move)
            $aclStats = [];
            if ($moveSucceeded) {
                try {
                    $aclStats = ACL::migrateSubtree($source, $target);
                } catch (\Throwable $e) {
                    error_log('moveFolder ACL-migration warning: ' . $e->getMessage());
                }
            }

            // If the move succeeded, migrate folder color mappings server-side
            $colorStats = [];
            if ($moveSucceeded) {
                try {
                    $colorStats = self::migrateFolderColors($source, $target);
                } catch (\Throwable $e) {
                    error_log('moveFolder color-migration warning: ' . $e->getMessage());
                }
            }

            // merge stats into response (single payload, non-breaking fields)
            $resultArr = is_array($result) ? $result : ['success' => true, 'target' => $target];
            $resultArr['aclMigration'] = $aclStats + ['changed' => false, 'moved' => 0];
            $resultArr['colorMigration'] = $colorStats + ['changed' => false, 'moved' => 0];
            echo json_encode($resultArr, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
        } catch (\Throwable $e) {
            error_log('moveFolder error: ' . $e->getMessage());
            http_response_code(500);
            echo json_encode(['error' => 'Internal error moving folder']);
        }
    }

    /* -------------------- API: Folder encryption jobs (v2) -------------------- */
    public function encryptionPlan(): void
    {
        header('Content-Type: application/json; charset=utf-8');
        header('Cache-Control: no-store');
        header('X-Content-Type-Options: nosniff');

        self::requireAuth();

        $folder = isset($_GET['folder']) ? (string)$_GET['folder'] : 'root';
        $folder = str_replace('\\', '/', trim($folder));
        $folder = ($folder === '' || strcasecmp($folder, 'root') === 0) ? 'root' : trim($folder, '/');

        $mode = isset($_GET['mode']) ? strtolower(trim((string)$_GET['mode'])) : 'encrypt';
        if ($mode !== 'encrypt' && $mode !== 'decrypt') {
            http_response_code(400);
            echo json_encode(['error' => 'Invalid mode.']);
            return;
        }

        // Validate folder path segments
        if ($folder !== 'root' && !preg_match(REGEX_FOLDER_NAME, $folder)) {
            http_response_code(400);
            echo json_encode(['error' => 'Invalid folder name.']);
            return;
        }

        $username = (string)($_SESSION['username'] ?? '');
        if ($username === '') {
            http_response_code(401);
            echo json_encode(['error' => 'Unauthorized']);
            return;
        }

        // Permission gate via capabilities (keeps rules centralized)
        $caps = self::capabilities($folder, $username);
        $encCaps = (is_array($caps) && isset($caps['encryption']) && is_array($caps['encryption'])) ? $caps['encryption'] : [];
        if ($mode === 'encrypt' && empty($encCaps['canEncrypt'])) {
            http_response_code(403);
            echo json_encode(['error' => 'Forbidden: cannot encrypt this folder.']);
            return;
        }
        if ($mode === 'decrypt' && empty($encCaps['canDecrypt'])) {
            http_response_code(403);
            echo json_encode(['error' => 'Forbidden: cannot decrypt this folder.']);
            return;
        }

        // Plan scan does not require master key (it only counts), but v2 is useless without it.
        if (!CryptoAtRest::isAvailable()) {
            http_response_code(500);
            echo json_encode(['error' => 'Encryption at rest is not supported on this server (libsodium secretstream missing).']);
            return;
        }
        if (!CryptoAtRest::masterKeyIsConfigured()) {
            http_response_code(409);
            echo json_encode(['error' => 'Encryption master key is not configured (Admin → Encryption at rest, or FR_ENCRYPTION_MASTER_KEY).']);
            return;
        }

        $resolved = self::cryptoResolveUploadDir($folder);
        if (isset($resolved['error'])) {
            http_response_code((int)($resolved['status'] ?? 400));
            echo json_encode(['error' => $resolved['error']]);
            return;
        }

        $dir = (string)$resolved['dir'];
        $tot = self::cryptoPlanScan($dir);

        echo json_encode([
            'ok' => true,
            'folder' => $folder,
            'mode' => $mode,
            'totalFiles' => $tot['files'],
            'totalBytes' => $tot['bytes'],
            'truncated' => $tot['truncated'],
        ], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
    }

    public function encryptionJobStart(): void
    {
        header('Content-Type: application/json; charset=utf-8');
        header('Cache-Control: no-store');
        header('X-Content-Type-Options: nosniff');

        self::requireAuth();
        if (($_SERVER['REQUEST_METHOD'] ?? '') !== 'POST') {
            http_response_code(405);
            echo json_encode(['error' => 'Method not allowed.']);
            return;
        }
        self::requireCsrf();
        self::requireNotReadOnly();

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

        $folder = isset($in['folder']) ? (string)$in['folder'] : 'root';
        $folder = str_replace('\\', '/', trim($folder));
        $folder = ($folder === '' || strcasecmp($folder, 'root') === 0) ? 'root' : trim($folder, '/');

        $mode = isset($in['mode']) ? strtolower(trim((string)$in['mode'])) : 'encrypt';
        if ($mode !== 'encrypt' && $mode !== 'decrypt') {
            http_response_code(400);
            echo json_encode(['error' => 'Invalid mode.']);
            return;
        }

        if ($folder !== 'root' && !preg_match(REGEX_FOLDER_NAME, $folder)) {
            http_response_code(400);
            echo json_encode(['error' => 'Invalid folder name.']);
            return;
        }

        $username = (string)($_SESSION['username'] ?? '');
        if ($username === '') {
            http_response_code(401);
            echo json_encode(['error' => 'Unauthorized']);
            return;
        }

        if (!CryptoAtRest::isAvailable()) {
            http_response_code(500);
            echo json_encode(['error' => 'Encryption at rest is not supported on this server (libsodium secretstream missing).']);
            return;
        }
        if (!CryptoAtRest::masterKeyIsConfigured()) {
            http_response_code(409);
            echo json_encode(['error' => 'Encryption master key is not configured (Admin → Encryption at rest, or FR_ENCRYPTION_MASTER_KEY).']);
            return;
        }

        // Permission gate via capabilities (keeps rules centralized)
        $caps = self::capabilities($folder, $username);
        $encCaps = (is_array($caps) && isset($caps['encryption']) && is_array($caps['encryption'])) ? $caps['encryption'] : [];
        if ($mode === 'encrypt' && empty($encCaps['canEncrypt'])) {
            http_response_code(403);
            echo json_encode(['error' => 'Forbidden: cannot encrypt this folder.']);
            return;
        }
        if ($mode === 'decrypt' && empty($encCaps['canDecrypt'])) {
            http_response_code(403);
            echo json_encode(['error' => 'Forbidden: cannot decrypt this folder.']);
            return;
        }

        // Prevent concurrent jobs on this folder or ancestors.
        $existingJob = FolderCrypto::getJobStatus($folder);
        if (!empty($existingJob['active']) && !empty($existingJob['job']) && is_array($existingJob['job'])) {
            http_response_code(409);
            echo json_encode([
                'error' => 'A folder encryption job is already running.',
                'job' => [
                    'id' => $existingJob['job']['id'] ?? null,
                    'type' => $existingJob['job']['type'] ?? null,
                    'state' => $existingJob['job']['state'] ?? null,
                    'root' => $existingJob['root'] ?? null,
                ],
            ], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
            return;
        }

        $resolved = self::cryptoResolveUploadDir($folder);
        if (isset($resolved['error'])) {
            http_response_code((int)($resolved['status'] ?? 400));
            echo json_encode(['error' => $resolved['error']]);
            return;
        }
        $dir = (string)$resolved['dir'];

        // v2 behavior: encryption is enabled immediately so new uploads are encrypted.
        // Decryption keeps the folder encrypted until completion (job will clear it at the end).
        if ($mode === 'encrypt') {
            $res = FolderCrypto::setEncrypted($folder, true, $username);
            if (empty($res['ok'])) {
                http_response_code(500);
                echo json_encode(['error' => $res['error'] ?? 'Failed to enable encryption for this folder.']);
                return;
            }
        }

        $totalFiles = isset($in['totalFiles']) ? (int)$in['totalFiles'] : 0;
        $totalBytes = isset($in['totalBytes']) ? (int)$in['totalBytes'] : 0;
        if ($totalFiles < 0) {
            $totalFiles = 0;
        }
        if ($totalBytes < 0) {
            $totalBytes = 0;
        }

        $jobId = bin2hex(random_bytes(16));
        $job = [
            'v' => 1,
            'id' => $jobId,
            'type' => $mode,
            'folder' => $folder,
            'startedBy' => $username,
            'createdAt' => time(),
            'updatedAt' => time(),
            'state' => 'running',
            'error' => null,
            'totalFiles' => $totalFiles,
            'totalBytes' => $totalBytes,
            'doneFiles' => 0,
            'doneBytes' => 0,
            // directory-walk state (relative to $dir)
            'queue' => [''], // '' means root dir
            'currentDir' => null,
            'currentOffset' => 0,
        ];

        self::cryptoEnsureJobsDir();
        $path = self::cryptoJobPath($jobId);
        $ok = @file_put_contents($path, json_encode($job, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES), LOCK_EX);
        if ($ok === false) {
            http_response_code(500);
            echo json_encode(['error' => 'Failed to create encryption job.']);
            return;
        }
        @chmod($path, 0664);

        // Record job marker in folder metadata so the UI can reconnect after refresh.
        try {
            FolderCrypto::setJob($folder, [
                'id' => $jobId,
                'type' => $mode,
                'state' => 'running',
                'startedAt' => time(),
            ], $username);
        } catch (\Throwable $e) {
            // best-effort; job can still run via jobId
            error_log('Failed to record crypto job marker: ' . $e->getMessage());
        }

        echo json_encode([
            'ok' => true,
            'jobId' => $jobId,
            'folder' => $folder,
            'mode' => $mode,
        ], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
    }

    public function encryptionJobStatus(): void
    {
        header('Content-Type: application/json; charset=utf-8');
        header('Cache-Control: no-store');
        header('X-Content-Type-Options: nosniff');

        self::requireAuth();

        $jobId = isset($_GET['jobId']) ? trim((string)$_GET['jobId']) : '';
        if (!preg_match('/^[a-f0-9]{16,64}$/i', $jobId)) {
            http_response_code(400);
            echo json_encode(['error' => 'Invalid job id.']);
            return;
        }

        $path = self::cryptoJobPath($jobId);
        if (!is_file($path)) {
            http_response_code(404);
            echo json_encode(['error' => 'Job not found.']);
            return;
        }

        $raw = @file_get_contents($path);
        $job = is_string($raw) ? json_decode($raw, true) : null;
        if (!is_array($job)) {
            http_response_code(500);
            echo json_encode(['error' => 'Corrupt job state.']);
            return;
        }

        // Basic authz: only the user who started the job (or admins) can view it.
        $username = (string)($_SESSION['username'] ?? '');
        if ($username === '') {
            http_response_code(401);
            echo json_encode(['error' => 'Unauthorized']);
            return;
        }
        $perms = self::getPerms();
        $isAdmin = self::isAdmin($perms);
        $startedBy = (string)($job['startedBy'] ?? '');
        if (!$isAdmin && $startedBy !== '' && strcasecmp($startedBy, $username) !== 0) {
            http_response_code(403);
            echo json_encode(['error' => 'Forbidden.']);
            return;
        }

        if (($job['state'] ?? '') === 'error') {
            $updatedAt = (int)($job['updatedAt'] ?? 0);
            if ($updatedAt <= 0) {
                $updatedAt = (int)($job['createdAt'] ?? 0);
            }
            if ($updatedAt <= 0) {
                $updatedAt = (int)@filemtime($path);
            }
            if ($updatedAt > 0 && (time() - $updatedAt) >= (7 * 24 * 60 * 60)) {
                self::cryptoDeleteJobFiles($jobId);
                http_response_code(404);
                echo json_encode(['error' => 'Job not found.']);
                return;
            }
        }

        // Return a redacted snapshot (don’t expose queue paths to clients)
        echo json_encode([
            'ok' => true,
            'job' => [
                'id' => $job['id'] ?? $jobId,
                'type' => $job['type'] ?? null,
                'folder' => $job['folder'] ?? null,
                'state' => $job['state'] ?? null,
                'error' => $job['error'] ?? null,
                'createdAt' => $job['createdAt'] ?? null,
                'updatedAt' => $job['updatedAt'] ?? null,
                'totalFiles' => $job['totalFiles'] ?? 0,
                'totalBytes' => $job['totalBytes'] ?? 0,
                'doneFiles' => $job['doneFiles'] ?? 0,
                'doneBytes' => $job['doneBytes'] ?? 0,
            ],
        ], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
    }

    public function encryptionJobTick(): void
    {
        header('Content-Type: application/json; charset=utf-8');
        header('Cache-Control: no-store');
        header('X-Content-Type-Options: nosniff');

        self::requireAuth();
        if (($_SERVER['REQUEST_METHOD'] ?? '') !== 'POST') {
            http_response_code(405);
            echo json_encode(['error' => 'Method not allowed.']);
            return;
        }
        self::requireCsrf();
        self::requireNotReadOnly();

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

        $jobId = isset($in['jobId']) ? trim((string)$in['jobId']) : '';
        if (!preg_match('/^[a-f0-9]{16,64}$/i', $jobId)) {
            http_response_code(400);
            echo json_encode(['error' => 'Invalid job id.']);
            return;
        }

        $maxFiles = isset($in['maxFiles']) ? (int)$in['maxFiles'] : 2;
        if ($maxFiles < 1) {
            $maxFiles = 1;
        }
        if ($maxFiles > 10) {
            $maxFiles = 10;
        }

        $path = self::cryptoJobPath($jobId);
        if (!is_file($path)) {
            http_response_code(404);
            echo json_encode(['error' => 'Job not found.']);
            return;
        }

        // Serialize tick processing per job to avoid overlapping conversion runs.
        $lockPath = self::cryptoJobLockPath($jobId);
        $lock = @fopen($lockPath, 'c');
        if ($lock === false) {
            http_response_code(500);
            echo json_encode(['error' => 'Failed to open job lock.']);
            return;
        }
        if (!@flock($lock, LOCK_EX)) {
            @fclose($lock);
            http_response_code(500);
            echo json_encode(['error' => 'Failed to lock job.']);
            return;
        }

        $cleanupJobFiles = false;
        try {
            $rawJob = @file_get_contents($path);
            $job = is_string($rawJob) ? json_decode($rawJob, true) : null;
            if (!is_array($job)) {
                http_response_code(500);
                echo json_encode(['error' => 'Corrupt job state.']);
                return;
            }

            $username = (string)($_SESSION['username'] ?? '');
            $perms = self::getPerms();
            $isAdmin = self::isAdmin($perms);
            $startedBy = (string)($job['startedBy'] ?? '');
            if (!$isAdmin && $startedBy !== '' && strcasecmp($startedBy, $username) !== 0) {
                http_response_code(403);
                echo json_encode(['error' => 'Forbidden.']);
                return;
            }

            $state = (string)($job['state'] ?? '');
            if ($state !== 'running') {
                echo json_encode(['ok' => true, 'job' => $job, 'note' => 'Job is not running.'], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
                return;
            }

            $folder = (string)($job['folder'] ?? 'root');
            $mode = (string)($job['type'] ?? 'encrypt');
            if ($mode !== 'encrypt' && $mode !== 'decrypt') {
                $job['state'] = 'error';
                $job['error'] = 'Invalid job type.';
                $job['updatedAt'] = time();
                @file_put_contents($path, json_encode($job, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES), LOCK_EX);
                echo json_encode(['ok' => false, 'error' => $job['error']], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
                return;
            }

            // Permission gate via capabilities (re-check each tick so scope changes don’t keep running)
            $caps = self::capabilities($folder, $username);
            $encCaps = (is_array($caps) && isset($caps['encryption']) && is_array($caps['encryption'])) ? $caps['encryption'] : [];
            if ($mode === 'encrypt' && empty($encCaps['encrypted']) && empty($encCaps['canEncrypt'])) {
                http_response_code(403);
                echo json_encode(['error' => 'Forbidden: cannot encrypt this folder.']);
                return;
            }
            if ($mode === 'decrypt') {
                // Note: capabilities intentionally disables canDecrypt during an active job to prevent starting
                // another job, but ticks must still be allowed for the job owner/admin to proceed.
                $canManageForEncryption = $isAdmin
                    || ACL::canManage($username, $perms, $folder)
                    || ACL::isOwner($username, $perms, $folder);
                if ($folder === 'root' && !$isAdmin) {
                    $canManageForEncryption = false;
                }

                $st = FolderCrypto::getStatus($folder);
                $rootEncrypted = !empty($st['rootEncrypted']);
                $inherited = !empty($st['inherited']);

                if (!$canManageForEncryption || !$rootEncrypted || $inherited) {
                    http_response_code(403);
                    echo json_encode(['error' => 'Forbidden: cannot decrypt this folder.']);
                    return;
                }
            }

            $resolved = self::cryptoResolveUploadDir($folder);
            if (isset($resolved['error'])) {
                $job['state'] = 'error';
                $job['error'] = $resolved['error'];
                $job['updatedAt'] = time();
                @file_put_contents($path, json_encode($job, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES), LOCK_EX);
                http_response_code((int)($resolved['status'] ?? 400));
                echo json_encode(['error' => $resolved['error']]);
                return;
            }
            $rootDir = (string)$resolved['dir'];

            $processed = 0;
            $processedBytes = 0;

            while ($processed < $maxFiles) {
                $next = self::cryptoJobNextFile($job, $rootDir);
                if ($next === null) {
                    // done scanning
                    $job['state'] = 'done';
                    break;
                }

                $filePath = $next['path'];
                $fileSize = $next['size'];
                $processed++;
                $processedBytes += $fileSize;

                $didWork = false;
                try {
                    if ($mode === 'encrypt') {
                        if (!CryptoAtRest::isEncryptedFile($filePath)) {
                            CryptoAtRest::encryptFileInPlace($filePath);
                            $didWork = true;
                        }
                    } else {
                        if (CryptoAtRest::isEncryptedFile($filePath)) {
                            CryptoAtRest::decryptFileInPlace($filePath);
                            $didWork = true;
                        }
                    }
                } catch (\Throwable $e) {
                    $job['state'] = 'error';
                    $job['error'] = $e->getMessage() ?: 'Crypto job failed.';
                    break;
                }

                // Progress always advances by visited file count/bytes (even if we skipped)
                $job['doneFiles'] = (int)($job['doneFiles'] ?? 0) + 1;
                $job['doneBytes'] = (int)($job['doneBytes'] ?? 0) + (int)$fileSize;

                // Best-effort: keep updatedAt reasonably fresh while the job runs
                if ($didWork) {
                    $job['updatedAt'] = time();
                }
            }

            $job['updatedAt'] = time();
            @file_put_contents($path, json_encode($job, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES), LOCK_EX);

            // Finalization hooks
            if (($job['state'] ?? '') === 'done') {
                if ($mode === 'decrypt') {
                    // v2 behavior: clear folder encryption marker after bulk decrypt finishes
                    try {
                        FolderCrypto::setEncrypted($folder, false, $username);
                    } catch (\Throwable $e) {
                        // don’t fail the job response; folder will remain encrypted
                        error_log('decrypt job finalization failed: ' . $e->getMessage());
                    }
                }
                // Clear job marker
                try {
                    FolderCrypto::setJob($folder, null, $username);
                } catch (\Throwable $e) {
                    error_log('Failed to clear crypto job marker: ' . $e->getMessage());
                }
                $cleanupJobFiles = true;
            } elseif (($job['state'] ?? '') === 'error') {
                // Persist error on folder marker (best-effort)
                try {
                    FolderCrypto::setJob($folder, [
                        'id' => $jobId,
                        'type' => $mode,
                        'state' => 'error',
                        'error' => (string)($job['error'] ?? 'Crypto job failed.'),
                        'startedAt' => (int)($job['createdAt'] ?? time()),
                    ], $username);
                } catch (\Throwable $e) {
                    error_log('Failed to record crypto job error marker: ' . $e->getMessage());
                }
            }

            echo json_encode([
                'ok' => true,
                'job' => [
                    'id' => $job['id'] ?? $jobId,
                    'type' => $job['type'] ?? null,
                    'folder' => $job['folder'] ?? null,
                    'state' => $job['state'] ?? null,
                    'error' => $job['error'] ?? null,
                    'totalFiles' => $job['totalFiles'] ?? 0,
                    'totalBytes' => $job['totalBytes'] ?? 0,
                    'doneFiles' => $job['doneFiles'] ?? 0,
                    'doneBytes' => $job['doneBytes'] ?? 0,
                    'updatedAt' => $job['updatedAt'] ?? null,
                ],
                'tick' => [
                    'processedFiles' => $processed,
                    'processedBytes' => $processedBytes,
                ],
            ], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
        } finally {
            @flock($lock, LOCK_UN);
            @fclose($lock);
            if ($cleanupJobFiles) {
                self::cryptoDeleteJobFiles($jobId);
            }
        }
    }

    /* -------------------- v2 crypto job helpers -------------------- */
    private static function cryptoJobsDir(): string
    {
        $metaRoot = class_exists('SourceContext')
            ? SourceContext::metaRoot()
            : rtrim((string)META_DIR, '/\\') . DIRECTORY_SEPARATOR;
        return rtrim($metaRoot, "/\\") . DIRECTORY_SEPARATOR . 'crypto_jobs';
    }

    private static function cryptoEnsureJobsDir(): void
    {
        $dir = self::cryptoJobsDir();
        if (!is_dir($dir)) {
            @mkdir($dir, 0775, true);
        }
    }

    private static function cryptoJobPath(string $jobId): string
    {
        $id = strtolower($jobId);
        return self::cryptoJobsDir() . DIRECTORY_SEPARATOR . 'job_' . $id . '.json';
    }

    private static function cryptoJobLockPath(string $jobId): string
    {
        $id = strtolower($jobId);
        return self::cryptoJobsDir() . DIRECTORY_SEPARATOR . 'job_' . $id . '.lock';
    }

    private static function cryptoDeleteJobFiles(string $jobId): void
    {
        $path = self::cryptoJobPath($jobId);
        if (is_file($path)) {
            @unlink($path);
        }
        $lockPath = self::cryptoJobLockPath($jobId);
        if (is_file($lockPath)) {
            @unlink($lockPath);
        }
    }

    private static function cryptoResolveUploadDir(string $folder): array
    {
        $root = class_exists('SourceContext')
            ? SourceContext::uploadRoot()
            : (string)UPLOAD_DIR;
        $base = realpath($root);
        if ($base === false) {
            return ['status' => 500, 'error' => 'Server misconfiguration.'];
        }

        if ($folder === 'root') {
            $dir = $base;
        } else {
            $guess = rtrim($root, "/\\") . DIRECTORY_SEPARATOR . str_replace('/', DIRECTORY_SEPARATOR, $folder);
            $dir = realpath($guess);
        }

        if ($dir === false || !is_dir($dir) || strpos($dir, $base) !== 0) {
            return ['status' => 404, 'error' => 'Folder not found.'];
        }

        return ['dir' => $dir, 'base' => $base];
    }

    /**
     * @return array{files:int,bytes:int,truncated:bool}
     */
    private static function cryptoPlanScan(string $rootDir): array
    {
        $skipDirs = ['trash', 'profile_pics', '@eadir'];
        $files = 0;
        $bytes = 0;
        $truncated = false;

        $it = new RecursiveIteratorIterator(
            new RecursiveDirectoryIterator($rootDir, FilesystemIterator::SKIP_DOTS),
            RecursiveIteratorIterator::SELF_FIRST
        );

        $seen = 0;
        foreach ($it as $p => $info) {
            if (++$seen > 250000) {
                $truncated = true;
                break;
            }
            $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->isFile() && !$info->isLink()) {
                $files++;
                $sz = $info->getSize();
                if (is_int($sz) && $sz > 0) {
                    $bytes += $sz;
                }
            }
        }

        return ['files' => $files, 'bytes' => $bytes, 'truncated' => $truncated];
    }

    /**
     * Finds the next file path to process for this job and advances its walk state.
     *
     * @return array{path:string,size:int}|null
     */
    private static function cryptoJobNextFile(array &$job, string $rootDir): ?array
    {
        $skipDirs = ['trash', 'profile_pics', '@eadir'];

        if (!isset($job['queue']) || !is_array($job['queue'])) {
            $job['queue'] = [''];
        }

        while (true) {
            $currentDir = $job['currentDir'] ?? null;
            if ($currentDir === null || $currentDir === false) {
                $nextDir = array_shift($job['queue']);
                if ($nextDir === null) {
                    return null;
                }
                $job['currentDir'] = (string)$nextDir;
                $job['currentOffset'] = 0;
                $currentDir = $job['currentDir'];
            }

            $abs = rtrim($rootDir, "/\\");
            if ($currentDir !== '') {
                $abs .= DIRECTORY_SEPARATOR . str_replace('/', DIRECTORY_SEPARATOR, (string)$currentDir);
            }

            if (!is_dir($abs)) {
                // skip missing dirs
                $job['currentDir'] = null;
                $job['currentOffset'] = 0;
                continue;
            }

            $names = @scandir($abs);
            if (!is_array($names)) {
                $job['currentDir'] = null;
                $job['currentOffset'] = 0;
                continue;
            }

            $names = array_values(array_filter($names, fn($n) => $n !== '.' && $n !== '..'));
            sort($names, SORT_STRING);

            $offset = (int)($job['currentOffset'] ?? 0);
            $count = count($names);
            while ($offset < $count) {
                $name = (string)$names[$offset];
                $offset++;
                $job['currentOffset'] = $offset;

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

                $childAbs = $abs . DIRECTORY_SEPARATOR . $name;
                if (@is_link($childAbs)) {
                    continue; // never follow symlinks
                }

                if (is_dir($childAbs)) {
                    $rel = ($currentDir === '') ? $name : ($currentDir . '/' . $name);
                    $job['queue'][] = $rel;
                    continue;
                }

                if (is_file($childAbs)) {
                    $sz = @filesize($childAbs);
                    if (!is_int($sz) || $sz < 0) {
                        $sz = 0;
                    }
                    return ['path' => $childAbs, 'size' => $sz];
                }
            }

            // end of directory
            $job['currentDir'] = null;
            $job['currentOffset'] = 0;
        }
    }
}
