<?php

namespace FileRise\Domain;

use FileRise\Support\ACL;
use FileRise\Support\AuditHook;
use FileRise\Support\CryptoAtRest;
use FileRise\Support\EventBus;
use FileRise\Support\UploadNamePolicy;
use FileRise\Support\WorkerLauncher;
use FileRise\Storage\StorageAdapterInterface;
use FileRise\Storage\SourceContext;
use FileRise\Storage\StorageRegistry;
use FileRise\Domain\AdminModel;
use FileRise\Domain\FolderCrypto;
use FilesystemIterator;
use RecursiveDirectoryIterator;
use RecursiveIteratorIterator;

// src/models/UploadModel.php

require_once PROJECT_ROOT . '/config/config.php';
require_once PROJECT_ROOT . '/src/lib/ACL.php';
require_once PROJECT_ROOT . '/src/lib/CryptoAtRest.php';
require_once PROJECT_ROOT . '/src/lib/AuditHook.php';
require_once PROJECT_ROOT . '/src/lib/StorageRegistry.php';
require_once PROJECT_ROOT . '/src/lib/SourceContext.php';

class UploadModel
{
    /**
     * Log file for virus detections (JSONL; one JSON record per line).
     */
    private const VIRUS_LOG_MAX_BYTES  = 5242880; // 5 MB soft rotation
    private const RESUMABLE_INDEX_FILE = 'resumable_pending.json';
    private const RESUMABLE_SWEEP_INTERVAL = 1800;
    private const RESUMABLE_INDEX_UPDATE_MIN = 120;

    private static function uploadRoot(): string
    {
        if (class_exists('SourceContext')) {
            return SourceContext::uploadRoot();
        }
        return rtrim((string)UPLOAD_DIR, '/\\') . DIRECTORY_SEPARATOR;
    }

    private static function metaRoot(): string
    {
        if (class_exists('SourceContext')) {
            SourceContext::ensureMetaDir();
            return SourceContext::metaRoot();
        }
        return rtrim((string)META_DIR, '/\\') . DIRECTORY_SEPARATOR;
    }

    private static function isLocalSourceType(): bool
    {
        if (!class_exists('SourceContext')) {
            return true;
        }
        $src = SourceContext::getActiveSource();
        $type = strtolower((string)($src['type'] ?? 'local'));
        return $type === '' || $type === 'local';
    }

    private static function stagingRoot(bool $isLocal): string
    {
        if ($isLocal) {
            return self::uploadRoot();
        }
        $base = rtrim(self::metaRoot(), '/\\') . DIRECTORY_SEPARATOR . 'uploadtmp' . DIRECTORY_SEPARATOR;
        if (!is_dir($base)) {
            @mkdir($base, 0775, true);
        }
        return $base;
    }

    private static function resumableTtlSeconds(): int
    {
        $hours = null;
        $env = getenv('FR_RESUMABLE_TTL_HOURS');
        if ($env !== false && trim((string)$env) !== '') {
            $hours = (float)$env;
        } elseif (defined('FR_RESUMABLE_TTL_HOURS')) {
            $hours = (float)FR_RESUMABLE_TTL_HOURS;
        } elseif (class_exists(AdminModel::class)) {
            $cfg = AdminModel::getConfig();
            if (is_array($cfg) && !isset($cfg['error'])) {
                $raw = $cfg['uploads']['resumableTtlHours'] ?? null;
                if (is_numeric($raw)) {
                    $hours = (float)$raw;
                }
            }
        }

        if ($hours === null || $hours <= 0) {
            $hours = 6.0;
        }

        $hours = max(0.5, min(168, $hours));

        return (int)round($hours * 3600);
    }

    private static function resumableIndexPath(): string
    {
        $base = rtrim(self::metaRoot(), '/\\') . DIRECTORY_SEPARATOR;
        if (!is_dir($base)) {
            @mkdir($base, 0775, true);
        }
        return $base . self::RESUMABLE_INDEX_FILE;
    }

    private static function resumableFolderKey(string $folderSan): string
    {
        $folderSan = trim(str_replace('\\', '/', $folderSan), '/');
        if ($folderSan === '' || $folderSan === 'root') {
            return 'root';
        }
        return $folderSan;
    }

    private static function normalizeResumableIdentifier($identifier): ?string
    {
        $identifier = trim((string)$identifier);
        if ($identifier === '') {
            return null;
        }
        if (strlen($identifier) > 512) {
            return null;
        }
        if (preg_match('/[\x00-\x1F\x7F]/', $identifier)) {
            return null;
        }
        return $identifier;
    }

    private static function resumableTempFolderName($identifier): ?string
    {
        $normalized = self::normalizeResumableIdentifier($identifier);
        if ($normalized === null) {
            return null;
        }
        return 'resumable_' . hash('sha256', $normalized);
    }

    private static function resumableTempDir(string $baseUploadDir, $identifier): ?string
    {
        $folderName = self::resumableTempFolderName($identifier);
        if ($folderName === null) {
            return null;
        }
        return rtrim($baseUploadDir, '/\\') . DIRECTORY_SEPARATOR . $folderName . DIRECTORY_SEPARATOR;
    }

    private static function isPathWithinRoot(string $path, string $root): bool
    {
        $rootReal = realpath($root);
        $pathReal = realpath($path);
        if ($rootReal === false || $pathReal === false) {
            return false;
        }

        $rootNorm = rtrim(str_replace('\\', '/', $rootReal), '/') . '/';
        $pathNorm = rtrim(str_replace('\\', '/', $pathReal), '/') . '/';

        return strpos($pathNorm, $rootNorm) === 0;
    }

    private static function loadResumableIndex(): array
    {
        $path = self::resumableIndexPath();
        if (!is_file($path)) {
            return ['lastSweep' => 0, 'folders' => []];
        }

        $raw = @file_get_contents($path);
        $data = is_string($raw) ? json_decode($raw, true) : null;
        if (!is_array($data)) {
            return ['lastSweep' => 0, 'folders' => []];
        }

        $folders = isset($data['folders']) && is_array($data['folders']) ? $data['folders'] : [];
        $lastSweep = isset($data['lastSweep']) ? (int)$data['lastSweep'] : 0;

        return ['lastSweep' => $lastSweep, 'folders' => $folders];
    }

    private static function saveResumableIndex(array $data): void
    {
        $path = self::resumableIndexPath();
        $payload = json_encode($data, JSON_UNESCAPED_SLASHES);
        if ($payload === false) {
            return;
        }
        @file_put_contents($path, $payload, LOCK_EX);
    }

    private static function markResumablePending(string $folderSan): void
    {
        $folderKey = self::resumableFolderKey($folderSan);
        $now = time();
        $data = self::loadResumableIndex();
        $folders = isset($data['folders']) && is_array($data['folders']) ? $data['folders'] : [];
        $lastSeen = isset($folders[$folderKey]) ? (int)$folders[$folderKey] : 0;

        if ($lastSeen !== 0 && ($now - $lastSeen) < self::RESUMABLE_INDEX_UPDATE_MIN) {
            return;
        }

        $folders[$folderKey] = $now;
        $data['folders'] = $folders;
        if (!isset($data['lastSweep'])) {
            $data['lastSweep'] = 0;
        }
        self::saveResumableIndex($data);
    }

