<?php

declare(strict_types=1);

class MediaStorage
{
    private static ?array $_storages = null;
    private static ?int $_defaultId = null;

    public static function all(): array
    {
        if (self::$_storages === null) {
            self::$_storages = [];
            try {
                $rows = Database::fetchAll("SELECT * FROM media_storages ORDER BY sort_order, id");
                foreach ($rows as $row) {
                    $row['config'] = json_decode($row['config'] ?: '{}', true) ?: [];
                    self::$_storages[(int)$row['id']] = $row;
                }
            } catch (\Throwable) {}
        }
        return self::$_storages;
    }

    public static function get(int $id): ?array
    {
        $all = self::all();
        return $all[$id] ?? null;
    }

    public static function defaultId(): int
    {
        if (self::$_defaultId === null) {
             
            foreach (self::all() as $s) {
                if ($s['is_default']) { self::$_defaultId = (int)$s['id']; break; }
            }
            if (self::$_defaultId === null) {
                $all = self::all();
                self::$_defaultId = !empty($all) ? (int)array_key_first($all) : 0;
            }
        }
        return self::$_defaultId;
    }

    public static function defaultStorage(): ?array
    {
        return self::get(self::defaultId());
    }

    public static function activeDefaultId(): int
    {
        $default = self::defaultId();
        $storage = self::get($default);

         
        if ($storage && ($storage['status'] ?? 'active') === 'active') {
            if (!self::isOverThreshold($storage)) return $default;
        }

         
        foreach (self::all() as $s) {
            if ((int)$s['id'] === $default) continue;
            if (($s['status'] ?? 'active') !== 'active') continue;
            if (!self::isOverThreshold($s)) {
                 
                Database::query('UPDATE media_storages SET is_default = (id = ?)', [(int)$s['id']]);
                self::$_defaultId = (int)$s['id'];
                self::$_storages = null;
                return (int)$s['id'];
            }
        }

         
        return 0;
    }

    public static function isAllFull(): bool
    {
        foreach (self::all() as $s) {
            if (($s['status'] ?? 'active') !== 'active') continue;
            if (!self::isOverThreshold($s)) return false;
        }
        return true;
    }

    

    public static function liveDiskUsage(array $storage): array
    {
        $total = (int)($storage['disk_total'] ?? 0);
        $used  = (int)($storage['disk_used'] ?? 0);
        if (($storage['driver'] ?? 'local') === 'local') {
            $path = APP_ROOT . '/public/uploads/videos';
            $t = @disk_total_space($path);
            $f = @disk_free_space($path);
            if ($t !== false && $f !== false && (float)$t > 0) {
                $total = (int)$t;
                $used  = (int)($t - $f);
            }
        }
        return ['disk_total' => $total, 'disk_used' => $used];
    }

    private static function isOverThreshold(array $storage): bool
    {
        $maxPct = (int)($storage['max_usage_pct'] ?? 90);
        $disk = self::liveDiskUsage($storage);
        $total = $disk['disk_total'];
        $used = $disk['disk_used'];
        if ($total <= 0) return false;  
        $usedPct = round($used / $total * 100);
        return $usedPct >= $maxPct;
    }

    public static function checkHealth(int $storageId): array
    {
        $s = self::get($storageId);
        if (!$s) return ['status' => 'error', 'message' => 'Storage not found'];

        if ($s['driver'] === 'local') {
            return self::checkLocalHealth();
        }

        $cfg = is_array($s['config']) ? $s['config'] : (json_decode($s['config'] ?: '{}', true) ?: []);
        $result = ['status' => 'ok', 'message' => null, 'disk_total' => 0, 'disk_used' => 0];

         
        try {
            if ($s['driver'] === 'ftp') {
                $c = !empty($cfg['ssl']) ? @ftp_ssl_connect($cfg['host'] ?? '', (int)($cfg['port'] ?? 21), 10) : @ftp_connect($cfg['host'] ?? '', (int)($cfg['port'] ?? 21), 10);
                if (!$c) return ['status' => 'error', 'message' => 'Cannot connect to FTP server'];
                if (!@ftp_login($c, $cfg['user'] ?? '', $cfg['pass'] ?? '')) { ftp_close($c); return ['status' => 'error', 'message' => 'FTP login failed']; }
                ftp_close($c);
            } elseif ($s['driver'] === 's3') {
                $ok = self::put(self::createTestFile(), '.tubepress_health_check', $storageId);
                if (!$ok) return ['status' => 'error', 'message' => 'S3 write test failed: ' . (self::lastError() ?? 'no detail available')];
            } elseif ($s['driver'] === 'webdav') {
                $curl = curl_init(rtrim($cfg['url'] ?? '', '/'));
                curl_setopt_array($curl, [CURLOPT_CUSTOMREQUEST => 'PROPFIND', CURLOPT_RETURNTRANSFER => true, CURLOPT_TIMEOUT => 10, CURLOPT_HTTPHEADER => ['Depth: 0']]);
                if (($cfg['user'] ?? '') !== '') { curl_setopt($curl, CURLOPT_USERPWD, $cfg['user'] . ':' . ($cfg['pass'] ?? '')); }
                curl_exec($curl); $code = (int)curl_getinfo($curl, CURLINFO_HTTP_CODE); curl_close($curl);
                if ($code === 401) return ['status' => 'error', 'message' => 'WebDAV authentication failed'];
                if ($code === 0 || $code >= 500) return ['status' => 'error', 'message' => 'WebDAV server unreachable'];
            }
        } catch (\Throwable $e) {
            return ['status' => 'error', 'message' => $e->getMessage()];
        }

         
        $monitorUrl = $cfg['monitor_url'] ?? '';
        if ($monitorUrl !== '') {
            $monitorResult = self::checkRemoteHealth($monitorUrl, $cfg['monitor_key'] ?? '');
            if (isset($monitorResult['disk_total'])) $result['disk_total'] = $monitorResult['disk_total'];
            if (isset($monitorResult['disk_used'])) $result['disk_used'] = $monitorResult['disk_used'];
             
            if ($monitorResult['status'] === 'error') {
                $result['message'] = 'Monitor: ' . ($monitorResult['message'] ?? 'unreachable');
            } elseif ($monitorResult['status'] === 'warning') {
                $result['status'] = 'warning';
                $result['message'] = $monitorResult['message'];
            }
        }

        return $result;
    }

    private static function createTestFile(): string
    {
        $tmp = tempnam(sys_get_temp_dir(), 'tp');
        file_put_contents($tmp, 'health-check-' . time());
        return $tmp;
    }

    private static function checkLocalHealth(): array
    {
        $path = APP_ROOT . '/public/uploads/videos';
        $webUser = function_exists('posix_getpwuid') && function_exists('posix_geteuid')
            ? (posix_getpwuid(posix_geteuid())['name'] ?? 'www-data') : 'www-data';

         
         
         
        if (!is_dir($path)) {
            @mkdir($path, 0755, true);
        }
        if (!is_dir($path)) {
            return [
                'status'  => 'error',
                'message' => "Local media folder is missing and could not be auto-created: {$path}. "
                           . "Create it and give the web-server user ('{$webUser}') ownership: "
                           . "mkdir -p {$path} && chown -R {$webUser}:{$webUser} " . APP_ROOT . '/public/uploads',
                'disk_total' => 0, 'disk_used' => 0,
            ];
        }

        $total = @disk_total_space($path);
        $free  = @disk_free_space($path);
        if ($total === false || $free === false) {
            $why = !is_readable($path)
                ? "the web-server user ('{$webUser}') cannot read it — fix ownership: chown -R {$webUser}:{$webUser} {$path}"
                : "disk_total_space() returned false — check PHP open_basedir restrictions and that the filesystem under {$path} is mounted";
            return [
                'status'  => 'error',
                'message' => "Cannot read disk info for {$path}: {$why}.",
                'disk_total' => 0, 'disk_used' => 0,
            ];
        }

        $used   = $total - $free;
        $result = ['status' => 'ok', 'message' => null, 'disk_total' => (int)$total, 'disk_used' => (int)$used];

         
        if (!is_writable($path)) {
            $result['status']  = 'warning';
            $result['message'] = "Folder exists but is not writable by the web-server user ('{$webUser}'): {$path}. "
                               . "Run: chown -R {$webUser}:{$webUser} {$path} && chmod -R u+rwX {$path}";
        }
        return $result;
    }