    private static function cleanupResumableTempDirs(string $folderSan, bool $isLocal, bool $remove = true): bool
    {
        $baseDir = self::stagingRoot($isLocal);
        if ($folderSan !== '') {
            $baseDir = rtrim($baseDir, '/\\') . DIRECTORY_SEPARATOR
                . str_replace('/', DIRECTORY_SEPARATOR, $folderSan) . DIRECTORY_SEPARATOR;
        }
        $baseDir = rtrim($baseDir, '/\\') . DIRECTORY_SEPARATOR;
        if (!is_dir($baseDir)) {
            return false;
        }

        $regex = "/^resumable_" . PATTERN_FOLDER_NAME . "$/u";
        $entries = @scandir($baseDir);
        if (!is_array($entries)) {
            return false;
        }

        $found = false;
        foreach ($entries as $name) {
            if ($name === '.' || $name === '..') {
                continue;
            }
            if (!preg_match($regex, $name)) {
                continue;
            }
            $found = true;
            if ($remove) {
                self::rrmdir($baseDir . $name);
            }
        }

        if (!$remove) {
            return $found;
        }

        $entries = @scandir($baseDir);
        if (!is_array($entries)) {
            return false;
        }
        foreach ($entries as $name) {
            if ($name === '.' || $name === '..') {
                continue;
            }
            if (preg_match($regex, $name)) {
                return true;
            }
        }

        return false;
    }

    private static function maybeSweepResumableExpired(bool $isLocal): void
    {
        self::sweepResumableExpiredInternal($isLocal, false);
    }

    public static function sweepResumableExpired(bool $force = true): array
    {
        $isLocal = self::isLocalSourceType();
        return self::sweepResumableExpiredInternal($isLocal, $force);
    }

    /**
     * Purge all resumable temp folders tracked in the index, ignoring TTL.
     */
    public static function purgeResumableAll(): array
    {
        $isLocal = self::isLocalSourceType();
        return self::purgeResumableAllInternal($isLocal);
    }

    private static function sweepResumableExpiredInternal(bool $isLocal, bool $force): array
    {
        $ttl = self::resumableTtlSeconds();
        if ($ttl <= 0) {
            return ['checked' => 0, 'removed' => 0, 'remaining' => 0, 'skipped' => true];
        }

        $data = self::loadResumableIndex();
        $folders = isset($data['folders']) && is_array($data['folders']) ? $data['folders'] : [];
        if (!$folders) {
            $data['lastSweep'] = time();
            self::saveResumableIndex($data);
            return ['checked' => 0, 'removed' => 0, 'remaining' => 0, 'skipped' => false];
        }

        $now = time();
        $lastSweep = isset($data['lastSweep']) ? (int)$data['lastSweep'] : 0;
        if (!$force && $lastSweep !== 0 && ($now - $lastSweep) < self::RESUMABLE_SWEEP_INTERVAL) {
            return ['checked' => 0, 'removed' => 0, 'remaining' => count($folders), 'skipped' => true];
        }

        $checked = 0;
        $removed = 0;
        $remaining = 0;

        foreach ($folders as $folderKey => $lastSeenRaw) {
            $lastSeen = (int)$lastSeenRaw;
            if ($lastSeen <= 0) {
                unset($folders[$folderKey]);
                continue;
            }
            $checked++;
            if (($now - $lastSeen) < $ttl) {
                $remaining++;
                continue;
            }
            $folderSan = ($folderKey === 'root') ? '' : (string)$folderKey;
            $hasRemaining = self::cleanupResumableTempDirs($folderSan, $isLocal, true);
            if (!$hasRemaining) {
                unset($folders[$folderKey]);
                $removed++;
            } else {
                $remaining++;
            }
        }

        $data['folders'] = $folders;
        $data['lastSweep'] = $now;
        self::saveResumableIndex($data);
        return ['checked' => $checked, 'removed' => $removed, 'remaining' => $remaining, 'skipped' => false];
    }

    private static function purgeResumableAllInternal(bool $isLocal): array
    {
        $data = self::loadResumableIndex();
        $folders = isset($data['folders']) && is_array($data['folders']) ? $data['folders'] : [];
        $now = time();

        if (!$folders) {
            $data['lastSweep'] = $now;
            self::saveResumableIndex($data);
            return ['checked' => 0, 'removed' => 0, 'remaining' => 0, 'skipped' => false];
        }

        $checked = 0;
        $removed = 0;
        $remaining = 0;

        foreach ($folders as $folderKey => $lastSeenRaw) {
            $lastSeen = (int)$lastSeenRaw;
            if ($lastSeen <= 0) {
                unset($folders[$folderKey]);
                continue;
            }
            $checked++;
            $folderSan = ($folderKey === 'root') ? '' : (string)$folderKey;
            $hasRemaining = self::cleanupResumableTempDirs($folderSan, $isLocal, true);
            if (!$hasRemaining) {
                unset($folders[$folderKey]);
                $removed++;
            } else {
                $remaining++;
            }
        }

        $data['folders'] = $folders;
        $data['lastSweep'] = $now;
        self::saveResumableIndex($data);
        return ['checked' => $checked, 'removed' => $removed, 'remaining' => $remaining, 'skipped' => false];
    }

    private static function buildStorageDir(string $folderSan, string $relativeSubDir): string
    {
        $base = rtrim(self::uploadRoot(), '/\\');
        $path = $base;
        if ($folderSan !== '') {
            $path .= DIRECTORY_SEPARATOR . str_replace('/', DIRECTORY_SEPARATOR, $folderSan);
        }
        if ($relativeSubDir !== '') {
            $path .= DIRECTORY_SEPARATOR . str_replace('/', DIRECTORY_SEPARATOR, $relativeSubDir);
        }
        return $path;
    }

    private static function buildStoragePath(string $folderSan, string $relativeSubDir, string $fileName): string
    {
        $path = self::buildStorageDir($folderSan, $relativeSubDir);
        return $path . DIRECTORY_SEPARATOR . $fileName;
    }

    private static function ensureRemoteUploadDir(StorageAdapterInterface $storage, string $folderSan, string $relativeSubDir): void
    {
        if ($storage->isLocal()) {
            return;
        }
        $dir = rtrim(self::buildStorageDir($folderSan, $relativeSubDir), '/\\');
        if ($dir === '' || $dir === '.') {
            return;
        }
        try {
            $stat = $storage->stat($dir);
            if ($stat && ($stat['type'] ?? '') === 'dir') {
                return;
            }
            $storage->mkdir($dir, 0775, true);
        } catch (\Throwable $e) {
            // Best-effort: some backends may auto-create paths on write.
        }
    }