    private static function checkRemoteHealth(string $monitorUrl, string $key): array
    {
        $url = rtrim($monitorUrl, '/') . '?action=status&key=' . urlencode($key);
        $ch = curl_init($url);
        curl_setopt_array($ch, [CURLOPT_RETURNTRANSFER => true, CURLOPT_TIMEOUT => 10, CURLOPT_CONNECTTIMEOUT => 5, CURLOPT_SSL_VERIFYPEER => true]);
        $body = curl_exec($ch);
        $code = (int)curl_getinfo($ch, CURLINFO_HTTP_CODE);
        $err = curl_error($ch);
        curl_close($ch);

        if ($code === 0) return ['status' => 'error', 'message' => 'Cannot connect: ' . ($err ?: 'timeout'), 'disk_total' => 0, 'disk_used' => 0];
        if ($code === 403) return ['status' => 'error', 'message' => 'Authentication failed (wrong key)', 'disk_total' => 0, 'disk_used' => 0];
        if ($code !== 200) return ['status' => 'error', 'message' => "HTTP {$code}", 'disk_total' => 0, 'disk_used' => 0];

        $data = json_decode($body, true);
        if (!$data || !isset($data['ok'])) return ['status' => 'error', 'message' => 'Invalid response', 'disk_total' => 0, 'disk_used' => 0];

        $total = (int)($data['disk_total'] ?? 0);
        $used = (int)($data['disk_used'] ?? 0);
        $writable = $data['write_test'] ?? false;

        $status = 'ok';
        $message = null;

        if (!$writable) { $status = 'error'; $message = 'Disk is not writable'; }
        elseif ($total > 0 && ($used / $total * 100) > 95) { $status = 'warning'; $message = 'Disk almost full (>95%)'; }

        return ['status' => $status, 'message' => $message, 'disk_total' => $total, 'disk_used' => $used];
    }

    public static function mediaUrl(?int $storageId): string
    {
        if ($storageId === null || $storageId === 0) {
            return rtrim(Setting::get('media_url', ''), '/');
        }
        $s = self::get($storageId);
        if (!$s) return '';
        $url = rtrim($s['media_url'], '/');
        // S3 stores objects UNDER the configured prefix, so the public URL has to carry it too.
        // Without this the prefix is applied on write and ignored on read, and every uploaded
        // file 404s on playback even though the upload itself succeeded.
        if (($s['driver'] ?? '') === 's3') {
            $cfg = is_array($s['config'] ?? null) ? $s['config'] : (json_decode((string) ($s['config'] ?? '{}'), true) ?: []);
            $prefix = trim((string) ($cfg['prefix'] ?? ''), '/');
            if ($prefix !== '') $url .= '/' . $prefix;
        }
        return $url;
    }

    public static function put(string $localFile, string $remotePath, ?int $storageId = null): bool
    {
        $s = self::get($storageId ?? self::defaultId());
        if (!$s || $s['driver'] === 'local') return true;

        try {
            $cfg = $s['config'];
            if ($s['driver'] === 'ftp') return self::ftpPut($cfg, $localFile, $remotePath);
            if ($s['driver'] === 's3') return self::s3Put($cfg, $localFile, $remotePath);
            if ($s['driver'] === 'webdav') return self::davPut($cfg, $localFile, $remotePath);
        } catch (\Throwable $e) {
            self::$lastError = $e->getMessage();
            error_log("MediaStorage put error (storage {$s['name']}): " . $e->getMessage());
        }
        return false;
    }

    public static function putData(string $data, string $remotePath, ?int $storageId = null): bool
    {
        $tmp = tempnam(sys_get_temp_dir(), 'ms');
        file_put_contents($tmp, $data);
        $ok = self::put($tmp, $remotePath, $storageId);
        @unlink($tmp);
        return $ok;
    }

    public static function isRemote(?int $storageId = null): bool
    {
        $s = self::get($storageId ?? self::defaultId());
        return $s && $s['driver'] !== 'local';
    }

    

    public static function exists(string $remotePath, ?int $storageId = null): bool
    {
        $s = self::get($storageId ?? self::defaultId());
        if (!$s) return false;
        if ($s['driver'] === 'local') return true;
        try {
            $cfg = $s['config'];
            if ($s['driver'] === 'ftp') {
                $c = self::ftpConn($cfg);
                $size = @ftp_size($c, ltrim($remotePath, '/'));
                ftp_close($c);
                return $size >= 0;
            }
            if ($s['driver'] === 'webdav') {
                $baseUrl = rtrim($cfg['url'] ?? '', '/');
                $ch = curl_init($baseUrl . '/' . ltrim($remotePath, '/'));
                curl_setopt_array($ch, [CURLOPT_NOBODY => true, CURLOPT_RETURNTRANSFER => true, CURLOPT_CONNECTTIMEOUT => 15, CURLOPT_TIMEOUT => 30, CURLOPT_FOLLOWLOCATION => true]);
                if (($cfg['user'] ?? '') !== '') { curl_setopt($ch, CURLOPT_USERPWD, $cfg['user'] . ':' . ($cfg['pass'] ?? '')); curl_setopt($ch, CURLOPT_HTTPAUTH, CURLAUTH_BASIC); }
                curl_exec($ch);
                $code = (int) curl_getinfo($ch, CURLINFO_HTTP_CODE);
                curl_close($ch);
                return $code >= 200 && $code < 300;
            }
            if ($s['driver'] === 's3') {
                return self::s3Exists($cfg, $remotePath);
            }
        } catch (\Throwable $e) {
            error_log("MediaStorage exists error (storage {$s['name']}): " . $e->getMessage());
        }
        return false;
    }

    public static function deleteFile(string $remotePath, ?int $storageId = null): bool
    {
        $s = self::get($storageId ?? self::defaultId());
        if (!$s || $s['driver'] === 'local') return true;

        try {
            $cfg = $s['config'];
            if ($s['driver'] === 'ftp') return self::ftpDelete($cfg, $remotePath);
            if ($s['driver'] === 's3') return self::s3Delete($cfg, $remotePath);
            if ($s['driver'] === 'webdav') return self::davDelete($cfg, $remotePath);
        } catch (\Throwable $e) {
            error_log("MediaStorage delete error (storage {$s['name']}): " . $e->getMessage());
        }
        return false;
    }

    public static function deleteRemoteFolder(string $folder, ?int $storageId = null): bool
    {
        $s = self::get($storageId ?? self::defaultId());
        if (!$s || $s['driver'] === 'local') return true;

        $basePath = 'uploads/videos/' . $folder . '/';

        try {
            $cfg = $s['config'];
            if ($s['driver'] === 'ftp') {
                $c = self::ftpConn($cfg);
                $files = @ftp_nlist($c, $basePath);
                if ($files) {
                    foreach ($files as $f) @ftp_delete($c, $f);
                }
                @ftp_rmdir($c, $basePath);
                ftp_close($c);
                return true;
            }
            if ($s['driver'] === 's3') {
                foreach (['video.mp4','video.webm','video.ogg','thumb.jpg','thumb_small.jpg','preview.mp4','timeline.jpg','timeline.vtt'] as $f) {
                    self::s3Delete($cfg, $basePath . $f);
                }
                foreach (['2160', '1440', '1080', '720', '480', '360'] as $h) {
                    self::s3Delete($cfg, $basePath . "video_{$h}p.mp4");
                }
                return true;
            }
            if ($s['driver'] === 'webdav') {
                $baseUrl = rtrim($cfg['url'] ?? '', '/');
                $dirUrl = $baseUrl . '/' . rtrim($basePath, '/');
                 
                 
                 
                 
                $members = self::davList($dirUrl, $cfg);
                if (!empty($members)) {
                    foreach ($members as $m) self::davReq('DELETE', $m, $cfg);
                } else {
                    foreach (['video.mp4','video.webm','video.ogg','thumb.jpg','thumb_small.jpg','preview.mp4','timeline.jpg','timeline.vtt'] as $f) {
                        self::davReq('DELETE', $baseUrl . '/' . $basePath . $f, $cfg);
                    }
                    foreach (['2160','1440','1080','720','480','360'] as $h) {
                        self::davReq('DELETE', $baseUrl . '/' . $basePath . "video_{$h}p.mp4", $cfg);
                    }
                }
                self::davReq('DELETE', $dirUrl, $cfg);  
                return true;
            }
        } catch (\Throwable $e) {
            error_log("MediaStorage deleteFolder error (storage {$s['name']}): " . $e->getMessage());
        }
        return false;
    }