    private static function buildFolderForLog(string $folderSan, string $relativeSubDir = ''): string
    {
        $folderForLog = ($folderSan === '') ? 'root' : $folderSan;
        if ($relativeSubDir !== '') {
            $folderForLog = ($folderForLog === 'root')
                ? $relativeSubDir
                : ($folderForLog . '/' . $relativeSubDir);
        }
        return $folderForLog;
    }

    private static function sanitizeFolder(string $folder): string
    {
        // decode "%20", normalise slashes & trim via ACL helper
        $f = ACL::normalizeFolder(rawurldecode($folder));

        // model uses '' to represent root
        if ($f === 'root') {
            return '';
        }

        // forbid dot segments / empty parts
        foreach (explode('/', $f) as $seg) {
            if ($seg === '' || $seg === '.' || $seg === '..') {
                return '';
            }
        }

        // allow spaces & unicode via your global regex
        // (REGEX_FOLDER_NAME validates a path "seg(/seg)*")
        if (!preg_match(REGEX_FOLDER_NAME, $f)) {
            return '';
        }

        return $f; // safe, normalised, with spaces allowed
    }

    /**
     * Parse a resumable relative path into [subDir, fileName].
     * Returns [null, null] on invalid input.
     */
    private static function parseRelativePath(string $raw): array
    {
        $raw = rawurldecode($raw);
        $raw = str_replace('\\', '/', trim($raw));
        $raw = preg_replace('/[\x00-\x1F\x7F]/', '', $raw);
        $raw = ltrim($raw, '/');
        if ($raw === '' || $raw === '.') {
            return ['', ''];
        }
        if (preg_match('~(^|/)\.\.(?:/|$)~', $raw)) {
            return [null, null];
        }
        if (preg_match('~(^|/)\.(?:/|$)~', $raw)) {
            return [null, null];
        }

        $file = basename($raw);
        if (!UploadNamePolicy::isAllowedForWrite($file)) {
            return [null, null];
        }

        $dir = dirname($raw);
        if ($dir === '.' || $dir === '') {
            return ['', $file];
        }

        if (!preg_match(REGEX_FOLDER_NAME, $dir)) {
            return [null, null];
        }

        return [$dir, $file];
    }

    private static function portalMetaFromRequest(): ?array
    {
        $src = $_POST['source'] ?? $_GET['source'] ?? '';
        if (strtolower((string)$src) !== 'portal') {
            return null;
        }
        $slug = trim((string)($_POST['portal'] ?? $_GET['portal'] ?? ''));
        if ($slug === '') {
            return null;
        }
        $slug = str_replace(["\r", "\n"], '', $slug);
        return ['portal' => $slug];
    }

    private static function normalizeEventSource($value): string
    {
        $source = strtolower(trim((string)$value));
        if ($source === '') {
            return 'upload';
        }
        if (!preg_match('/^[a-z0-9_-]{1,32}$/', $source)) {
            return 'upload';
        }
        return $source;
    }

    /**
     * @param array<string,mixed> $post
     */
    private static function emitUploadEvent(string $user, string $folder, string $filename, array $post): void
    {
        $folder = ACL::normalizeFolder($folder);
        if ($folder === '') {
            $folder = 'root';
        }

        $path = ($folder === 'root') ? $filename : ($folder . '/' . $filename);
        $source = self::normalizeEventSource($post['source'] ?? '');

        $payload = [
            'user' => $user,
            'folder' => $folder,
            'path' => $path,
            'source' => $source,
        ];

        $sourceId = trim((string)($post['sourceId'] ?? ''));
        if ($sourceId !== '' && preg_match('/^[A-Za-z0-9_-]{1,64}$/', $sourceId)) {
            $payload['sourceId'] = $sourceId;
        }

        if ($source === 'portal') {
            $portal = trim((string)($post['portal'] ?? ''));
            if ($portal !== '') {
                $portal = str_replace(["\r", "\n"], '', $portal);
                $payload['portal'] = $portal;
            }
        }

        EventBus::emit('file.upload', $payload);
    }

    private static function isVirusScanEnabled(): bool
    {
        // 1) Container env override (most explicit)
        $env = getenv('VIRUS_SCAN_ENABLED');
        if ($env !== false && $env !== '') {
            // Accept "1", "true", "0", "false", etc.
            $envBool = filter_var($env, FILTER_VALIDATE_BOOLEAN, FILTER_NULL_ON_FAILURE);
            return $envBool === true;
        }

        // 2) PHP constant override (non-container / legacy setups)
        if (defined('VIRUS_SCAN_ENABLED')) {
            return (bool)VIRUS_SCAN_ENABLED;
        }

        // 3) Admin configuration toggle
        if (!class_exists(AdminModel::class)) {
            return false;
        }

        $cfg = AdminModel::getConfig();
        if (!is_array($cfg) || isset($cfg['error'])) {
            return false;
        }

        if (empty($cfg['clamav']) || !is_array($cfg['clamav'])) {
            return false;
        }

        return !empty($cfg['clamav']['scanUploads']);
    }

    private static function getVirusScanExcludeRules(): array
    {
        static $rules = null;
        if ($rules !== null) {
            return $rules;
        }

        $raw = '';
        $env = getenv('VIRUS_SCAN_EXCLUDE_DIRS');
        if ($env !== false && trim((string)$env) !== '') {
            $raw = (string)$env;
        } elseif (class_exists(AdminModel::class)) {
            $cfg = AdminModel::getConfig();
            if (is_array($cfg) && !isset($cfg['error'])) {
                $raw = (string)($cfg['clamav']['excludeDirs'] ?? '');
            }
        }

        $rules = [];
        if (trim($raw) !== '') {
            $parts = preg_split('/[,\r\n]+/', $raw);
            if (is_array($parts)) {
                foreach ($parts as $entry) {
                    $entry = trim((string)$entry);
                    if ($entry === '') {
                        continue;
                    }

                    $source = '';
                    $path = $entry;
                    if (strpos($entry, ':') !== false) {
                        [$maybeSource, $rest] = explode(':', $entry, 2);
                        $maybeSource = trim($maybeSource);
                        if ($maybeSource !== '' && preg_match('/^[A-Za-z0-9_-]{1,64}$/', $maybeSource)) {
                            $source = $maybeSource;
                            $path = $rest;
                        }
                    }

                    $path = self::normalizeVirusScanExcludePath($path);
                    if ($path === '') {
                        continue;
                    }

                    $rules[] = [
                        'source' => $source,
                        'path'   => $path,
                    ];
                }
            }
        }

        return $rules;
    }

    private static function normalizeVirusScanExcludePath(string $path): string
    {
        $norm = str_replace('\\', '/', trim($path));
        return trim($norm, "/ \t\n\r\0\x0B");
    }

    private static function isVirusScanExcluded(array $context): bool
    {
        $rules = self::getVirusScanExcludeRules();
        if (empty($rules)) {
            return false;
        }

        $folder = trim((string)($context['folder'] ?? ''));
        if ($folder === '') {
            return false;
        }
        $folder = self::normalizeVirusScanExcludePath($folder);
        if ($folder === '') {
            return false;
        }

        $activeSource = class_exists('SourceContext') ? SourceContext::getActiveId() : 'local';

        foreach ($rules as $rule) {
            $ruleSource = (string)($rule['source'] ?? '');
            if ($ruleSource !== '' && $ruleSource !== $activeSource) {
                continue;
            }
            $path = (string)($rule['path'] ?? '');
            if ($path === '') {
                continue;
            }
            if ($folder === $path || str_starts_with($folder, $path . '/')) {
                return true;
            }
        }

        return false;
    }

    /**
     * Public helper: scan a single $_FILES-style upload array, if ClamAV is enabled.
     *
     * Used by places like shared-folder upload so they can reuse the same logic.
     *
     * $context may include:
     *   - 'user'       => override username (default: current session)
     *   - 'ip'         => override client IP (default: derived from $_SERVER)
     *   - 'folder'     => logical folder name / path
     *   - 'file'       => original file name
     *   - 'source'     => e.g. "normal", "shared", "portal", "self_test"
     *   - 'suppressLog'=> true to skip logging (used by self-test endpoint)
     *
     * Returns:
     *   - null           => scanning disabled or clean
     *   - ['error' => …] => infected or scan error (file is deleted)
     */
    public static function scanSingleUploadIfEnabled(array $upload, array $context = []): ?array
    {
        // Respect same toggle logic (env + admin config)
        if (!self::isVirusScanEnabled()) {
            return null;
        }

        $tmp = $upload['tmp_name'] ?? '';
        if (!$tmp || !is_file($tmp)) {
            return ['error' => 'Virus scan failed: uploaded file not found.'];
        }

        // Default file name in log context, if not provided by caller
        if (!isset($context['file']) && isset($upload['name'])) {
            $context['file'] = (string)$upload['name'];
        }

        return self::scanFileIfEnabled($tmp, $context);
    }

    private static function adapterErrorDetail(StorageAdapterInterface $storage): string
    {
        if (method_exists($storage, 'getLastError')) {
            $detail = trim((string)$storage->getLastError());
            if ($detail !== '') {
                $detail = preg_replace('/(\\w+:\\/\\/)([^\\s@]+@)/i', '$1', $detail) ?? $detail;
                if (strlen($detail) > 240) {
                    $detail = substr($detail, 0, 240) . '...';
                }
                return $detail;
            }
        }
        return '';
    }