    /**
     * Delete many remote objects in as few requests as possible.
     *
     * S3 takes 1000 keys per DeleteObjects call, so a whole batch of videos or a whole gallery goes
     * in one request instead of one request per file - which is what made bulk deletion crawl.
     */
    public static function deleteRemoteKeys(array $remotePaths, ?int $storageId = null): bool
    {
        $s = self::get($storageId ?? self::defaultId());
        if (!$s || $s['driver'] === 'local') return true;

        $paths = array_values(array_unique(array_filter(
            $remotePaths,
            static fn($p) => is_string($p) && $p !== ''
        )));
        if (!$paths) return true;

        try {
            $cfg = $s['config'];
            if ($s['driver'] === 's3') {
                $keys = [];
                foreach ($paths as $p) $keys[] = self::s3Key($cfg, $p);
                return self::s3DeleteObjects($cfg, $keys);
            }
            // FTP and WebDAV have no batch verb; delete one by one but keep going on failure.
            $ok = true;
            foreach ($paths as $p) {
                if (!self::deleteFile($p, $storageId)) $ok = false;
            }
            return $ok;
        } catch (\Throwable $e) {
            self::$lastError = $e->getMessage();
            error_log("MediaStorage deleteRemoteKeys error (storage {$s['name']}): " . $e->getMessage());
            return false;
        }
    }

    /** Every asset a video folder can hold, renditions included. */
    private const VIDEO_FOLDER_FILES = [
        'video.mp4', 'video.webm', 'video.ogg', 'thumb.jpg', 'thumb_small.jpg',
        'preview.mp4', 'timeline.jpg', 'timeline.vtt',
        'video_2160p.mp4', 'video_1440p.mp4', 'video_1080p.mp4',
        'video_720p.mp4', 'video_480p.mp4', 'video_360p.mp4',
    ];

    /**
     * Delete many video folders at once.
     *
     * Deleting them one by one issued 14 sequential HTTP round trips per video - 700 for a batch of
     * 50, which is what made bulk deletion crawl on remote storage. S3 accepts up to 1000 keys per
     * DeleteObjects call, so the same batch becomes a single request.
     */
    public static function deleteRemoteFolders(array $folders, ?int $storageId = null): bool
    {
        $folders = array_values(array_filter(
            $folders,
            static fn($f) => is_string($f) && preg_match('/^[a-f0-9]+$/i', $f)
        ));
        if (!$folders) return true;

        $paths = [];
        foreach ($folders as $folder) {
            foreach (self::VIDEO_FOLDER_FILES as $file) {
                $paths[] = 'uploads/videos/' . $folder . '/' . $file;
            }
        }
        return self::deleteRemoteKeys($paths, $storageId);
    }
    /**
     * S3 DeleteObjects: up to 1000 keys per request. Missing keys are not an error, which is what we
     * want - a video rarely has every rendition on disk.
     */
    private static function s3DeleteObjects(array $cfg, array $keys): bool
    {
        $keys = array_values(array_unique($keys));
        if (!$keys) return true;

        $ep   = self::s3Endpoint($cfg);
        $host = self::s3Host($ep);
        // DeleteObjects addresses the bucket, not an object.
        $path = (($cfg['endpoint'] ?? '') !== '') ? '/' . $cfg['bucket'] : '/';
        $ok = true;

        foreach (array_chunk($keys, 1000) as $slice) {
            $xml = '<?xml version="1.0" encoding="UTF-8"?><Delete><Quiet>true</Quiet>';
            foreach ($slice as $k) {
                $xml .= '<Object><Key>' . htmlspecialchars($k, ENT_XML1 | ENT_QUOTES, 'UTF-8') . '</Key></Object>';
            }
            $xml .= '</Delete>';

            $q = self::s3CanonicalQuery(['delete' => '']);
            // S3 and Ceph both require Content-MD5 on DeleteObjects.
            $hdrs = self::s3AuthHeaders($cfg, 'POST', $host, $path, $q, [
                'content-type' => 'application/xml',
                'content-md5'  => base64_encode(md5($xml, true)),
            ], hash('sha256', $xml));

            $curl = curl_init($ep . $path . '?' . $q);
            curl_setopt_array($curl, [
                CURLOPT_POST => true,
                CURLOPT_POSTFIELDS => $xml,
                CURLOPT_HTTPHEADER => $hdrs,
                CURLOPT_RETURNTRANSFER => true,
                CURLOPT_CONNECTTIMEOUT => 15,
                CURLOPT_TIMEOUT => 300,
            ]);
            $resp = (string) curl_exec($curl);
            $code = (int) curl_getinfo($curl, CURLINFO_HTTP_CODE);
            $err  = curl_error($curl);
            curl_close($curl);

            if ($code < 200 || $code >= 300) {
                $ok = false;
                self::$lastError = self::s3ErrorDetail($code, $err, $resp);
                error_log('MediaStorage s3DeleteObjects failed on ' . $host . ': ' . self::$lastError);
            } elseif (stripos($resp, '<Error>') !== false) {
                // Quiet mode only reports failures; a per-key error still leaves the rest deleted.
                error_log('MediaStorage s3DeleteObjects partial failure on ' . $host . ': ' . substr($resp, 0, 300));
            }
        }
        return $ok;
    }

    public static function resetCache(): void
    {
        self::$_storages = null;
        self::$_defaultId = null;
    }

     

    private static function ftpConn(array $cfg): mixed
    {
        if (!function_exists('ftp_connect')) {
            throw new \RuntimeException('The PHP "ftp" extension is required to use an FTP storage backend. Install ext-ftp and reload PHP.');
        }
        $host = $cfg['host'] ?? '';
        $port = (int)($cfg['port'] ?? 21);
        $user = $cfg['user'] ?? '';
        $pass = $cfg['pass'] ?? '';
        $ssl = !empty($cfg['ssl']);

        $c = $ssl ? @ftp_ssl_connect($host, $port, 15) : @ftp_connect($host, $port, 15);
        if (!$c) throw new \RuntimeException("FTP connect failed: {$host}:{$port}");
        if (!@ftp_login($c, $user, $pass)) { ftp_close($c); throw new \RuntimeException('FTP login failed'); }
        if ($cfg['passive'] ?? true) ftp_pasv($c, true);

        $path = $cfg['path'] ?? '/';
        if ($path !== '' && $path !== '/') @ftp_chdir($c, $path);

        return $c;
    }

    private static function ftpPut(array $cfg, string $local, string $remote): bool
    {
        $c = self::ftpConn($cfg);
        $parts = explode('/', trim(dirname($remote), '/'));
        $cur = '';
        foreach ($parts as $p) { if ($p === '') continue; $cur .= '/' . $p; @ftp_mkdir($c, $cur); }
        $ok = @ftp_put($c, $remote, $local, FTP_BINARY);
        ftp_close($c);
        return $ok;
    }