    public static function handleUpload(array $post, array $files): array
    {
        $storage = StorageRegistry::getAdapter();
        $isLocal = self::isLocalSourceType();
        if (!$isLocal && $storage->isLocal()) {
            return ['error' => 'Remote storage adapter unavailable.'];
        }

        // --- GET resumable test (make folder handling consistent) ---
        if (
            (($_SERVER['REQUEST_METHOD'] ?? 'GET') === 'GET')
            && isset($post['resumableChunkNumber'], $post['resumableIdentifier'])
        ) {
            $chunkNumber         = (int)($post['resumableChunkNumber'] ?? 0);
            $resumableIdentifier = self::normalizeResumableIdentifier($post['resumableIdentifier'] ?? '');
            $folderSan           = self::sanitizeFolder((string)($post['folder'] ?? 'root'));

            if ($chunkNumber < 1 || $resumableIdentifier === null) {
                return ['error' => 'Invalid resumable upload request'];
            }

            $baseUploadDir = self::stagingRoot($isLocal);
            if ($folderSan !== '') {
                $baseUploadDir = rtrim($baseUploadDir, '/\\') . DIRECTORY_SEPARATOR
                    . str_replace('/', DIRECTORY_SEPARATOR, $folderSan) . DIRECTORY_SEPARATOR;
            }

            $tempDir = self::resumableTempDir($baseUploadDir, $resumableIdentifier);
            if ($tempDir === null) {
                return ['error' => 'Invalid resumable upload request'];
            }
            $chunkFile = $tempDir . $chunkNumber;

            return ['status' => file_exists($chunkFile) ? 'found' : 'not found'];
        }

        // --- CHUNKED (Resumable.js POST uploads) ---
        if (isset($post['resumableChunkNumber'])) {
            $chunkNumber         = (int)$post['resumableChunkNumber'];
            $totalChunks         = (int)$post['resumableTotalChunks'];
            $resumableIdentifier = self::normalizeResumableIdentifier($post['resumableIdentifier'] ?? '');
            $resumableFilename   = urldecode(basename($post['resumableFilename'] ?? ''));
            $relativeSubDir      = '';

            if ($chunkNumber < 1 || $totalChunks < 1 || $chunkNumber > $totalChunks || $resumableIdentifier === null) {
                return ['error' => 'Invalid resumable upload request'];
            }

            if (!empty($post['resumableRelativePath'])) {
                [$subDir, $relFile] = self::parseRelativePath((string)$post['resumableRelativePath']);
                if ($subDir === null) {
                    return ['error' => 'Invalid relative path'];
                }
                if ($relFile !== '') {
                    $resumableFilename = $relFile;
                }
                $relativeSubDir = $subDir;
            }

            if (!UploadNamePolicy::isAllowedForWrite($resumableFilename)) {
                return ['error' => "Invalid file name: $resumableFilename"];
            }

            $folderSan = self::sanitizeFolder((string)($post['folder'] ?? 'root'));
            self::markResumablePending($folderSan);
            self::maybeSweepResumableExpired($isLocal);

            if (empty($files['file']) || !isset($files['file']['name'])) {
                return ['error' => 'No files received'];
            }

            $createdDirs = [];
            $stagingRoot = self::stagingRoot($isLocal);
            $baseUploadDir = $stagingRoot;
            if ($folderSan !== '') {
                $baseUploadDir = rtrim($baseUploadDir, '/\\') . DIRECTORY_SEPARATOR
                    . str_replace('/', DIRECTORY_SEPARATOR, $folderSan) . DIRECTORY_SEPARATOR;
            }
            if (!self::ensureDir($baseUploadDir, $createdDirs)) {
                return ['error' => 'Failed to create upload directory'];
            }

            $tempDir = self::resumableTempDir($baseUploadDir, $resumableIdentifier);
            if ($tempDir === null) {
                return ['error' => 'Invalid resumable upload request'];
            }
            if (!self::ensureDir($tempDir, $createdDirs)) {
                return ['error' => 'Failed to create temporary chunk directory'];
            }

            $chunkErr = $files['file']['error'] ?? UPLOAD_ERR_NO_FILE;
            if ($chunkErr !== UPLOAD_ERR_OK) {
                return ['error' => "Upload error on chunk $chunkNumber"];
            }

            $chunkFile = $tempDir . $chunkNumber;
            $tmpName   = $files['file']['tmp_name'] ?? null;
            if (!$tmpName || !move_uploaded_file($tmpName, $chunkFile)) {
                return ['error' => "Failed to move uploaded chunk $chunkNumber"];
            }

            // All chunks present?
            for ($i = 1; $i <= $totalChunks; $i++) {
                if (!file_exists($tempDir . $i)) {
                    return ['status' => 'chunk uploaded'];
                }
            }

            $cleanupChunk = function () use ($tempDir, $baseUploadDir, $createdDirs, $stagingRoot, $isLocal): void {
                if (self::isPathWithinRoot($tempDir, $baseUploadDir)) {
                    self::rrmdir($tempDir);
                }
                if (!$isLocal) {
                    self::cleanupCreatedDirs($createdDirs, $stagingRoot);
                }
            };

            // Merge
            $targetDir = $baseUploadDir;
            if ($relativeSubDir !== '') {
                $targetDir = rtrim($baseUploadDir, '/\\') . DIRECTORY_SEPARATOR
                    . str_replace('/', DIRECTORY_SEPARATOR, $relativeSubDir) . DIRECTORY_SEPARATOR;
                if (!self::ensureDir($targetDir, $createdDirs)) {
                    $cleanupChunk();
                    return ['error' => 'Failed to create upload subfolder'];
                }
            }
            $targetPath = $targetDir . $resumableFilename;
            if (!$out = fopen($targetPath, 'wb')) {
                $cleanupChunk();
                return ['error' => 'Failed to open target file for writing'];
            }
            for ($i = 1; $i <= $totalChunks; $i++) {
                $chunkPath = $tempDir . $i;
                if (!file_exists($chunkPath)) {
                    fclose($out);
                    $cleanupChunk();
                    return ['error' => "Chunk $i missing during merge"];
                }
                if (!$in = fopen($chunkPath, 'rb')) {
                    fclose($out);
                    $cleanupChunk();
                    return ['error' => "Failed to open chunk $i"];
                }
                while ($buff = fread($in, 4096)) {
                    fwrite($out, $buff);
                }
                fclose($in);
            }
            fclose($out);

            // Optional: virus scan the merged file
            $folderForLog = self::buildFolderForLog($folderSan, $relativeSubDir);
            $scanResult   = self::scanFileIfEnabled($targetPath, [
                'folder' => $folderForLog,
                'file'   => $resumableFilename,
                'source' => 'normal', // core/resumable upload
            ]);

            if (is_array($scanResult) && isset($scanResult['error'])) {
                $cleanupChunk();
                return $scanResult; // e.g. "Upload blocked: virus detected in file."
            }

            if (!$isLocal) {
                try {
                    if (FolderCrypto::isEncryptedOrAncestor($folderForLog)) {
                        @unlink($targetPath);
                        $cleanupChunk();
                        return ['error' => 'Encrypted folders are not supported for remote storage.'];
                    }
                } catch (\Throwable $e) {
/* ignore */
                }
            }

            // Encrypt at rest if folder is marked encrypted (local storage only)
            if ($isLocal) {
                try {
                    if (FolderCrypto::isEncryptedOrAncestor($folderForLog)) {
                        if (!CryptoAtRest::isAvailable()) {
                            throw new \RuntimeException('Upload failed: encryption at rest is not supported on this server (libsodium secretstream missing).');
                        }
                        if (!CryptoAtRest::masterKeyIsConfigured()) {
                            throw new \RuntimeException('Upload failed: destination folder is encrypted but the encryption master key is not configured (Admin → Encryption at rest, or FR_ENCRYPTION_MASTER_KEY).');
                        }
                        CryptoAtRest::encryptFileInPlace($targetPath);
                    }
                } catch (\Throwable $e) {
                    error_log('Upload encryption failed: ' . $e->getMessage());
                    @unlink($targetPath);
                    $cleanupChunk();
                    $msg = $e->getMessage();
                    if (!is_string($msg) || trim($msg) === '') {
                        $msg = 'Upload failed: could not encrypt file at rest.';
                    }
                    return ['error' => $msg];
                }
            }

            if (!$isLocal) {
                self::ensureRemoteUploadDir($storage, $folderSan, $relativeSubDir);
                $remoteTargetPath = self::buildStoragePath($folderSan, $relativeSubDir, $resumableFilename);
                $mimeType = function_exists('mime_content_type') ? mime_content_type($targetPath) : null;
                $size = @filesize($targetPath);
                $stream = @fopen($targetPath, 'rb');
                if ($stream === false) {
                    @unlink($targetPath);
                    $cleanupChunk();
                    return ['error' => 'Failed to open file for remote upload.'];
                }
                $ok = $storage->writeStream($remoteTargetPath, $stream, ($size === false ? null : (int)$size), $mimeType ?: null);
                @fclose($stream);
                if (!$ok) {
                    $detail = self::adapterErrorDetail($storage);
                    @unlink($targetPath);
                    $cleanupChunk();
                    return ['error' => $detail !== '' ? ('Failed to upload to remote storage: ' . $detail) : 'Failed to upload to remote storage.'];
                }
                @unlink($targetPath);
            }

            // Metadata
            $metadataKey      = ($folderForLog === '' ? 'root' : $folderForLog);
            $metadataFileName = str_replace(['/', '\\', ' '], '-', $metadataKey) . '_metadata.json';
            $metadataFile     = self::metaRoot() . $metadataFileName;
            $uploadedDate     = date(DATE_TIME_FORMAT);
            $uploader         = $_SESSION['username'] ?? 'Unknown';
            $collection       = file_exists($metadataFile)
                ? json_decode(file_get_contents($metadataFile), true)
                : [];
            if (!is_array($collection)) {
                $collection = [];
            }
            if (!isset($collection[$resumableFilename])) {
                $collection[$resumableFilename] = [
                    'uploaded' => $uploadedDate,
                    'uploader' => $uploader,
                ];
                file_put_contents($metadataFile, json_encode($collection, JSON_PRETTY_PRINT));
            }

            AuditHook::log('file.upload', [
                'user'   => $uploader,
                'folder' => $folderForLog,
                'path'   => ($folderForLog === 'root') ? $resumableFilename : ($folderForLog . '/' . $resumableFilename),
                'meta'   => self::portalMetaFromRequest(),
            ]);
            self::emitUploadEvent($uploader, $folderForLog, $resumableFilename, $post);

            $cleanupChunk();

            return ['success' => 'File uploaded successfully'];
        }

        // --- NON-CHUNKED (drag-and-drop / folder uploads) ---
        $createdDirs = [];
        try {
            $folderSan = self::sanitizeFolder((string)($post['folder'] ?? 'root'));

            $baseUploadDir = self::uploadRoot();
            if ($folderSan !== '') {
                $baseUploadDir = rtrim($baseUploadDir, '/\\') . DIRECTORY_SEPARATOR
                    . str_replace('/', DIRECTORY_SEPARATOR, $folderSan) . DIRECTORY_SEPARATOR;
            }
            if ($isLocal && !self::ensureDir($baseUploadDir, $createdDirs)) {
                return ['error' => 'Failed to create upload directory'];
            }

            $metadataCollection  = [];
            $metadataChanged     = [];

            if (empty($files['file']) || empty($files['file']['name'])) {
                return ['error' => 'No files received'];
            }
            if (!is_array($files['file']['name'])) {
                $files['file']['name'] = [$files['file']['name']];
                $files['file']['tmp_name'] = [$files['file']['tmp_name'] ?? ''];
                $files['file']['error'] = [$files['file']['error'] ?? UPLOAD_ERR_OK];
            }

            foreach ($files['file']['name'] as $index => $fileName) {
                if (($files['file']['error'][$index] ?? UPLOAD_ERR_OK) !== UPLOAD_ERR_OK) {
                    return ['error' => 'Error uploading file'];
                }

                $safeFileName = trim(urldecode(basename($fileName)));

                $relativePath = '';
                if (isset($post['relativePath'])) {
                    $relativePath = is_array($post['relativePath'])
                        ? ($post['relativePath'][$index] ?? '')
                        : $post['relativePath'];
                }

                $uploadDir = rtrim($baseUploadDir, '/\\') . DIRECTORY_SEPARATOR;
                $relativeSubDir = '';
                if (!empty($relativePath)) {
                    [$subDir, $relFile] = self::parseRelativePath((string)$relativePath);
                    if ($subDir === null) {
                        return ['error' => 'Invalid relative path'];
                    }
                    if ($relFile !== '') {
                        $safeFileName = $relFile;
                    }
                    $relativeSubDir = $subDir;
                    if ($relativeSubDir !== '') {
                        $uploadDir = rtrim($baseUploadDir, '/\\') . DIRECTORY_SEPARATOR
                            . str_replace('/', DIRECTORY_SEPARATOR, $relativeSubDir) . DIRECTORY_SEPARATOR;
                    }
                }
                if (!UploadNamePolicy::isAllowedForWrite($safeFileName)) {
                    return ['error' => 'Invalid file name: ' . $fileName];
                }

                $folderForLog = self::buildFolderForLog($folderSan, $relativeSubDir);

                if ($isLocal) {
                    if (!self::ensureDir($uploadDir, $createdDirs)) {
                        return ['error' => 'Failed to create subfolder: ' . $uploadDir];
                    }

                    $targetPath = $uploadDir . $safeFileName;
                    if (!move_uploaded_file($files['file']['tmp_name'][$index], $targetPath)) {
                        return ['error' => 'Error uploading file'];
                    }
                    $scanPath = $targetPath;
                } else {
                    $tmpPath = $files['file']['tmp_name'][$index] ?? '';
                    if ($tmpPath === '' || !is_uploaded_file($tmpPath)) {
                        return ['error' => 'Error uploading file'];
                    }
                    self::ensureRemoteUploadDir($storage, $folderSan, $relativeSubDir);
                    $targetPath = self::buildStoragePath($folderSan, $relativeSubDir, $safeFileName);
                    $scanPath = $tmpPath;
                }

                // Optional: virus scan this file
                $scanResult = self::scanFileIfEnabled($scanPath, [
                    'folder' => $folderForLog,
                    'file'   => $safeFileName,
                    'source' => 'normal', // core non-resumable upload
                ]);

                if (is_array($scanResult) && isset($scanResult['error'])) {
                    // scanFileIfEnabled already unlinks the file on failure/infection
                    return $scanResult;
                }

                if (!$isLocal) {
                    try {
                        if (FolderCrypto::isEncryptedOrAncestor($folderForLog)) {
                            @unlink($scanPath);
                            return ['error' => 'Encrypted folders are not supported for remote storage.'];
                        }
                    } catch (\Throwable $e) {
/* ignore */
                    }
                }

                // Encrypt at rest if destination folder is encrypted (local storage only)
                if ($isLocal) {
                    try {
                        if (FolderCrypto::isEncryptedOrAncestor($folderForLog)) {
                            if (!CryptoAtRest::isAvailable()) {
                                throw new \RuntimeException('Upload failed: encryption at rest is not supported on this server (libsodium secretstream missing).');
                            }
                            if (!CryptoAtRest::masterKeyIsConfigured()) {
                                throw new \RuntimeException('Upload failed: destination folder is encrypted but the encryption master key is not configured (Admin → Encryption at rest, or FR_ENCRYPTION_MASTER_KEY).');
                            }
                            CryptoAtRest::encryptFileInPlace($targetPath);
                        }
                    } catch (\Throwable $e) {
                        error_log('Upload encryption failed: ' . $e->getMessage());
                        @unlink($targetPath);
                        $msg = $e->getMessage();
                        if (!is_string($msg) || trim($msg) === '') {
                            $msg = 'Upload failed: could not encrypt file at rest.';
                        }
                        return ['error' => $msg];
                    }
                }

                if (!$isLocal) {
                    $mimeType = function_exists('mime_content_type') ? mime_content_type($scanPath) : null;
                    $size = @filesize($scanPath);
                    $stream = @fopen($scanPath, 'rb');
                    if ($stream === false) {
                        @unlink($scanPath);
                        return ['error' => 'Failed to open file for remote upload.'];
                    }
                    $ok = $storage->writeStream($targetPath, $stream, ($size === false ? null : (int)$size), $mimeType ?: null);
                    @fclose($stream);
                    if (!$ok) {
                        $detail = self::adapterErrorDetail($storage);
                        @unlink($scanPath);
                        return ['error' => $detail !== '' ? ('Failed to upload to remote storage: ' . $detail) : 'Failed to upload to remote storage.'];
                    }
                    @unlink($scanPath);
                }

                $uploader = $_SESSION['username'] ?? 'Unknown';
                AuditHook::log('file.upload', [
                    'user'   => $uploader,
                    'folder' => $folderForLog,
                    'path'   => ($folderForLog === 'root') ? $safeFileName : ($folderForLog . '/' . $safeFileName),
                    'meta'   => self::portalMetaFromRequest(),
                ]);
                self::emitUploadEvent($uploader, $folderForLog, $safeFileName, $post);

                $metadataKey      = ($folderSan === '') ? 'root' : $folderSan;
                $metadataFileName = str_replace(['/', '\\', ' '], '-', $metadataKey) . '_metadata.json';
                $metadataFile     = self::metaRoot() . $metadataFileName;

                if (!isset($metadataCollection[$metadataKey])) {
                    $metadataCollection[$metadataKey] = file_exists($metadataFile)
                        ? json_decode(file_get_contents($metadataFile), true)
                        : [];
                    if (!is_array($metadataCollection[$metadataKey])) {
                        $metadataCollection[$metadataKey] = [];
                    }
                    $metadataChanged[$metadataKey] = false;
                }

                if (!isset($metadataCollection[$metadataKey][$safeFileName])) {
                    $uploadedDate = date(DATE_TIME_FORMAT);
                    $metadataCollection[$metadataKey][$safeFileName] = [
                        'uploaded' => $uploadedDate,
                        'uploader' => $uploader,
                    ];
                    $metadataChanged[$metadataKey] = true;
                }
            }

            foreach ($metadataCollection as $folderKey => $data) {
                if (!empty($metadataChanged[$folderKey])) {
                    $metadataFileName = str_replace(['/', '\\', ' '], '-', $folderKey) . '_metadata.json';
                    $metadataFile     = self::metaRoot() . $metadataFileName;
                    file_put_contents($metadataFile, json_encode($data, JSON_PRETTY_PRINT));
                }
            }

            return ['success' => 'Files uploaded successfully'];
        } finally {
            if (!$isLocal) {
                self::cleanupCreatedDirs($createdDirs, self::uploadRoot());
            }
        }
    }