    private static function ftpDelete(array $cfg, string $remote): bool
    {
        $c = self::ftpConn($cfg);
        $ok = @ftp_delete($c, $remote);
        ftp_close($c);
        return $ok;
    }

     

    /** Abort an upload only after this many seconds with the storage server accepting NOTHING. */
    private const S3_STALL_SECONDS = 300;

    private static ?string $lastError = null;

    /**
     * Whether the last failure is worth retrying. Recorded at the point of failure rather than
     * re-derived from the message: parsing our own human-readable text is brittle, and enriching
     * that text with transfer statistics silently disabled the retry until a test caught it.
     */
    private static bool $lastErrorTransient = false;

    /** Detail of the last remote-storage failure (HTTP status + provider error), for the admin UI and the import log. */
    public static function lastError(): ?string
    {
        return self::$lastError;
    }

    /**
     * Percent-encode an S3 object path for BOTH the request URL and the SigV4 canonical request.
     * They must be byte-identical or the signature is rejected, and curl refuses a URL that still
     * contains a raw space. Segments are encoded individually so the "/" separators survive.
     */
    public static function s3EncodePath(string $path): string
    {
        $segs = explode('/', ltrim($path, '/'));
        return '/' . implode('/', array_map('rawurlencode', $segs));
    }

    /**
     * Run the REAL upload/delete path against an ad-hoc config (not a saved storage row).
     * The admin storage test uses these so it can never diverge from what an import actually does:
     * same signed headers (content-type included) and same streamed transfer.
     */
    public static function s3PutWithConfig(array $cfg, string $local, string $remote): bool
    {
        return self::s3Put($cfg, $local, $remote);
    }

    public static function s3DeleteWithConfig(array $cfg, string $remote): bool
    {
        return self::s3Delete($cfg, $remote);
    }

    private static function humanBytes(float $n): string
    {
        if ($n >= 1073741824) return round($n / 1073741824, 2) . ' GB';
        if ($n >= 1048576)    return round($n / 1048576, 2) . ' MB';
        if ($n >= 1024)       return round($n / 1024, 1) . ' kB';
        return ((int) $n) . ' B';
    }

    /**
     * What the transfer actually did before it failed.
     *
     * This is the line that decides a diagnosis: "sent 8 MB of 8 MB" means the body left us
     * completely and the server died while storing it, while "sent 3.2 MB of 8 MB" means the
     * connection was cut mid-flight. Without it, a dropped connection is unattributable and the
     * customer has to be asked to go digging - which is how this went round in circles.
     */
    private static function s3TransferStats($curl, ?int $expected = null, ?float $bodyDoneAfter = null): string
    {
        $sent  = (float) curl_getinfo($curl, CURLINFO_SIZE_UPLOAD);
        $time  = (float) curl_getinfo($curl, CURLINFO_TOTAL_TIME);
        $conn  = (float) curl_getinfo($curl, CURLINFO_CONNECT_TIME);

        $of = ($expected !== null && $expected > 0) ? ' of ' . self::humanBytes((float) $expected) : '';

        // The averaged speed curl reports spans the whole request, so a body that went out quickly
        // and then sat waiting for a reply looks identical to one that trickled. Splitting the two
        // is what tells a slow link apart from a server that is slow to answer.
        $split = '';
        if ($bodyDoneAfter !== null) {
            $wait = max(0.0, $time - $bodyDoneAfter);
            $rate = $bodyDoneAfter > 0.01 ? self::humanBytes($sent / $bodyDoneAfter) . '/s' : 'instant';
            $split = sprintf(', body out in %.1fs at %s, then waited %.1fs for a reply', $bodyDoneAfter, $rate, $wait);
        }

        return sprintf(
            'sent %s%s in %.1fs total%s, connect %.2fs',
            self::humanBytes($sent), $of, $time, $split, $conn
        );
    }

    /** Full, self-contained failure line. Must be called BEFORE curl_close(). */
    private static function s3FailureDetail($curl, int $code, string $resp, ?int $expected = null, ?float $bodyDoneAfter = null): string
    {
        $err = (string) curl_error($curl);
        $detail = self::s3ErrorDetail($code, $err, $resp);
        if ($err !== '') $detail .= ' (curl ' . curl_errno($curl) . ')';
        return $detail . ' [' . self::s3TransferStats($curl, $expected, $bodyDoneAfter) . ']';
    }

    /** Turn a failed S3 response into a short, human-readable reason. */
    public static function s3ErrorDetail(int $code, string $curlErr, string $resp): string
    {
        if ($curlErr !== '') return "connection error: {$curlErr}";
        $body = trim($resp);
        $c = '';
        $m = '';
        if ($body !== '' && preg_match('~<Code>([^<]{1,64})</Code>~i', $body, $mm)) $c = $mm[1];
        if ($body !== '' && preg_match('~<Message>([^<]{1,200})</Message>~i', $body, $mm)) $m = $mm[1];
        if ($c !== '') return "HTTP {$code} - {$c}" . ($m !== '' ? ": {$m}" : '');
        $snippet = substr((string) preg_replace('/\s+/', ' ', $body), 0, 160);
        return "HTTP {$code}" . ($snippet !== '' ? " - {$snippet}" : '');
    }

    public static function s3Host(string $ep): string
    {
        $host = (string) parse_url($ep, PHP_URL_HOST);
        $port = parse_url($ep, PHP_URL_PORT);
        $scheme = strtolower((string) parse_url($ep, PHP_URL_SCHEME));
        if ($port !== null && !($scheme === 'http' && $port === 80) && !($scheme === 'https' && $port === 443)) {
            $host .= ':' . $port;
        }
        return $host;
    }

    /** True when the last failure is worth retrying: dropped connection, stall, or a 5xx. */
    private static function s3LastErrorWasTransient(): bool
    {
        return self::$lastErrorTransient;
    }

    private static function s3Put(array $cfg, string $local, string $remote): bool
    {
        // Past the single-PUT ceiling multipart is the only option (5 GB on S3 and on Ceph RGW by
        // default), and it is markedly more robust for anything merely large: per-part acks instead
        // of one enormous request, and a retry that replays one part rather than the whole file.
        $size = @filesize($local);
        if ($size !== false && $size > self::S3_MULTIPART_THRESHOLD) {
            return self::s3PutMultipart($cfg, $local, $remote, (int) $size);
        }

        // Multipart parts already retry; a single PUT did not, so one dropped connection lost the
        // file outright (ticket 77: preview.mp4 and video_360p.mp4 failed on a single attempt).
        for ($attempt = 1; $attempt <= self::S3_PART_ATTEMPTS; $attempt++) {
            if (self::s3PutOnce($cfg, $local, $remote)) return true;
            if (!self::s3LastErrorWasTransient()) return false;
            if ($attempt < self::S3_PART_ATTEMPTS) sleep($attempt * 2);
        }
        return false;
    }

    private static function s3PutOnce(array $cfg, string $local, string $remote): bool
    {
        $key = ($cfg['prefix'] ?? '') !== '' ? rtrim($cfg['prefix'], '/') . '/' . ltrim($remote, '/') : ltrim($remote, '/');
        $ep = ($cfg['endpoint'] ?? '') !== '' ? rtrim($cfg['endpoint'], '/') : "https://{$cfg['bucket']}.s3.{$cfg['region']}.amazonaws.com";
        $path = '/' . ltrim($key, '/');
        if (($cfg['endpoint'] ?? '') !== '') $path = '/' . $cfg['bucket'] . $path;
        $path = self::s3EncodePath($path);
        $host = self::s3Host($ep);
        $date = gmdate('Ymd\THis\Z');
        $ds = gmdate('Ymd');

        $contentType = self::s3ContentType($remote);

        $hdrs = ['content-type' => $contentType, 'host' => $host, 'x-amz-content-sha256' => 'UNSIGNED-PAYLOAD', 'x-amz-date' => $date];
        ksort($hdrs);
        $sh = implode(';', array_keys($hdrs));
        $ch = ''; foreach ($hdrs as $k => $v) $ch .= "{$k}:{$v}\n";
        $cr = "PUT\n{$path}\n\n{$ch}\n{$sh}\nUNSIGNED-PAYLOAD";
        $scope = "{$ds}/{$cfg['region']}/s3/aws4_request";
        $sts = "AWS4-HMAC-SHA256\n{$date}\n{$scope}\n" . hash('sha256', $cr);
        $sk = hash_hmac('sha256', $ds, 'AWS4' . $cfg['secret'], true);
        $sk = hash_hmac('sha256', $cfg['region'], $sk, true);
        $sk = hash_hmac('sha256', 's3', $sk, true);
        $sk = hash_hmac('sha256', 'aws4_request', $sk, true);
        $sig = hash_hmac('sha256', $sts, $sk);
        $auth = "AWS4-HMAC-SHA256 Credential={$cfg['key']}/{$scope}, SignedHeaders={$sh}, Signature={$sig}";

        $fh = @fopen($local, 'rb');
        if ($fh === false) {
            self::$lastError = 'local file is missing or unreadable: ' . $local;
            error_log("MediaStorage s3Put: cannot open local file {$local}");
            return false;
        }
        $curl = curl_init($ep . $path);
        // Stall detection that does not punish the storage server's think-time. A gateway that buffers
        // the request body (nginx proxy_request_buffering, on by default) goes completely silent between
        // our last byte and its reply, for as long as the backend needs to commit a large object. curl's
        // LOW_SPEED guard counted that silence as a stall and killed uploads that were perfectly healthy
        // ("Operation too slow ... the last 120 seconds"). Enforce a stall window only while bytes are
        // still outstanding; once the body is fully sent, CURLOPT_TIMEOUT alone bounds the wait.
        $progress = ['at' => time(), 'sent' => 0.0, 'bodyDone' => null];
        $startedAt = microtime(true);
        $stalled = false;
        curl_setopt_array($curl, [
            CURLOPT_CUSTOMREQUEST => 'PUT',
            CURLOPT_HTTPHEADER => ["Authorization: {$auth}", "Content-Type: {$contentType}", "x-amz-content-sha256: UNSIGNED-PAYLOAD", "x-amz-date: {$date}"],
            CURLOPT_RETURNTRANSFER => true,
            CURLOPT_CONNECTTIMEOUT => 15,
            CURLOPT_TIMEOUT => 3600,
            CURLOPT_NOPROGRESS => false,
            CURLOPT_PROGRESSFUNCTION => static function ($ch, $dlTotal, $dlNow, $ulTotal, $ulNow) use (&$progress, &$stalled) {
                if ($ulNow > $progress['sent']) { $progress['at'] = time(); $progress['sent'] = $ulNow; }
                if ($ulTotal > 0 && $ulNow >= $ulTotal) {
                    if ($progress['bodyDone'] === null) $progress['bodyDone'] = microtime(true);
                    return 0;
                }
                if ((time() - $progress['at']) >= self::S3_STALL_SECONDS) { $stalled = true; return 1; }
                return 0;
            },
            CURLOPT_UPLOAD => true,
            CURLOPT_INFILE => $fh,
            CURLOPT_INFILESIZE => filesize($local),
        ]);
        $resp = curl_exec($curl);
        $code = (int)curl_getinfo($curl, CURLINFO_HTTP_CODE);
        $expected = (int) filesize($local);
        $curlErrNo = curl_errno($curl);
        $failure = self::s3FailureDetail($curl, $code, (string) $resp, $expected, $progress['bodyDone'] !== null ? $progress['bodyDone'] - $startedAt : null);
        curl_close($curl);
        fclose($fh);
        if ($code < 200 || $code >= 300) {
            $detail = $stalled
                ? 'upload stalled: the storage server accepted no data for ' . self::S3_STALL_SECONDS . 's'
                : $failure;
            $detail = 'single PUT of ' . self::humanBytes((float) $expected) . ' to ' . $host . ' failed: ' . $detail;
            self::$lastError = $detail;
            // A dropped connection, a stall or a 5xx may well succeed on the next attempt; a 4xx will not.
            self::$lastErrorTransient = $stalled || $curlErrNo !== 0 || ($code >= 500 && $code < 600);
            error_log("MediaStorage s3Put failed for {$key}: {$detail}");
        } else {
            self::$lastError = null;
            self::$lastErrorTransient = false;
        }
        return $code >= 200 && $code < 300;
    }

    /** Above this size a single PUT is replaced by a multipart upload. */
    private const S3_MULTIPART_THRESHOLD = 16 * 1024 * 1024;
    /**
     * Default part size, raised automatically so a huge object still fits in S3_MAX_PARTS.
     *
     * 8 MB is what the AWS CLI and the official SDKs use. We shipped 128 MB, which keeps a single
     * connection busy for minutes on an ordinary link and gives every intermediary - proxy, NAT,
     * gateway - a long window in which to drop it; ticket 77 saw exactly that, with the customer's
     * own AWS CLI succeeding against the same endpoint from the same host while we failed.
     */
    private const S3_MULTIPART_PART_SIZE = 8 * 1024 * 1024;
    /** Hard S3 protocol limit. */
    private const S3_MAX_PARTS = 10000;
    /** Per-part retry attempts before the whole upload is abandoned. */
    private const S3_PART_ATTEMPTS = 3;

    private static function s3ContentType(string $remote): string
    {
        $ext = strtolower(pathinfo($remote, PATHINFO_EXTENSION));
        $map = ['mp4' => 'video/mp4', 'webm' => 'video/webm', 'ogg' => 'video/ogg', 'jpg' => 'image/jpeg',
                'jpeg' => 'image/jpeg', 'png' => 'image/png', 'gif' => 'image/gif', 'webp' => 'image/webp',
                'vtt' => 'text/vtt'];
        return $map[$ext] ?? 'application/octet-stream';
    }

    /** Object key for a remote path, honouring the configured prefix. */
    private static function s3Key(array $cfg, string $remote): string
    {
        return ($cfg['prefix'] ?? '') !== ''
            ? rtrim($cfg['prefix'], '/') . '/' . ltrim($remote, '/')
            : ltrim($remote, '/');
    }

    /** Endpoint base, falling back to the AWS virtual-hosted form when none is configured. */
    private static function s3Endpoint(array $cfg): string
    {
        return ($cfg['endpoint'] ?? '') !== ''
            ? rtrim($cfg['endpoint'], '/')
            : "https://{$cfg['bucket']}.s3.{$cfg['region']}.amazonaws.com";
    }

    /** Request path for a key: a custom endpoint means path-style, so the bucket goes in the path. */
    private static function s3Path(array $cfg, string $key): string
    {
        $path = '/' . ltrim($key, '/');
        if (($cfg['endpoint'] ?? '') !== '') $path = '/' . $cfg['bucket'] . $path;
        return self::s3EncodePath($path);
    }

    /** Canonical query string: sorted by key, each component percent-encoded. */
    private static function s3CanonicalQuery(array $params): string
    {
        ksort($params);
        $out = [];
        foreach ($params as $k => $v) $out[] = rawurlencode((string) $k) . '=' . rawurlencode((string) $v);
        return implode('&', $out);
    }