    private static function ensureDir(string $path, array &$createdDirs): bool
    {
        $path = rtrim($path, "/\\");
        if ($path === '') {
            return false;
        }
        if (is_dir($path)) {
            return true;
        }

        $stack = [];
        $cur = $path;
        while ($cur !== '' && !is_dir($cur)) {
            $stack[] = $cur;
            $parent = dirname($cur);
            if ($parent === $cur) {
                break;
            }
            $cur = $parent;
        }

        for ($i = count($stack) - 1; $i >= 0; $i--) {
            $dir = $stack[$i];
            if (!is_dir($dir)) {
                if (!@mkdir($dir, 0775)) {
                    if (!is_dir($dir)) {
                        return false;
                    }
                } else {
                    $createdDirs[] = $dir;
                }
            }
        }

        return is_dir($path);
    }

    private static function cleanupCreatedDirs(array $createdDirs, string $root): void
    {
        if (empty($createdDirs)) {
            return;
        }

        $rootNorm = rtrim(str_replace('\\', '/', $root), '/') . '/';
        $unique = array_values(array_unique($createdDirs));
        usort($unique, static function ($a, $b) {
            return strlen((string)$b) <=> strlen((string)$a);
        });

        foreach ($unique as $dir) {
            if (!is_string($dir) || $dir === '') {
                continue;
            }
            $dirNorm = rtrim(str_replace('\\', '/', $dir), '/') . '/';
            if ($dirNorm === $rootNorm) {
                continue;
            }
            if ($rootNorm !== '' && strpos($dirNorm, $rootNorm) !== 0) {
                continue;
            }
            @rmdir($dir);
        }
    }