    /**
     * Build the SigV4 headers for one request. Returns curl-ready header lines; Host is signed but
     * never emitted, because curl derives it from the URL and a duplicate would break the signature.
     */
    private static function s3AuthHeaders(array $cfg, string $method, string $host, string $encodedPath, string $canonQuery, array $hdrs, string $payloadHash): array
    {
        $date = gmdate('Ymd\THis\Z');
        $ds   = gmdate('Ymd');
        $hdrs = array_change_key_case($hdrs, CASE_LOWER);
        $hdrs['host'] = $host;
        $hdrs['x-amz-content-sha256'] = $payloadHash;
        $hdrs['x-amz-date'] = $date;
        ksort($hdrs);

        $sh = implode(';', array_keys($hdrs));
        $ch = '';
        foreach ($hdrs as $k => $v) $ch .= "{$k}:{$v}\n";
        $cr = "{$method}\n{$encodedPath}\n{$canonQuery}\n{$ch}\n{$sh}\n{$payloadHash}";

        $scope = "{$ds}/{$cfg['region']}/s3/aws4_request";
        $sts = "AWS4-HMAC-SHA256\n{$date}\n{$scope}\n" . hash('sha256', $cr);
        $sk = hash_hmac('sha256', $ds, 'AWS4' . $cfg['secret'], true);
        $sk = hash_hmac('sha256', $cfg['region'], $sk, true);
        $sk = hash_hmac('sha256', 's3', $sk, true);
        $sk = hash_hmac('sha256', 'aws4_request', $sk, true);
        $sig = hash_hmac('sha256', $sts, $sk);

        $out = ["Authorization: AWS4-HMAC-SHA256 Credential={$cfg['key']}/{$scope}, SignedHeaders={$sh}, Signature={$sig}"];
        foreach ($hdrs as $k => $v) {
            if ($k !== 'host') $out[] = "{$k}: {$v}";
        }
        return $out;
    }

    /**
     * Multipart upload: the only way past the 5 GB single-PUT ceiling (S3 and Ceph RGW alike), and the
     * only shape that survives a very large object - each part is acknowledged in seconds instead of
     * one multi-hour request, a failed part is retried on its own rather than restarting from zero,
     * and the object can reach 5 TB.
     */
    private static function s3PutMultipart(array $cfg, string $local, string $remote, int $size): bool
    {
        $key         = self::s3Key($cfg, $remote);
        $ep          = self::s3Endpoint($cfg);
        $path        = self::s3Path($cfg, $key);
        $host        = self::s3Host($ep);
        $contentType = self::s3ContentType($remote);

        $partSize = max(self::S3_MULTIPART_PART_SIZE, (int) ceil($size / self::S3_MAX_PARTS));
        $partCount = (int) ceil($size / $partSize);
        if ($partCount > self::S3_MAX_PARTS) {
            self::$lastError = "file too large: {$size} bytes needs more than " . self::S3_MAX_PARTS . ' parts';
            return false;
        }

        // 1) initiate
        $q = self::s3CanonicalQuery(['uploads' => '']);
        $hdrs = self::s3AuthHeaders($cfg, 'POST', $host, $path, $q, ['content-type' => $contentType], hash('sha256', ''));
        $curl = curl_init($ep . $path . '?' . $q);
        curl_setopt_array($curl, [CURLOPT_POST => true, CURLOPT_POSTFIELDS => '', CURLOPT_HTTPHEADER => $hdrs,
                                  CURLOPT_RETURNTRANSFER => true, CURLOPT_CONNECTTIMEOUT => 15, CURLOPT_TIMEOUT => 120]);
        $resp = (string) curl_exec($curl);
        $code = (int) curl_getinfo($curl, CURLINFO_HTTP_CODE);
        $err  = curl_error($curl);
        curl_close($curl);
        if ($code < 200 || $code >= 300 || !preg_match('~<UploadId>([^<]+)</UploadId>~', $resp, $m)) {
            self::$lastError = 'multipart initiate failed: ' . self::s3ErrorDetail($code, $err, $resp);
            error_log("MediaStorage s3PutMultipart initiate failed for {$key} on {$host}: " . self::$lastError);
            return false;
        }
        $uploadId = $m[1];

        // 2) parts
        $fh = @fopen($local, 'rb');
        if ($fh === false) {
            self::$lastError = 'local file is missing or unreadable: ' . $local;
            self::s3AbortMultipart($cfg, $path, $ep, $host, $uploadId);
            return false;
        }

        $parts = [];
        for ($n = 1; $n <= $partCount; $n++) {
            $offset = ($n - 1) * $partSize;
            $len    = (int) min($partSize, $size - $offset);
            $etag   = null;

            for ($attempt = 1; $attempt <= self::S3_PART_ATTEMPTS && $etag === null; $attempt++) {
                $etag = self::s3UploadPart($cfg, $ep, $host, $path, $uploadId, $n, $fh, $offset, $len);
                if ($etag === null && $attempt < self::S3_PART_ATTEMPTS) sleep($attempt * 2);
            }
            if ($etag === null) {
                fclose($fh);
                error_log("MediaStorage s3PutMultipart part {$n}/{$partCount} failed for {$key} on {$host}: " . (self::$lastError ?? '?'));
                self::s3AbortMultipart($cfg, $path, $ep, $host, $uploadId);
                return false;
            }
            $parts[] = ['n' => $n, 'etag' => $etag];
        }
        fclose($fh);

        // 3) complete
        $xml = '<CompleteMultipartUpload>';
        foreach ($parts as $p) $xml .= "<Part><PartNumber>{$p['n']}</PartNumber><ETag>{$p['etag']}</ETag></Part>";
        $xml .= '</CompleteMultipartUpload>';

        $q = self::s3CanonicalQuery(['uploadId' => $uploadId]);
        $hdrs = self::s3AuthHeaders($cfg, 'POST', $host, $path, $q, ['content-type' => 'application/xml'], hash('sha256', $xml));
        $curl = curl_init($ep . $path . '?' . $q);
        curl_setopt_array($curl, [CURLOPT_POST => true, CURLOPT_POSTFIELDS => $xml, CURLOPT_HTTPHEADER => $hdrs,
                                  CURLOPT_RETURNTRANSFER => true, CURLOPT_CONNECTTIMEOUT => 15, CURLOPT_TIMEOUT => 3600]);
        $resp = (string) curl_exec($curl);
        $code = (int) curl_getinfo($curl, CURLINFO_HTTP_CODE);
        $err  = curl_error($curl);
        curl_close($curl);

        // S3 can answer 200 and then report a failure inside the body - check the payload, not just the status.
        if ($code < 200 || $code >= 300 || stripos($resp, '<Error') !== false) {
            self::$lastError = 'multipart complete failed: ' . self::s3ErrorDetail($code, $err, $resp);
            error_log("MediaStorage s3PutMultipart complete failed for {$key} on {$host}: " . self::$lastError);
            self::s3AbortMultipart($cfg, $path, $ep, $host, $uploadId);
            return false;
        }

        self::$lastError = null;
        return true;
    }

    /** Upload one part, streaming exactly $len bytes from $offset. Returns the ETag, or null on failure. */
    private static function s3UploadPart(array $cfg, string $ep, string $host, string $path, string $uploadId, int $n, $fh, int $offset, int $len): ?string
    {
        if (fseek($fh, $offset) !== 0) {
            self::$lastError = "cannot seek to offset {$offset} in the local file";
            return null;
        }

        $q = self::s3CanonicalQuery(['partNumber' => (string) $n, 'uploadId' => $uploadId]);
        $hdrs = self::s3AuthHeaders($cfg, 'PUT', $host, $path, $q, [], 'UNSIGNED-PAYLOAD');

        $remaining = $len;
        $etag = null;
        $progress = ['at' => time(), 'sent' => 0.0, 'bodyDone' => null];
        $startedAt = microtime(true);
        $stalled = false;

        $curl = curl_init($ep . $path . '?' . $q);
        curl_setopt_array($curl, [
            CURLOPT_UPLOAD => true,
            CURLOPT_CUSTOMREQUEST => 'PUT',
            CURLOPT_HTTPHEADER => $hdrs,
            CURLOPT_INFILESIZE => $len,
            CURLOPT_RETURNTRANSFER => true,
            CURLOPT_CONNECTTIMEOUT => 15,
            CURLOPT_TIMEOUT => 3600,
            CURLOPT_NOPROGRESS => false,
            // Read exactly this part's slice; without the budget curl would keep reading to EOF.
            CURLOPT_READFUNCTION => static function ($ch, $fd, $length) use ($fh, &$remaining) {
                if ($remaining <= 0) return '';
                $chunk = fread($fh, (int) min($length, $remaining));
                if ($chunk === false) return '';
                $remaining -= strlen($chunk);
                return $chunk;
            },
            CURLOPT_HEADERFUNCTION => static function ($ch, $line) use (&$etag) {
                if (stripos($line, 'etag:') === 0) $etag = trim(substr($line, 5));
                return strlen($line);
            },
            CURLOPT_PROGRESSFUNCTION => static function ($ch, $dlTotal, $dlNow, $ulTotal, $ulNow) use (&$progress, &$stalled) {
                if ($ulNow > $progress['sent']) { $progress['at'] = time(); $progress['sent'] = $ulNow; }
                if ($ulTotal > 0 && $ulNow >= $ulTotal) {
                    if ($progress['bodyDone'] === null) $progress['bodyDone'] = microtime(true);
                    return 0;
                }
                if ((time() - $progress['at']) >= self::S3_STALL_SECONDS) { $stalled = true; return 1; }
                return 0;
            },
        ]);
        $resp = (string) curl_exec($curl);
        $code = (int) curl_getinfo($curl, CURLINFO_HTTP_CODE);
        $failure = self::s3FailureDetail($curl, $code, $resp, $len, $progress['bodyDone'] !== null ? $progress['bodyDone'] - $startedAt : null);
        curl_close($curl);

        if ($code < 200 || $code >= 300 || $etag === null || $etag === '') {
            $why = $stalled
                ? 'upload stalled: the storage server accepted no data for ' . self::S3_STALL_SECONDS . 's'
                : $failure;
            if ($code >= 200 && $code < 300) {
                $why = 'the storage server accepted the part but returned no ETag - ' . $failure;
            }
            self::$lastError = 'part ' . $n . ' (' . self::humanBytes((float) $len) . ') to ' . $host . ' failed: ' . $why;
            return null;
        }
        return $etag;
    }

    /** Best-effort cleanup so a failed upload does not leave parts billed on the storage account. */
    private static function s3AbortMultipart(array $cfg, string $path, string $ep, string $host, string $uploadId): void
    {
        $q = self::s3CanonicalQuery(['uploadId' => $uploadId]);
        $hdrs = self::s3AuthHeaders($cfg, 'DELETE', $host, $path, $q, [], hash('sha256', ''));
        $curl = curl_init($ep . $path . '?' . $q);
        curl_setopt_array($curl, [CURLOPT_CUSTOMREQUEST => 'DELETE', CURLOPT_HTTPHEADER => $hdrs,
                                  CURLOPT_RETURNTRANSFER => true, CURLOPT_TIMEOUT => 60]);
        curl_exec($curl);
        curl_close($curl);
    }

    private static function s3Delete(array $cfg, string $remote): bool
    {
        $key = ($cfg['prefix'] ?? '') !== '' ? rtrim($cfg['prefix'], '/') . '/' . ltrim($remote, '/') : ltrim($remote, '/');
        $ep = ($cfg['endpoint'] ?? '') !== '' ? rtrim($cfg['endpoint'], '/') : "https://{$cfg['bucket']}.s3.{$cfg['region']}.amazonaws.com";
        $path = '/' . ltrim($key, '/');
        if (($cfg['endpoint'] ?? '') !== '') $path = '/' . $cfg['bucket'] . $path;
        $path = self::s3EncodePath($path);
        $host = self::s3Host($ep);
        $date = gmdate('Ymd\THis\Z');
        $ds = gmdate('Ymd');
        $hdrs = ['host' => $host, 'x-amz-content-sha256' => hash('sha256', ''), 'x-amz-date' => $date];
        ksort($hdrs);
        $sh = implode(';', array_keys($hdrs));
        $ch = ''; foreach ($hdrs as $k => $v) $ch .= "{$k}:{$v}\n";
        $cr = "DELETE\n{$path}\n\n{$ch}\n{$sh}\n" . hash('sha256', '');
        $scope = "{$ds}/{$cfg['region']}/s3/aws4_request";
        $sts = "AWS4-HMAC-SHA256\n{$date}\n{$scope}\n" . hash('sha256', $cr);
        $sk = hash_hmac('sha256', $ds, 'AWS4' . $cfg['secret'], true);
        $sk = hash_hmac('sha256', $cfg['region'], $sk, true);
        $sk = hash_hmac('sha256', 's3', $sk, true);
        $sk = hash_hmac('sha256', 'aws4_request', $sk, true);
        $sig = hash_hmac('sha256', $sts, $sk);
        $auth = "AWS4-HMAC-SHA256 Credential={$cfg['key']}/{$scope}, SignedHeaders={$sh}, Signature={$sig}";
        $curl = curl_init($ep . $path);
        curl_setopt_array($curl, [CURLOPT_CUSTOMREQUEST => 'DELETE', CURLOPT_HTTPHEADER => ["Authorization: {$auth}", "x-amz-content-sha256: " . hash('sha256', ''), "x-amz-date: {$date}"], CURLOPT_RETURNTRANSFER => true, CURLOPT_TIMEOUT => 30]);
        curl_exec($curl);
        $code = (int)curl_getinfo($curl, CURLINFO_HTTP_CODE);
        curl_close($curl);
        return $code >= 200 && $code < 300 || $code === 404;
    }

    private static function s3Exists(array $cfg, string $remote): bool
    {
        $key = ($cfg['prefix'] ?? '') !== '' ? rtrim($cfg['prefix'], '/') . '/' . ltrim($remote, '/') : ltrim($remote, '/');
        $ep = ($cfg['endpoint'] ?? '') !== '' ? rtrim($cfg['endpoint'], '/') : "https://{$cfg['bucket']}.s3.{$cfg['region']}.amazonaws.com";
        $path = '/' . ltrim($key, '/');
        if (($cfg['endpoint'] ?? '') !== '') $path = '/' . $cfg['bucket'] . $path;
        $path = self::s3EncodePath($path);
        $host = self::s3Host($ep);
        $date = gmdate('Ymd\THis\Z');
        $ds = gmdate('Ymd');
        $hdrs = ['host' => $host, 'x-amz-content-sha256' => hash('sha256', ''), 'x-amz-date' => $date];
        ksort($hdrs);
        $sh = implode(';', array_keys($hdrs));
        $ch = ''; foreach ($hdrs as $k => $v) $ch .= "{$k}:{$v}\n";
        $cr = "HEAD\n{$path}\n\n{$ch}\n{$sh}\n" . hash('sha256', '');
        $scope = "{$ds}/{$cfg['region']}/s3/aws4_request";
        $sts = "AWS4-HMAC-SHA256\n{$date}\n{$scope}\n" . hash('sha256', $cr);
        $sk = hash_hmac('sha256', $ds, 'AWS4' . $cfg['secret'], true);
        $sk = hash_hmac('sha256', $cfg['region'], $sk, true);
        $sk = hash_hmac('sha256', 's3', $sk, true);
        $sk = hash_hmac('sha256', 'aws4_request', $sk, true);
        $sig = hash_hmac('sha256', $sts, $sk);
        $auth = "AWS4-HMAC-SHA256 Credential={$cfg['key']}/{$scope}, SignedHeaders={$sh}, Signature={$sig}";
        $curl = curl_init($ep . $path);
        curl_setopt_array($curl, [CURLOPT_NOBODY => true, CURLOPT_HTTPHEADER => ["Authorization: {$auth}", "x-amz-content-sha256: " . hash('sha256', ''), "x-amz-date: {$date}"], CURLOPT_RETURNTRANSFER => true, CURLOPT_TIMEOUT => 30]);
        curl_exec($curl);
        $code = (int) curl_getinfo($curl, CURLINFO_HTTP_CODE);
        curl_close($curl);
        return $code >= 200 && $code < 300;
    }