    /**
     * Optionally scan an uploaded file with ClamAV.
     *
     * $context may include the same keys as scanSingleUploadIfEnabled().
     *
     * Returns:
     *   - null           => scanning disabled or file clean
     *   - ['error' => …] => infected or scan error (file is deleted)
     */
    private static function scanFileIfEnabled(string $path, array $context = []): ?array
    {
        // Respect env override + admin setting
        if (!self::isVirusScanEnabled()) {
            return null; // scanning disabled
        }

        if (!is_file($path)) {
            return ['error' => 'Virus scan failed: uploaded file not found.'];
        }

        if (self::isVirusScanExcluded($context)) {
            return null; // excluded path
        }

        if (!WorkerLauncher::canRunForeground()) {
            error_log('ClamAV scan skipped: PHP command execution is unavailable on this host.');
            return null;
        }

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

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

        $output   = [];
        $exitCode = 0;
        @exec($cmdline, $output, $exitCode);
        $msg = trim(implode("\n", $output));

        // 0 = clean
        if ($exitCode === 0) {
            return null;
        }

        // 1 = virus found → block + delete + log
        if ($exitCode === 1) {
            // Allow self-test endpoints to suppress log if they pass suppressLog=true
            if (empty($context['suppressLog'])) {
                self::logVirusDetection($path, $msg, $context, $cmd, $exitCode);
            }
            @unlink($path);
            return [
                'error' => 'Upload blocked: virus detected in file.',
            ];
        }

        // >1 = scanner error (missing DB, bad config, etc.)
        // Log but do NOT block the upload.
        error_log("ClamAV scan error (exit={$exitCode}, cmd={$cmd}): {$msg}");
        return null;
    }

    /**
     * Recursively removes a directory and its contents.
     *
     * @param string $dir The directory to remove.
     * @return void
     */
    private static function rrmdir(string $dir): void
    {
        if (!is_dir($dir)) {
            return;
        }
        $iterator = new RecursiveIteratorIterator(
            new RecursiveDirectoryIterator($dir, FilesystemIterator::SKIP_DOTS),
            RecursiveIteratorIterator::CHILD_FIRST
        );
        foreach ($iterator as $file) {
            if ($file->isDir()) {
                rmdir($file->getRealPath());
            } else {
                unlink($file->getRealPath());
            }
        }
        rmdir($dir);
    }

    /**
     * Removes the temporary chunk directory for resumable uploads.
     *
     * The folder name is expected to exactly match the "resumable_" pattern.
     *
     * @param string $folder The folder name provided (URL-decoded).
     * @return array Returns a status array indicating success or error.
     */
    public static function removeChunks(string $folder): array
    {
        $folder = urldecode($folder);
        if (strpos($folder, 'resumable_') === 0) {
            $folder = substr($folder, strlen('resumable_'));
        }

        $tempFolderName = self::resumableTempFolderName($folder);
        if ($tempFolderName === null) {
            return ['error' => 'Invalid resumable identifier'];
        }

        $isLocal = self::isLocalSourceType();
        $targetFolder = self::sanitizeFolder((string)($_POST['targetFolder'] ?? 'root'));
        $baseDir = self::stagingRoot($isLocal);
        if ($targetFolder !== '') {
            $baseDir = rtrim($baseDir, '/\\') . DIRECTORY_SEPARATOR
                . str_replace('/', DIRECTORY_SEPARATOR, $targetFolder) . DIRECTORY_SEPARATOR;
        }
        $tempDir = rtrim($baseDir, '/\\') . DIRECTORY_SEPARATOR . $tempFolderName;
        $hadDir = is_dir($tempDir);
        if ($hadDir) {
            if (!self::isPathWithinRoot($tempDir, $baseDir)) {
                return ['error' => 'Invalid temporary folder'];
            }
            self::rrmdir($tempDir);
        }
        if (is_dir($tempDir)) {
            return ['error' => 'Failed to remove temporary folder.'];
        }

        $hasRemaining = self::cleanupResumableTempDirs($targetFolder, $isLocal, false);
        if (!$hasRemaining) {
            $data = self::loadResumableIndex();
            $folderKey = self::resumableFolderKey($targetFolder);
            if (isset($data['folders'][$folderKey])) {
                unset($data['folders'][$folderKey]);
                self::saveResumableIndex($data);
            }
        }

        return [
            'success' => true,
            'message' => $hadDir ? 'Temporary folder removed.' : 'Temporary folder already removed.',
        ];
    }