    private static function davDelete(array $cfg, string $remote): bool
    {
        $baseUrl = rtrim($cfg['url'] ?? '', '/');
        $code = self::davReq('DELETE', $baseUrl . '/' . ltrim($remote, '/'), $cfg);
        return $code >= 200 && $code < 300 || $code === 404;
    }

     

    private static function davPut(array $cfg, string $local, string $remote): bool
    {
        $baseUrl = rtrim($cfg['url'] ?? '', '/');

         
         
         
         
        if ((@filesize($local) ?: 0) > 95 * 1024 * 1024) {
            [$chunkEndpoint, $chunkRoot] = self::davChunkTarget($baseUrl);
            if ($chunkEndpoint !== null && self::davPutChunked($chunkEndpoint, $chunkRoot, $remote, $local, $cfg)) {
                return true;
            }
            error_log('MediaStorage: chunked upload unavailable/failed for ' . $remote . ' — trying direct PUT');
        }

        $parts = explode('/', trim(dirname($remote), '/'));
        $cur = '';
        foreach ($parts as $p) {
            if ($p === '') continue;
            $cur .= '/' . $p;
            self::davReq('MKCOL', $baseUrl . $cur, $cfg);
        }
        $r = self::davReq('PUT', $baseUrl . '/' . ltrim($remote, '/'), $cfg, $local);
        return $r >= 200 && $r < 300;
    }

    

    private static function davChunkTarget(string $baseUrl): array
    {
        $p = parse_url($baseUrl);
        if (!$p || empty($p['host'])) return [null, null];
        $origin = ($p['scheme'] ?? 'https') . '://' . $p['host'] . (isset($p['port']) ? ':' . $p['port'] : '');
        $path = rtrim($p['path'] ?? '', '/');
        if ($path === '/dav') return [$origin . '/tp-upload.php', 'main'];
        if (preg_match('#^/([^/]+)/dav$#', $path, $m)) return [$origin . '/tp-upload.php', $m[1]];
        return [null, null];
    }

    

    private static function davPutChunked(string $endpoint, string $root, string $remote, string $local, array $cfg): bool
    {
        $size = (int) (@filesize($local) ?: 0);
        if ($size <= 0) return false;
        $fh = @fopen($local, 'rb');
        if (!$fh) return false;
        $chunkSize = 90 * 1024 * 1024;  
        $ok = true;
        for ($offset = 0; $offset < $size; $offset += $chunkSize) {
            $len = (int) min($chunkSize, $size - $offset);
            fseek($fh, $offset);
            $remaining = $len;
            $ch = curl_init($endpoint);
            curl_setopt_array($ch, [
                CURLOPT_UPLOAD         => true,             
                CURLOPT_CUSTOMREQUEST  => 'POST',          
                CURLOPT_INFILESIZE     => $len,
                CURLOPT_RETURNTRANSFER => true,
                CURLOPT_READFUNCTION   => function ($c, $stream, $maxlen) use ($fh, &$remaining) {
                    if ($remaining <= 0) return '';
                    $buf = fread($fh, (int) min($maxlen, $remaining));
                    if ($buf === false) return '';
                    $remaining -= strlen($buf);
                    return $buf;
                },
                CURLOPT_HTTPHEADER     => [
                    'X-TP-Root: ' . $root,
                    'X-TP-Path: ' . ltrim($remote, '/'),
                    'X-TP-Offset: ' . $offset,
                    'X-TP-Total: ' . $size,
                    'Content-Type: application/octet-stream',
                    'Expect:',
                ],
                CURLOPT_CONNECTTIMEOUT  => 15,
                CURLOPT_TIMEOUT         => 1800,
                CURLOPT_LOW_SPEED_LIMIT => 1024,
                CURLOPT_LOW_SPEED_TIME  => 120,
            ]);
            if (($cfg['user'] ?? '') !== '') {
                curl_setopt($ch, CURLOPT_USERPWD, $cfg['user'] . ':' . ($cfg['pass'] ?? ''));
                curl_setopt($ch, CURLOPT_HTTPAUTH, CURLAUTH_BASIC);
            }
            $resp = curl_exec($ch);
            $code = (int) curl_getinfo($ch, CURLINFO_HTTP_CODE);
            curl_close($ch);
            if ($code < 200 || $code >= 300) {
                error_log("MediaStorage davPutChunked: HTTP $code at offset $offset for $remote :: " . substr((string) $resp, 0, 200));
                $ok = false;
                break;
            }
        }
        fclose($fh);
        return $ok;
    }

    

    private static function davList(string $dirUrl, array $cfg): array
    {
        $ch = curl_init($dirUrl);
        curl_setopt_array($ch, [
            CURLOPT_CUSTOMREQUEST => 'PROPFIND', CURLOPT_RETURNTRANSFER => true,
            CURLOPT_CONNECTTIMEOUT => 15, CURLOPT_TIMEOUT => 30,
            CURLOPT_HTTPHEADER => ['Depth: 1'], CURLOPT_FOLLOWLOCATION => true,
        ]);
        if (($cfg['user'] ?? '') !== '') {
            curl_setopt($ch, CURLOPT_USERPWD, $cfg['user'] . ':' . ($cfg['pass'] ?? ''));
            curl_setopt($ch, CURLOPT_HTTPAUTH, CURLAUTH_BASIC);
        }
        $body = curl_exec($ch);
        $code = (int) curl_getinfo($ch, CURLINFO_HTTP_CODE);
        curl_close($ch);
        if ($code < 200 || $code >= 300 || !is_string($body) || $body === '') return [];
        if (!preg_match_all('#<[a-z0-9]*:?href>\s*([^<]+?)\s*</[a-z0-9]*:?href>#i', $body, $m)) return [];

        $scheme = parse_url($dirUrl, PHP_URL_SCHEME); $host = parse_url($dirUrl, PHP_URL_HOST);
        $port = parse_url($dirUrl, PHP_URL_PORT);
        $origin = $scheme . '://' . $host . ($port ? ':' . $port : '');
        $dirPath = rtrim((string) parse_url($dirUrl, PHP_URL_PATH), '/');

        $out = [];
        foreach ($m[1] as $href) {
            $href = html_entity_decode($href, ENT_QUOTES);
            $path = rtrim((string) (parse_url($href, PHP_URL_PATH) ?: $href), '/');
            if ($path === '' || $path === $dirPath) continue;           
            if (rtrim(str_replace('\\', '/', dirname($path)), '/') !== $dirPath) continue;  
            $out[] = $origin . $path;
        }
        return $out;
    }

    private static function davReq(string $method, string $url, array $cfg, ?string $file = null): int
    {
        $curl = curl_init($url);
         
         
         
         
         
         
        curl_setopt_array($curl, [
            CURLOPT_CUSTOMREQUEST  => $method,
            CURLOPT_RETURNTRANSFER => true,
            CURLOPT_CONNECTTIMEOUT => 15,
            CURLOPT_TIMEOUT        => 3600,
            CURLOPT_LOW_SPEED_LIMIT => 1024,
            CURLOPT_LOW_SPEED_TIME  => 120,
            CURLOPT_FOLLOWLOCATION => true,
        ]);
        if (($cfg['user'] ?? '') !== '') {
            curl_setopt($curl, CURLOPT_USERPWD, $cfg['user'] . ':' . ($cfg['pass'] ?? ''));
             
             
             
             
             
            curl_setopt($curl, CURLOPT_HTTPAUTH, CURLAUTH_BASIC);
        }
        if ($method === 'PUT' && $file) {
            $fh = fopen($file, 'rb');
            curl_setopt($curl, CURLOPT_UPLOAD, true);
            curl_setopt($curl, CURLOPT_INFILE, $fh);
            curl_setopt($curl, CURLOPT_INFILESIZE, filesize($file));
        }
        curl_exec($curl);
        $code = (int)curl_getinfo($curl, CURLINFO_HTTP_CODE);
        curl_close($curl);
        if (isset($fh) && is_resource($fh)) fclose($fh);
        return $code;
    }
}