    /**
     * Force-clean any resumable_* temp folders for a target folder.
     */
    public static function cleanupResumableForFolder(string $folder): void
    {
        $raw = trim($folder);
        $folderSan = self::sanitizeFolder($raw === '' ? 'root' : $raw);
        if ($folderSan === '' && $raw !== '' && strtolower($raw) !== 'root') {
            return;
        }

        $isLocal = self::isLocalSourceType();
        $hasRemaining = self::cleanupResumableTempDirs($folderSan, $isLocal, true);

        if ($hasRemaining) {
            return;
        }

        $data = self::loadResumableIndex();
        $folderKey = self::resumableFolderKey($folderSan);
        if (isset($data['folders'][$folderKey])) {
            unset($data['folders'][$folderKey]);
            self::saveResumableIndex($data);
        }
    }

    /**
     * Check which files already exist in the target folder.
     *
     * @param string $folder Normalized folder (e.g., "root" or "team/reports").
     * @param array $files Array of ['path' => 'sub/file.txt', 'size' => 123].
     * @return array
     */
    public static function checkExisting(string $folder, array $files): array
    {
        $storage = StorageRegistry::getAdapter();
        $folderSan = self::sanitizeFolder($folder);
        $existing = [];
        $seen = [];

        foreach ($files as $entry) {
            if (!is_array($entry)) {
                continue;
            }
            $rawPath = isset($entry['path']) ? (string)$entry['path'] : '';
            if ($rawPath === '') {
                continue;
            }
            $path = str_replace('\\', '/', trim($rawPath));
            $path = ltrim($path, '/');
            if ($path === '') {
                continue;
            }
            if (isset($seen[$path])) {
                continue;
            }
            $seen[$path] = true;

            [$subDir, $fileName] = self::parseRelativePath($path);
            if ($subDir === null || $fileName === '') {
                continue;
            }

            $storagePath = self::buildStoragePath($folderSan, $subDir, $fileName);
            $exists = false;
            $existingSize = null;

            if ($storage->isLocal()) {
                if (is_file($storagePath)) {
                    $exists = true;
                    $size = @filesize($storagePath);
                    $existingSize = ($size === false) ? null : (int)$size;
                }
            } else {
                try {
                    $stat = $storage->stat($storagePath);
                    if ($stat && ($stat['type'] ?? '') === 'file') {
                        $exists = true;
                        $size = $stat['size'] ?? null;
                        $existingSize = ($size === null) ? null : (int)$size;
                    }
                } catch (\Throwable $e) {
                    // Best-effort: treat as non-existing on stat errors.
                }
            }

            if (!$exists) {
                continue;
            }

            $reqSize = null;
            if (array_key_exists('size', $entry) && is_numeric($entry['size'])) {
                $reqSize = (int)$entry['size'];
            }
            $sameSize = null;
            if ($reqSize !== null && $existingSize !== null) {
                $sameSize = ($reqSize === $existingSize);
            }

            $existing[] = [
                'path' => ($subDir !== '' ? $subDir . '/' : '') . $fileName,
                'size' => $existingSize,
                'sameSize' => $sameSize,
            ];
        }

        return ['existing' => $existing];
    }

    /**
     * Append a virus detection record to META_DIR/virus_detections.log (JSONL).
     *
     * @param string $path       The scanned file path on disk.
     * @param string $rawMessage Raw clamscan output (stdout/stderr combined).
     * @param array  $context    Extra context: folder, file, user, ip, source, etc.
     * @param string $cmd        Command used (clamscan / custom).
     * @param int    $exitCode   ClamAV exit code.
     */
    private static function logVirusDetection(
        string $path,
        string $rawMessage,
        array $context,
        string $cmd,
        int $exitCode
    ): void {
        try {
            $baseMeta = rtrim(self::metaRoot(), '/\\') . DIRECTORY_SEPARATOR;
            if (!is_dir($baseMeta)) {
                @mkdir($baseMeta, 0775, true);
            }

            $user   = $context['user']   ?? ($_SESSION['username'] ?? 'Unknown');
            $ip     = $context['ip']     ?? self::getClientIp();
            $source = $context['source'] ?? 'normal';

            // Folder + file in log – prefer context, fallback to path
            $fileName = $context['file'] ?? basename($path);
            $folder   = $context['folder'] ?? null;

            if ($folder === null) {
                // Best-effort: derive folder relative to upload root
                $rootDir = rtrim(self::uploadRoot(), '/\\') . DIRECTORY_SEPARATOR;
                if (strpos($path, $rootDir) === 0) {
                    $rel     = substr($path, strlen($rootDir));
                    $rel     = str_replace(DIRECTORY_SEPARATOR, '/', $rel);
                    $pos     = strrpos($rel, '/');
                    $folder  = ($pos !== false) ? substr($rel, 0, $pos) : '';
                } else {
                    $folder = '';
                }
            }

            $msg = self::truncateLogMessage($rawMessage, 400);

            $record = [
                'ts'       => gmdate('c'),
                'user'     => $user,
                'ip'       => $ip,
                'folder'   => ($folder === '' ? 'root' : $folder),
                'file'     => $fileName,
                'source'   => $source,
                'engine'   => $cmd,
                'exitCode' => $exitCode,
                'message'  => $msg,
            ];

            $json = json_encode($record, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE);
            if ($json === false) {
                return;
            }

            // *** Canonical base path: matches virusLog.php ***
            $logFile  = $baseMeta . 'virus_detections.log';

            // Soft rotation
            if (file_exists($logFile) && filesize($logFile) > self::VIRUS_LOG_MAX_BYTES) {
                $ts  = date('Ymd-His');
                $rot = $baseMeta . 'virus_detections-' . $ts . '.log';
                @rename($logFile, $rot);
            }

            @file_put_contents($logFile, $json . "\n", FILE_APPEND | LOCK_EX);
        } catch (\Throwable $e) {
            // Never break uploads because logging failed.
            error_log('Failed to log virus detection: ' . $e->getMessage());
        }
    }

    /**
     * Best-effort client IP resolution for logging.
     */
    private static function getClientIp(): string
    {
        $keys = [
            'HTTP_X_FORWARDED_FOR',
            'HTTP_CLIENT_IP',
            'REMOTE_ADDR',
        ];

        foreach ($keys as $key) {
            if (!empty($_SERVER[$key])) {
                $val = trim((string)$_SERVER[$key]);
                // X-Forwarded-For may contain multiple IPs – use first
                if ($key === 'HTTP_X_FORWARDED_FOR' && strpos($val, ',') !== false) {
                    $parts = explode(',', $val);
                    $val   = trim($parts[0]);
                }
                return $val;
            }
        }

        return 'unknown';
    }

    /**
     * Truncate ClamAV output message for log safety.
     */
    private static function truncateLogMessage(string $msg, int $max): string
    {
        if (mb_strlen($msg, 'UTF-8') <= $max) {
            return $msg;
        }
        return mb_substr($msg, 0, $max, 'UTF-8') . '…';
    }
}
