<?php
namespace EasyChat\Library;

use EasyChat\Library\AttachmentPolicy;
use EasyChat\Library\Http\ExternalHttpClient;
use EasyChat\Library\Http\PublicUrlGuard;
use EasyChat\Library\ImageVariantService;
use EasyChat\Library\Queue\QueueManager;
use EasyChat\Library\Voice\VoiceMediaService;








class AttachmentStorage {

    private $db;
    private $prefix;
    private $registry;
    private $variant_service;
    private $http;
    private $public_url_guard;

    public function __construct($registry, ?ExternalHttpClient $http = null, ?PublicUrlGuard $public_url_guard = null) {
        $this->registry = $registry;
        $this->db = $registry->get('db');
        $this->prefix = easychat_db_prefix();
        $this->variant_service = new ImageVariantService();
        $this->http = $http ?: new ExternalHttpClient($registry);
        $this->public_url_guard = $public_url_guard ?: new PublicUrlGuard();
    }

    public function saveUploadedImage(int $chat_id, array $file, int $max_mb, string $name_prefix = ''): array {
        if (empty($file['tmp_name']) || empty($file['name']) || !is_uploaded_file($file['tmp_name'])) {
            return ['success' => false, 'error' => 'file_missing'];
        }

        $validation = AttachmentPolicy::validateImageFile(
            $file['tmp_name'],
            (string)$file['name'],
            (int)$file['size'],
            $max_mb,
            true
        );
        if (empty($validation['success'])) {
            return $validation;
        }
        $upload_dir = $this->chatUploadDir($chat_id);
        if (!$this->ensureDir($upload_dir)) {
            return ['success' => false, 'error' => 'upload_dir_failed'];
        }

        $prefix = $name_prefix !== '' ? preg_replace('/[^a-zA-Z0-9_\-]/', '', $name_prefix) . '_' : '';
        $filename = $prefix . time() . '_' . bin2hex(random_bytes(4)) . '.' . $validation['extension'];
        $destination = $upload_dir . DS . $filename;

        if (!move_uploaded_file($file['tmp_name'], $destination)) {
            return ['success' => false, 'error' => 'file_save_failed'];
        }

        return $this->variant_service->processIncomingImage($chat_id, $destination, $filename);
    }

    public function saveUploadedAttachment(int $chat_id, array $file, int $max_mb, string $name_prefix = ''): array {
        if (empty($file['tmp_name']) || empty($file['name']) || !is_uploaded_file($file['tmp_name'])) {
            return ['success' => false, 'error' => 'file_missing'];
        }

        $validation = AttachmentPolicy::validateAttachmentFile(
            $file['tmp_name'],
            (string)$file['name'],
            (int)$file['size'],
            $max_mb,
            true
        );
        if (empty($validation['success'])) {
            return $validation;
        }
        if (($validation['kind'] ?? '') === 'audio') {
            return ['success' => false, 'error' => 'attachment_type_unsupported'];
        }

        $upload_dir = $this->chatUploadDir($chat_id);
        if (!$this->ensureDir($upload_dir)) {
            return ['success' => false, 'error' => 'upload_dir_failed'];
        }

        $prefix = $name_prefix !== '' ? preg_replace('/[^a-zA-Z0-9_\-]/', '', $name_prefix) . '_' : '';
        $filename = $prefix . time() . '_' . bin2hex(random_bytes(4)) . '.' . $validation['extension'];
        $destination = $upload_dir . DS . $filename;

        if (!move_uploaded_file($file['tmp_name'], $destination)) {
            return ['success' => false, 'error' => 'file_save_failed'];
        }

        if (($validation['kind'] ?? '') === 'image') {
            return $this->withAttachmentInfo(
                $this->variant_service->processIncomingImage($chat_id, $destination, $filename),
                'image',
                (string)$file['name']
            );
        }

        return [
            'success' => true,
            'kind' => 'document',
            'filename' => $filename,
            'path' => $destination,
            'mime' => (string)$validation['mime'],
            'size' => (int)$validation['size'],
            'original_name' => $this->displayName((string)$file['name'], $filename),
            'display_name' => $this->displayName((string)$file['name'], $filename),
        ];
    }

    public function saveExistingImage(int $chat_id, string $filename, int $max_mb): array {
        $filename = basename(trim($filename));
        if ($filename === '') {
            return ['success' => false, 'error' => 'file_missing'];
        }

        $path = $this->chatUploadDir($chat_id) . DS . $filename;
        if (!is_file($path)) {
            return ['success' => false, 'error' => 'file_not_found'];
        }

        $validation = AttachmentPolicy::validateImageFile(
            $path,
            $filename,
            (int)filesize($path),
            $max_mb,
            true
        );
        if (empty($validation['success'])) {
            return $validation;
        }

        $result = $this->canonicalizeStoredImage($chat_id, $filename);
        if (empty($result['success'])) {
            return $result;
        }

        return $result;
    }

    public function saveExistingAttachment(int $chat_id, string $filename, int $max_mb, string $display_name = ''): array {
        $filename = basename(trim($filename));
        if ($filename === '') {
            return ['success' => false, 'error' => 'file_missing'];
        }
        $display_name = $this->displayName($display_name, $filename);

        $path = $this->chatUploadDir($chat_id) . DS . $filename;
        if (!is_file($path)) {
            return ['success' => false, 'error' => 'file_not_found'];
        }

        $validation = AttachmentPolicy::validateAttachmentFile(
            $path,
            $filename,
            (int)filesize($path),
            $max_mb,
            true
        );
        if (empty($validation['success'])) {
            return $validation;
        }
        if (($validation['kind'] ?? '') === 'audio') {
             
             
            return ['success' => false, 'error' => 'attachment_type_unsupported'];
        }

        if (($validation['kind'] ?? '') === 'image') {
            $result = $this->canonicalizeStoredImage($chat_id, $filename);
            return $this->withAttachmentInfo($result, 'image', $display_name);
        }

        return [
            'success' => true,
            'kind' => 'document',
            'filename' => $filename,
            'path' => $path,
            'mime' => (string)$validation['mime'],
            'size' => (int)$validation['size'],
            'original_name' => $display_name,
            'display_name' => $display_name,
        ];
    }

    




    public function importTrustedProviderFile(
        int $chat_id,
        string $source_path,
        string $trusted_root,
        string $original_name,
        string $expected_sha256,
        int $max_mb = 32
    ): array {
        $source = realpath($source_path);
        $root = realpath($trusted_root);
        if ($chat_id <= 0 || $source === false || $root === false || is_link($source_path)
            || strpos($source, rtrim($root, DS) . DS) !== 0 || !is_file($source) || !is_readable($source)) {
            return ['success' => false, 'error' => 'trusted_file_denied'];
        }
        $size = (int)filesize($source);
        if (preg_match('/^[a-f0-9]{64}$/D', $expected_sha256) !== 1
            || !hash_equals($expected_sha256, (string)hash_file('sha256', $source))) {
            return ['success' => false, 'error' => 'trusted_file_hash_mismatch'];
        }
        $validation = AttachmentPolicy::validateAttachmentFile($source, $original_name, $size, $max_mb, true);
        if (empty($validation['success'])) {
            return $validation;
        }
        $upload_dir = $this->chatUploadDir($chat_id);
        if (!$this->ensureDir($upload_dir)) {
            return ['success' => false, 'error' => 'upload_dir_failed'];
        }
        $extension = (string)$validation['extension'];
        $filename = 'wa_' . time() . '_' . bin2hex(random_bytes(6)) . '.' . $extension;
        $destination = $upload_dir . DS . $filename;
        $temporary = $destination . '.part';
        $input = SafeRuntime::call(function () use ($source) {
            return fopen($source, 'rb');
        }, false);
        $previous_umask = umask(0077);
        try {
            $output = SafeRuntime::call(function () use ($temporary) {
                return fopen($temporary, 'xb');
            }, false);
        } finally {
            umask($previous_umask);
        }
        if (!is_resource($input) || !is_resource($output)) {
            if (is_resource($input)) fclose($input);
            if (is_resource($output)) fclose($output);
            SafeRuntime::unlink($temporary);
            return ['success' => false, 'error' => 'file_save_failed'];
        }
        $copied = SafeRuntime::call(function () use ($input, $output, $size) {
            return stream_copy_to_stream($input, $output, $size + 1);
        }, false);
        $flushed = SafeRuntime::call(function () use ($output) {
            return fflush($output);
        }, false);
        fclose($input);
        fclose($output);
        if ($copied !== $size || !$flushed || !SafeRuntime::rename($temporary, $destination)) {
            SafeRuntime::unlink($temporary);
            SafeRuntime::unlink($destination);
            return ['success' => false, 'error' => 'file_save_failed'];
        }
        if (!SafeRuntime::chmod($destination, 0640) || SafeRuntime::permissions($destination) !== 0640) {
            SafeRuntime::unlink($destination);
            return ['success' => false, 'error' => 'file_save_failed'];
        }
        if ((string)$validation['kind'] === 'image') {
            return $this->withAttachmentInfo(
                $this->variant_service->processIncomingImage($chat_id, $destination, $filename),
                'image',
                $original_name
            );
        }
        return [
            'success' => true,
            'kind' => (string)$validation['kind'],
            'filename' => $filename,
            'path' => $destination,
            'mime' => (string)$validation['mime'],
            'size' => $size,
            'original_name' => $this->displayName($original_name, $filename),
            'display_name' => $this->displayName($original_name, $filename),
        ];
    }

    public function saveRemoteAttachment(int $chat_id, string $url, array $headers, int $max_mb, string $trust_profile = '', string $original_name = '', array $allowed_hosts = [], int $timeout = 15): array {
        $target = $this->public_url_guard->prepare($url, $allowed_hosts);
        if (empty($target['success'])) {
            return ['success' => false, 'error' => (string)$target['error']];
        }
        $url = (string)$target['url'];

        $upload_dir = $this->chatUploadDir($chat_id);
        if (!$this->ensureDir($upload_dir)) {
            return ['success' => false, 'error' => 'upload_dir_failed'];
        }

        $tmp = tempnam($upload_dir, 'remote_');
        if ($tmp === false) {
            return ['success' => false, 'error' => 'tmp_failed'];
        }

        $fp = fopen($tmp, 'wb');
        if (!$fp) {
            SafeRuntime::unlink($tmp);
            return ['success' => false, 'error' => 'tmp_failed'];
        }

        $max_bytes = max(1, $max_mb) * 1024 * 1024;
        $written = 0;
        $response = $this->http->request('GET', $url, [
            'return_transfer' => false,
             
             
            'follow_location' => false,
            'connect_timeout' => 4,
            'timeout' => max(5, min(60, $timeout)),
            'headers' => $headers,
            'trust_profile' => $trust_profile,
            'proxy_profile' => 'messenger_attachments',
            'allow_tls_fallback' => $trust_profile !== '',
            'curl_options' => $target['curl_options'],
            'write_function' => function ($ch, $data) use ($fp, &$written, $max_bytes) {
                $len = strlen($data);
                $written += $len;
                if ($written > $max_bytes) {
                    return 0;
                }
                return fwrite($fp, $data);
            },
        ]);
        $http_code = (int)($response['http_code'] ?? 0);
        $content_type = (string)($response['content_type'] ?? '');
        fclose($fp);

        if (empty($response['transport_ok']) || $http_code < 200 || $http_code >= 300) {
            SafeRuntime::unlink($tmp);
            return [
                'success' => false,
                'error' => 'remote_download_failed',
                'http_code' => $http_code,
                'details' => !empty($response['tls_fallback_used']) ? 'tls_fallback' : (!empty($response['curl_error']) ? 'curl' : ''),
            ];
        }

        $mime = AttachmentPolicy::detectMime($tmp);
        if ($mime === 'application/octet-stream' && $content_type !== '') {
            $mime = strtolower(trim(explode(';', $content_type)[0]));
        }
        $extension = $this->remoteAttachmentExtension($mime, $url, $original_name);
        if (!in_array($extension, AttachmentPolicy::allowedAttachmentExtensions(true), true)
            && !AttachmentPolicy::isAudioExtension($extension)) {
            SafeRuntime::unlink($tmp);
            return ['success' => false, 'error' => 'file_mime_denied'];
        }

        $display_name = $this->displayName($original_name !== '' ? $original_name : basename((string)(parse_url($url, PHP_URL_PATH) ?: '')), 'remote.' . $extension);
        $validation = AttachmentPolicy::validateAttachmentFile($tmp, 'remote.' . $extension, (int)filesize($tmp), $max_mb, true);
        if (empty($validation['success'])) {
            SafeRuntime::unlink($tmp);
            return $validation;
        }

        $filename = 'm_' . time() . '_' . bin2hex(random_bytes(4)) . '.' . $validation['extension'];
        $destination = $upload_dir . DS . $filename;
        if (!SafeRuntime::rename($tmp, $destination)) {
            SafeRuntime::unlink($tmp);
            return ['success' => false, 'error' => 'file_save_failed'];
        }

        if (($validation['kind'] ?? '') === 'image') {
            return $this->withAttachmentInfo(
                $this->variant_service->processIncomingImage($chat_id, $destination, $filename),
                'image',
                $display_name
            );
        }

        return [
            'success' => true,
            'kind' => (string)($validation['kind'] ?? 'document'),
            'filename' => $filename,
            'path' => $destination,
            'mime' => (string)$validation['mime'],
            'size' => (int)$validation['size'],
            'original_name' => $display_name,
            'display_name' => $display_name,
        ];
    }

     
    public function acquireMessageAttachmentLock(int $message_id): bool {
        if ($message_id <= 0) {
            return false;
        }
        $name = $this->messageAttachmentLockName($message_id);
        $query = $this->db->query("SELECT GET_LOCK('" . $this->db->escape($name) . "', 2) AS `locked`");
        return (int)($query->row['locked'] ?? 0) === 1;
    }

    public function releaseMessageAttachmentLock(int $message_id): void {
        if ($message_id <= 0) {
            return;
        }
        $name = $this->messageAttachmentLockName($message_id);
        $this->db->query("SELECT RELEASE_LOCK('" . $this->db->escape($name) . "')");
    }

    public function attachToMessage(int $message_id, int $chat_id, array $file_info, string $provider = '', array $meta = []): int {
        if (empty($file_info['filename'])) {
            return 0;
        }

        $raw_filename = (string)$file_info['filename'];
        $filename = $this->db->escape($raw_filename);
        $file_name_hash = hash('sha256', $raw_filename);
        $mime = $this->db->escape((string)($file_info['mime'] ?? 'application/octet-stream'));
        $size = (int)($file_info['size'] ?? 0);
        $provider_e = $this->db->escape($provider);
        if (!empty($file_info['variants']) && is_array($file_info['variants'])) {
            $meta['variants'] = $file_info['variants'];
        }
        $kind = (string)($file_info['kind'] ?? '');
        if ($kind !== '') {
            $meta['kind'] = $kind;
        }
        if ($kind === 'audio' && is_array($file_info['voice'] ?? null)) {
            $meta['voice'] = $file_info['voice'];
        }
        $display_name = $this->displayName((string)($file_info['display_name'] ?? $file_info['original_name'] ?? ''), (string)$file_info['filename']);
        if ($display_name !== '') {
            $meta['display_name'] = $display_name;
        }
        $content_path = (string)($file_info['path'] ?? '');
        if ($content_path !== '' && is_file($content_path)) {
            $content_hash = hash_file('sha256', $content_path);
            if (is_string($content_hash) && preg_match('/^[a-f0-9]{64}$/D', $content_hash) === 1) {
                $meta['content_hash'] = $content_hash;
            }
        }

        $meta_json = $this->db->escape(json_encode($meta, JSON_UNESCAPED_UNICODE));

        $this->db->query(
            "INSERT INTO `{$this->prefix}message_attachments`
             SET `message_id` = " . (int)$message_id . ",
                 `chat_id` = " . (int)$chat_id . ",
                 `file_name` = '{$filename}',
                 `file_name_hash` = '" . $this->db->escape($file_name_hash) . "',
                 `mime_type` = '{$mime}',
                 `file_size` = " . (int)$size . ",
                 `provider` = '{$provider_e}',
                 `meta_json` = '{$meta_json}',
                 `date_added` = NOW()
             ON DUPLICATE KEY UPDATE `id` = LAST_INSERT_ID(`id`)"
        );

        $attachment_id = (int)$this->db->getLastId();
        $attachment_created = (int)$this->db->countAffected() > 0;
        if ($attachment_id > 0 && $attachment_created && $kind === 'audio') {
            $voice = is_array($meta['voice'] ?? null) ? $meta['voice'] : [];
            $service = new VoiceMediaService($this->registry);
            if (($voice['media_status'] ?? '') === 'queued') {
                $service->enqueueFetch($attachment_id);
            } elseif (($voice['media_status'] ?? '') === 'ready') {
                $service->enqueueTranscription($attachment_id);
            }
        }

        return $attachment_id;
    }

    public function replaceMessageAttachments(int $message_id, int $chat_id, array $attachments, string $provider = ''): void {
        $message_id = (int)$message_id;
        $chat_id = (int)$chat_id;
        if ($message_id <= 0 || $chat_id <= 0) {
            return;
        }

        $this->deleteMessageAttachments($message_id, $chat_id);
        foreach ($attachments as $attachment) {
            if (!is_array($attachment)) {
                continue;
            }
            $this->attachToMessage($message_id, $chat_id, $attachment, $provider, [
                'external_payload_hash' => $attachment['external_payload_hash'] ?? '',
            ]);
        }
    }

    public function deleteMessageAttachments(int $message_id, int $chat_id): void {
        $message_id = (int)$message_id;
        $chat_id = (int)$chat_id;
        if ($message_id <= 0 || $chat_id <= 0) {
            return;
        }

        $result = $this->db->query(
            "SELECT `file_name`, `meta_json`
             FROM `{$this->prefix}message_attachments`
             WHERE `message_id` = " . $message_id . "
               AND `chat_id` = " . $chat_id
        );

        $files = [];
        foreach ($result->rows as $row) {
            $this->collectAttachmentFiles($files, (string)($row['file_name'] ?? ''), (string)($row['meta_json'] ?? ''));
        }

        $this->db->query(
            "DELETE FROM `{$this->prefix}message_attachments`
             WHERE `message_id` = " . $message_id . "
               AND `chat_id` = " . $chat_id
        );

        $dir = $this->chatUploadDir($chat_id);
        foreach (array_keys($files) as $file) {
            $path = $dir . DS . basename($file);
            if (is_file($path)) {
                SafeRuntime::unlink($path);
            }
        }
    }

     
    public function deleteChatDirectory(int $chat_id): bool {
        if ($chat_id <= 0) return false;
        $dir = $this->chatUploadDir($chat_id);
        if (is_link($dir)) return SafeRuntime::unlink($dir);
        if (!is_dir($dir)) return true;

        $base = realpath(rtrim(DIR_IMAGE, DS));
        $real = realpath($dir);
        if ($base === false || $real === false
            || strpos($real . DS, rtrim($base, DS) . DS) !== 0) {
            return false;
        }

        try {
            $iterator = new \RecursiveIteratorIterator(
                new \RecursiveDirectoryIterator($real, \FilesystemIterator::SKIP_DOTS),
                \RecursiveIteratorIterator::CHILD_FIRST
            );
            foreach ($iterator as $entry) {
                $path = $entry->getPathname();
                if ($entry->isLink() || $entry->isFile()) {
                    if (!SafeRuntime::unlink($path)) return false;
                } elseif ($entry->isDir() && !SafeRuntime::rmdir($path)) {
                    return false;
                }
            }
            return SafeRuntime::rmdir($real);
        } catch (\Throwable $e) {
            return false;
        }
    }

    public function attachmentPayload(int $chat_id, array $attachment, string $route = 'index/image', bool $sync_variants = false): array {
        $filename = (string)($attachment['file_name'] ?? '');
        $meta = $this->attachmentMeta($attachment);
        $kind = $this->attachmentKind($attachment, $meta);
        if ($kind === 'image') {
            $attachment = $this->ensureAttachmentVariants($attachment, $sync_variants);
            $filename = (string)($attachment['file_name'] ?? '');
            $meta = $this->attachmentMeta($attachment);
        }
        $urls = $this->imageUrlSet($chat_id, $filename, $route, $meta);
        $display_name = $this->displayName((string)($meta['display_name'] ?? ''), $filename);
        $content_hash = strtolower(trim((string)($meta['content_hash'] ?? '')));
        if (preg_match('/^[a-f0-9]{64}$/D', $content_hash) !== 1) {
            $content_hash = '';
        }

        if ($kind === 'audio') {
            $voice = is_array($meta['voice'] ?? null) ? $meta['voice'] : [];
            $transcription = is_array($voice['transcription'] ?? null) ? $voice['transcription'] : [];
            $media_ready = ($voice['media_status'] ?? '') === 'ready' && is_file($this->chatFilePath($chat_id, $filename));
            $stream_url = $media_ready ? $this->fileUrl($chat_id, $filename, $route, $display_name) : '';
            $waveform = [];
            foreach (array_slice((array)($voice['waveform'] ?? []), 0, 64) as $bar) {
                $waveform[] = max(10, min(100, (int)$bar));
            }
            return [
                'attachment_id' => (int)($attachment['id'] ?? 0),
                'kind' => 'audio',
                'url' => $stream_url,
                'full_url' => $stream_url,
                'stream_url' => $stream_url,
                'download_url' => $stream_url,
                'preview_url' => '',
                'thumb_url' => '',
                'file_name' => htmlspecialchars($filename, ENT_QUOTES, 'UTF-8'),
                'display_name' => htmlspecialchars($display_name, ENT_QUOTES, 'UTF-8'),
                'mime_type' => htmlspecialchars((string)($attachment['mime_type'] ?? ''), ENT_QUOTES, 'UTF-8'),
                'file_size' => (int)($attachment['file_size'] ?? 0),
                'content_hash' => $content_hash,
                'duration' => max(0, (int)($voice['duration'] ?? 0)),
                'waveform' => $waveform,
                'media_status' => (string)($voice['media_status'] ?? 'error'),
                'transcription' => [
                    'status' => (string)($transcription['status'] ?? 'disabled'),
                    'text' => (string)($transcription['text'] ?? ''),
                    'language' => (string)($transcription['language'] ?? ''),
                    'error_code' => (string)($transcription['error_code'] ?? ''),
                    'can_retry' => $this->canRetryVoice($voice, $transcription),
                ],
            ];
        }

        if ($kind !== 'image') {
            $download_url = $this->fileUrl($chat_id, $filename, $route, $display_name);
            $pdf_preview = $this->pdfPreviewUrlSet($chat_id, $route, $meta);
            return [
                'attachment_id' => (int)($attachment['id'] ?? 0),
                'kind' => $kind,
                'url' => $download_url,
                'full_url' => $download_url,
                'download_url' => $download_url,
                'preview_url' => $pdf_preview['preview_url'],
                'thumb_url' => $pdf_preview['thumb_url'],
                'file_name' => htmlspecialchars($filename, ENT_QUOTES, 'UTF-8'),
                'display_name' => htmlspecialchars($display_name, ENT_QUOTES, 'UTF-8'),
                'mime_type' => htmlspecialchars((string)($attachment['mime_type'] ?? ''), ENT_QUOTES, 'UTF-8'),
                'file_size' => (int)($attachment['file_size'] ?? 0),
                'content_hash' => $content_hash,
                'width' => (int)$pdf_preview['width'],
                'height' => (int)$pdf_preview['height'],
            ];
        }

        return array_merge($urls, [
            'attachment_id' => (int)($attachment['id'] ?? 0),
            'kind' => 'image',
            'download_url' => $urls['full_url'],
            'file_name' => htmlspecialchars($filename, ENT_QUOTES, 'UTF-8'),
            'display_name' => htmlspecialchars($display_name, ENT_QUOTES, 'UTF-8'),
            'mime_type' => htmlspecialchars((string)($attachment['mime_type'] ?? ''), ENT_QUOTES, 'UTF-8'),
            'file_size' => (int)($attachment['file_size'] ?? 0),
            'content_hash' => $content_hash,
            'width' => (int)($meta['variants']['canonical']['width'] ?? $meta['variants']['source']['width'] ?? 0),
            'height' => (int)($meta['variants']['canonical']['height'] ?? $meta['variants']['source']['height'] ?? 0),
            'preview_ready' => !empty($urls['preview_ready']),
            'variant_status' => (string)($meta['variants']['status'] ?? $urls['variant_status'] ?? ''),
        ]);
    }

    public function getAttachmentById(int $attachment_id): array {
        if ($attachment_id <= 0) {
            return [];
        }
        $result = $this->db->query(
            "SELECT * FROM `{$this->prefix}message_attachments`
             WHERE `id` = " . (int)$attachment_id . "
             LIMIT 1"
        );
        return $result->row ?? [];
    }

    public function savePdfPreviewUpload(int $attachment_id, int $chat_id, array $file, string $route = 'index/image'): array {
        $attachment = $this->getAttachmentById($attachment_id);
        if (!$attachment || (int)($attachment['chat_id'] ?? 0) !== $chat_id) {
            return ['success' => false, 'error' => 'attachment_not_found'];
        }

        $meta = $this->attachmentMeta($attachment);
        if ($this->attachmentKind($attachment, $meta) !== 'document') {
            return ['success' => false, 'error' => 'attachment_not_pdf'];
        }

        $filename = basename((string)($attachment['file_name'] ?? ''));
        $source_path = $this->chatFilePath($chat_id, $filename);
        if (!AttachmentPolicy::isDocumentExtension(strtolower(pathinfo($filename, PATHINFO_EXTENSION))) || !AttachmentPolicy::isPdfFile($source_path)) {
            return ['success' => false, 'error' => 'attachment_not_pdf'];
        }

        $existing = $this->pdfPreviewUrlSet($chat_id, $route, $meta);
        if ($existing['preview_url'] !== '') {
            return [
                'success' => true,
                'attachment' => $this->attachmentPayload($chat_id, $attachment, $route, false),
            ];
        }

        if (empty($file['tmp_name']) || !is_uploaded_file($file['tmp_name'])) {
            return ['success' => false, 'error' => 'file_missing'];
        }

        $size = (int)($file['size'] ?? 0);
        if ($size <= 0 || $size > 300 * 1024) {
            return ['success' => false, 'error' => 'file_size_denied'];
        }

        $mime = AttachmentPolicy::detectMime((string)$file['tmp_name']);
        if (!in_array($mime, ['image/webp', 'image/jpeg', 'image/png'], true)) {
            return ['success' => false, 'error' => 'file_mime_denied'];
        }

        $image_size = SafeRuntime::imageInfo((string)$file['tmp_name']);
        if (!is_array($image_size) || empty($image_size[0]) || empty($image_size[1])) {
            return ['success' => false, 'error' => 'file_mime_denied'];
        }

        $extension = AttachmentPolicy::extensionForMime($mime);
        if (!in_array($extension, ['webp', 'jpg', 'png'], true)) {
            return ['success' => false, 'error' => 'file_extension_denied'];
        }

        $upload_dir = $this->chatUploadDir($chat_id);
        if (!$this->ensureDir($upload_dir)) {
            return ['success' => false, 'error' => 'upload_dir_failed'];
        }

        $preview_file = 'pdfp_' . (int)$attachment_id . '_' . substr(sha1($filename . '|' . $size . '|' . microtime(true)), 0, 12) . '.' . $extension;
        $destination = $upload_dir . DS . $preview_file;
        if (!move_uploaded_file((string)$file['tmp_name'], $destination)) {
            return ['success' => false, 'error' => 'file_save_failed'];
        }

        $meta['pdf_preview'] = [
            'status' => 'ready',
            'file' => $preview_file,
            'mime' => $mime,
            'width' => (int)$image_size[0],
            'height' => (int)$image_size[1],
            'size' => (int)filesize($destination),
            'generated_at' => gmdate('c'),
        ];
        $this->updateAttachmentMeta($attachment_id, $meta);
        $attachment['meta_json'] = json_encode($meta, JSON_UNESCAPED_UNICODE);

        return [
            'success' => true,
            'attachment' => $this->attachmentPayload($chat_id, $attachment, $route, false),
        ];
    }

    public function imageUrlSet(int $chat_id, string $filename, string $route = 'index/image', array $meta = [], bool $sync_variants = false): array {
        $filename = basename(trim($filename));
        if ($filename === '') {
            return [
                'url' => '',
                'full_url' => '',
                'preview_url' => '',
                'thumb_url' => '',
                'preview_ready' => false,
                'variant_status' => 'missing',
                'width' => 0,
                'height' => 0,
            ];
        }

        if ($sync_variants) {
            $canonical = $this->canonicalizeStoredImage($chat_id, $filename);
            if (!empty($canonical['success']) && !empty($canonical['filename'])) {
                $filename = (string)$canonical['filename'];
                $meta = ['variants' => $canonical['variants'] ?? []];
            }
        }

        $variants = isset($meta['variants']) && is_array($meta['variants']) ? $meta['variants'] : [];
        $thumb_file = (string)($variants['thumb']['file'] ?? '');
        $preview_file = (string)($variants['preview']['file'] ?? '');
        if ($thumb_file === '' || $preview_file === '') {
            $discovered = $this->discoverVariantFiles($chat_id, $filename);
            $thumb_file = $thumb_file !== '' ? $thumb_file : $discovered['thumb'];
            $preview_file = $preview_file !== '' ? $preview_file : $discovered['preview'];
        }

        $full_url = $this->fileUrl($chat_id, $filename, $route);
        $variant_status = (string)($variants['status'] ?? '');
        $preview_ready = $thumb_file !== '' || $preview_file !== '';
        return [
            'url' => $full_url,
            'full_url' => $full_url,
            'preview_url' => $preview_file !== '' ? $this->fileUrl($chat_id, $preview_file, $route) : $full_url,
            'thumb_url' => $thumb_file !== '' ? $this->fileUrl($chat_id, $thumb_file, $route) : ($preview_file !== '' ? $this->fileUrl($chat_id, $preview_file, $route) : $full_url),
            'preview_ready' => $preview_ready || $variant_status === '',
            'variant_status' => $variant_status,
            'width' => (int)($variants['canonical']['width'] ?? $variants['source']['width'] ?? 0),
            'height' => (int)($variants['canonical']['height'] ?? $variants['source']['height'] ?? 0),
        ];
    }

    private function pdfPreviewUrlSet(int $chat_id, string $route, array $meta): array {
        $preview = isset($meta['pdf_preview']) && is_array($meta['pdf_preview']) ? $meta['pdf_preview'] : [];
        $file = basename((string)($preview['file'] ?? ''));
        if ($file === '') {
            return ['preview_url' => '', 'thumb_url' => '', 'width' => 0, 'height' => 0];
        }
        $path = $this->chatFilePath($chat_id, $file);
        $mime = AttachmentPolicy::detectMime($path, strtolower(pathinfo($file, PATHINFO_EXTENSION)));
        if (!is_file($path) || !in_array($mime, ['image/webp', 'image/jpeg', 'image/png'], true)) {
            return ['preview_url' => '', 'thumb_url' => '', 'width' => 0, 'height' => 0];
        }
        $url = $this->fileUrl($chat_id, $file, $route);
        return [
            'preview_url' => $url,
            'thumb_url' => $url,
            'width' => (int)($preview['width'] ?? 0),
            'height' => (int)($preview['height'] ?? 0),
        ];
    }

    public function ensureAttachmentVariants(array $attachment, bool $sync = false): array {
        $filename = (string)($attachment['file_name'] ?? '');
        $chat_id = (int)($attachment['chat_id'] ?? 0);
        if ($filename === '' || $chat_id <= 0) {
            return $attachment;
        }

        $meta = $this->attachmentMeta($attachment);
        if ($this->attachmentKind($attachment, $meta) !== 'image') {
            return $attachment;
        }
        if ($sync) {
            $result = $this->canonicalizeStoredImage($chat_id, $filename, (int)($attachment['id'] ?? 0), (int)($attachment['message_id'] ?? 0));
            if (!empty($result['success'])) {
                $attachment['file_name'] = (string)($result['filename'] ?? $filename);
                $attachment['mime_type'] = (string)($result['mime'] ?? ($attachment['mime_type'] ?? ''));
                $attachment['file_size'] = (int)($result['size'] ?? ($attachment['file_size'] ?? 0));
                $attachment['meta_json'] = json_encode(['variants' => $result['variants'] ?? []], JSON_UNESCAPED_UNICODE);
            }
            return $attachment;
        }

        if ($this->metaHasUsableVariants($chat_id, $meta)) {
            return $attachment;
        }

        $variant_status = (string)($meta['variants']['status'] ?? '');
        $queued_at = max(0, (int)($meta['variants']['queued_at'] ?? 0));
        $queue_stale = $variant_status === 'queued'
            && ($queued_at <= 0 || $queued_at < time() - 600);
        $retry_after = max(0, (int)($meta['variants']['last_queue_error_at'] ?? 0));
        $retryable_due = $variant_status === 'retryable'
            && ($retry_after <= 0 || $retry_after < time() - 60);
        $last_attempt_at = max(0, (int)($meta['variants']['last_attempt_at'] ?? 0));
        $generation_retry_due = in_array($variant_status, ['partial', 'failed', 'error'], true)
            && ($last_attempt_at <= 0 || $last_attempt_at < time() - 600);
         
         
        $unsupported_retry_due = $variant_status === 'unsupported'
            && ($last_attempt_at <= 0 || $last_attempt_at < time() - 21600);
        $should_queue = $variant_status === ''
            || $queue_stale
            || $retryable_due
            || $generation_retry_due
            || $unsupported_retry_due;
        if ($should_queue) {
             
             
            $job_id = $this->queueVariantJob($chat_id, $filename, (int)($attachment['id'] ?? 0));
            if ($job_id > 0) {
                $meta['variants'] = array_merge($meta['variants'] ?? [], [
                    'status' => 'queued',
                    'queued_at' => time(),
                    'job_id' => $job_id,
                ]);
            } else {
                $meta['variants'] = array_merge($meta['variants'] ?? [], [
                    'status' => 'retryable',
                    'last_queue_error_at' => time(),
                ]);
            }
            $this->updateAttachmentMeta((int)($attachment['id'] ?? 0), $meta);
            $attachment['meta_json'] = json_encode($meta, JSON_UNESCAPED_UNICODE);
        }
        return $attachment;
    }

    public function generateVariantsForFile(int $chat_id, string $filename, int $attachment_id = 0): array {
        $path = $this->chatFilePath($chat_id, $filename);
        $extension = strtolower(pathinfo($filename, PATHINFO_EXTENSION));
        $mime = AttachmentPolicy::detectMime($path, $extension);
        if (!AttachmentPolicy::isImageExtension($extension, true) || !AttachmentPolicy::isImageMime($mime, true)) {
            return ['status' => 'skipped', 'reason' => 'not_image'];
        }
        $result = $this->canonicalizeStoredImage($chat_id, $filename, $attachment_id);
        if (empty($result['success'])) {
            return ['status' => $result['error'] ?? 'failed'];
        }
        return isset($result['variants']) && is_array($result['variants']) ? $result['variants'] : ['status' => 'ready'];
    }

    public function getAttachmentsByMessageIds(array $message_ids): array {
        $ids = array_values(array_filter(array_map('intval', $message_ids)));
        if (!$ids) {
            return [];
        }

        $result = $this->db->query(
            "SELECT * FROM `{$this->prefix}message_attachments`
             WHERE `message_id` IN (" . implode(',', $ids) . ")
             ORDER BY `id` ASC"
        );

        $grouped = [];
        $seen = [];
        foreach ($result->rows as $row) {
            $mid = (int)$row['message_id'];
            if (!isset($grouped[$mid])) {
                $grouped[$mid] = [];
                $seen[$mid] = [];
            }
            if ($this->attachmentRowAlreadySeen($row, $seen[$mid])) {
                continue;
            }
            $grouped[$mid][] = $row;
        }
        return $grouped;
    }

    private function messageAttachmentLockName(int $message_id): string {
        return 'easychat_attach_' . substr(hash('sha256', $this->prefix . ':' . $message_id), 0, 40);
    }

    public function getMediaAttachmentsForChat(int $chat_id, int $limit = 60, int $before_message_id = 0): array {
        if ($chat_id <= 0) {
            return [];
        }

        $limit = min(120, max(1, $limit));
        $where_before = $before_message_id > 0 ? " AND m.`message_id` < " . (int)$before_message_id : "";
        $result = $this->db->query(
            "SELECT a.*, m.`author`, m.`manager_name`, m.`date_added`
             FROM `{$this->prefix}message_attachments` a
             INNER JOIN `{$this->prefix}messages` m ON m.`message_id` = a.`message_id`
             WHERE a.`chat_id` = " . (int)$chat_id . $where_before . "
             ORDER BY m.`message_id` DESC, a.`id` DESC
             LIMIT " . (int)($limit + 1)
        );

        $rows = [];
        $seen_by_message = [];
        foreach ($result->rows ?? [] as $row) {
            $message_id = (int)($row['message_id'] ?? 0);
            if (!isset($seen_by_message[$message_id])) {
                $seen_by_message[$message_id] = [];
            }
            if ($this->attachmentRowAlreadySeen($row, $seen_by_message[$message_id])) {
                continue;
            }
            $rows[] = $row;
            if (count($rows) >= $limit + 1) {
                break;
            }
        }
        return $rows;
    }

    





    private function attachmentRowAlreadySeen(array $row, array &$seen): bool {
        $meta = json_decode((string)($row['meta_json'] ?? ''), true);
        $meta = is_array($meta) ? $meta : [];
        $keys = [];
        $has_strong_content_identity = false;
        $external_id = trim((string)($row['external_attachment_id'] ?? ''));
        if ($external_id !== '') {
            $keys[] = 'external:' . strtolower($external_id);
            $has_strong_content_identity = true;
        }
        foreach (['content_hash', 'external_payload_hash'] as $hash_key) {
            $hash = strtolower(trim((string)($meta[$hash_key] ?? '')));
            if ($hash !== '') {
                $keys[] = $hash_key . ':' . $hash;
                $has_strong_content_identity = true;
            }
        }
        $file_name = trim((string)($row['file_name'] ?? ''));
        if ($file_name !== '') {
            $keys[] = 'file:' . strtolower($file_name);
        }
        $display_name = trim((string)($meta['display_name'] ?? ''));
        $mime = strtolower(trim((string)($row['mime_type'] ?? '')));
        $size = max(0, (int)($row['file_size'] ?? 0));
         
         
        if (!$has_strong_content_identity && $display_name !== '' && $size > 0) {
            $keys[] = 'visual:' . hash('sha256', mb_strtolower($display_name) . '|' . $mime . '|' . $size);
        }
        if (!$keys) {
            $keys[] = 'row:' . (int)($row['id'] ?? 0);
        }

        foreach ($keys as $key) {
            if (isset($seen[$key])) {
                return true;
            }
        }
        foreach ($keys as $key) {
            $seen[$key] = true;
        }
        return false;
    }

    public function fileUrl(int $chat_id, string $filename, string $route = 'index/image', string $download_name = ''): string {
        $filename = basename(trim($filename));
        $url = HTTP_SERVER . 'index.php?route=' . $route . '&chat_id=' . (int)$chat_id . '&file=' . urlencode($filename);
        $path = $this->chatFilePath($chat_id, $filename);
        if (is_file($path)) {
            $size = (int)filesize($path);
            $mtime = (int)filemtime($path);
            $url .= '&v=' . sha1($filename . '|' . $size . '|' . $mtime);
        }
        $download_name = $this->displayName($download_name, $filename);
        if ($download_name !== '' && $download_name !== basename($filename)) {
            $url .= '&name=' . rawurlencode($download_name);
        }
        return $url;
    }

    private function remoteAttachmentExtension(string $mime, string $url, string $original_name): string {
         
         
         
         
        $detected_audio_extension = AttachmentPolicy::extensionForMime($mime);
        if (AttachmentPolicy::isAudioMime($mime) && AttachmentPolicy::isAudioExtension($detected_audio_extension)) {
            return $detected_audio_extension;
        }

         
         
         
         
        $candidates = [
            strtolower(pathinfo($original_name, PATHINFO_EXTENSION)),
            strtolower(pathinfo((string)(parse_url($url, PHP_URL_PATH) ?: ''), PATHINFO_EXTENSION)),
            AttachmentPolicy::extensionForMime($mime),
        ];
        foreach ($candidates as $candidate) {
            if (in_array($candidate, AttachmentPolicy::allowedAttachmentExtensions(true), true)
                || AttachmentPolicy::isAudioExtension($candidate)) {
                return $candidate;
            }
        }
        return '';
    }

    public function chatFilePath(int $chat_id, string $filename): string {
        return $this->chatUploadDir($chat_id) . DS . basename($filename);
    }

    public function canonicalizeStoredImage(int $chat_id, string $filename, int $attachment_id = 0, int $message_id = 0): array {
        $old_filename = basename(trim($filename));
        if ($chat_id <= 0 || $old_filename === '') {
            return ['success' => false, 'error' => 'file_missing'];
        }

        $old_path = $this->chatFilePath($chat_id, $old_filename);
        if (!is_file($old_path)) {
            return ['success' => false, 'error' => 'file_not_found'];
        }

        $result = $this->variant_service->canonicalizeExistingImage($chat_id, $old_filename);
        if (empty($result['success'])) {
            return $result;
        }

        $new_filename = basename((string)($result['filename'] ?? $old_filename));
        $this->syncCanonicalReferences($chat_id, $old_filename, $new_filename, $result, $attachment_id, $message_id);

         
         
        if ($new_filename !== $old_filename && is_file($old_path)) {
            SafeRuntime::unlink($old_path);
        }

        return $result;
    }

    private function chatUploadDir(int $chat_id): string {
        return rtrim(DIR_IMAGE, DS) . DS . 'chat_id_' . (int)$chat_id;
    }

    private function ensureDir(string $dir): bool {
        return SafeRuntime::mkdir($dir, 0755, true);
    }

    private function canRetryVoice(array $voice, array $transcription): bool {
        $media_status = (string)($voice['media_status'] ?? '');
        if ($media_status === 'error') {
            return trim((string)($voice['source'] ?? '')) !== '';
        }
        return $media_status === 'ready'
            && in_array((string)($transcription['status'] ?? ''), ['error', 'empty'], true);
    }

    private function attachmentMeta(array $attachment): array {
        $meta = json_decode((string)($attachment['meta_json'] ?? ''), true);
        return is_array($meta) ? $meta : [];
    }

    private function collectAttachmentFiles(array &$files, string $filename, string $meta_json): void {
        $filename = basename(trim($filename));
        if ($filename !== '') {
            $files[$filename] = true;
        }

        $meta = json_decode($meta_json, true);
        if (!is_array($meta)) {
            return;
        }

        $variants = $meta['variants'] ?? [];
        if (!is_array($variants)) {
            return;
        }

        foreach ($variants as $variant) {
            if (!is_array($variant)) {
                continue;
            }
            $file = basename(trim((string)($variant['file'] ?? '')));
            if ($file !== '') {
                $files[$file] = true;
            }
        }
    }

    private function attachmentKind(array $attachment, array $meta = []): string {
        $kind = (string)($meta['kind'] ?? '');
        $mime = strtolower(trim((string)($attachment['mime_type'] ?? '')));
        $extension = strtolower(pathinfo((string)($attachment['file_name'] ?? ''), PATHINFO_EXTENSION));

         
         
         
         
        $mime_unknown = $mime === '' || $mime === 'application/octet-stream';
        if (($mime !== '' && AttachmentPolicy::isImageMime($mime, true))
            || ($mime_unknown && AttachmentPolicy::isImageExtension($extension, true))) {
            return 'image';
        }

        if ($kind === 'audio' || ($mime !== '' && AttachmentPolicy::isAudioMime($mime))
            || ($mime_unknown && AttachmentPolicy::isAudioExtension($extension))) {
            return 'audio';
        }

        if ($kind === 'document') {
            return 'document';
        }

        if ($kind === 'image') {
            return 'image';
        }

        if ($mime !== '' && AttachmentPolicy::isAudioMime($mime)) {
            return 'audio';
        }
        return AttachmentPolicy::isAudioExtension($extension) ? 'audio' : 'document';
    }

    private function displayName(string $candidate, string $fallback): string {
        $name = basename(trim($candidate));
        if ($name === '') {
            $name = basename(trim($fallback));
        }
        $name = preg_replace('/[\x00-\x1F\x7F]+/', '', $name);
        $name = trim((string)$name);
        if ($name === '') {
            return basename(trim($fallback));
        }
        if (function_exists('mb_substr')) {
            return mb_substr($name, 0, 120, 'UTF-8');
        }
        return substr($name, 0, 120);
    }

    private function withAttachmentInfo(array $result, string $kind, string $display_name): array {
        if (empty($result['success'])) {
            return $result;
        }
        $filename = (string)($result['filename'] ?? '');
        $result['kind'] = $kind;
        $result['display_name'] = $this->displayName($display_name, $filename);
        $result['original_name'] = $result['display_name'];
        return $result;
    }

    private function updateAttachmentMeta(int $attachment_id, array $meta): void {
        if ($attachment_id <= 0) {
            return;
        }
        $meta_json = $this->db->escape(json_encode($meta, JSON_UNESCAPED_UNICODE));
        $this->db->query("UPDATE `{$this->prefix}message_attachments` SET `meta_json` = '{$meta_json}' WHERE `id` = " . (int)$attachment_id);
    }

    private function syncCanonicalReferences(int $chat_id, string $old_filename, string $new_filename, array $result, int $attachment_id = 0, int $message_id = 0): void {
        $new_e = $this->db->escape($new_filename);
        $old_e = $this->db->escape($old_filename);
        $mime_e = $this->db->escape((string)($result['mime'] ?? AttachmentPolicy::detectMime((string)($result['path'] ?? ''))));
        $size = (int)($result['size'] ?? 0);
        $message_ids = [];
        $attachment_where = $attachment_id > 0
            ? "`id` = " . (int)$attachment_id
            : "`chat_id` = " . (int)$chat_id . " AND `file_name` = '{$old_e}'";
        $attachment_rows = $this->db->query(
            "SELECT `id`, `message_id`, `meta_json`
             FROM `{$this->prefix}message_attachments`
             WHERE {$attachment_where}"
        );

        foreach ((array)($attachment_rows->rows ?? []) as $attachment_row) {
             
             
            $meta = json_decode((string)($attachment_row['meta_json'] ?? ''), true);
            $meta = is_array($meta) ? $meta : [];
            $meta['variants'] = $result['variants'] ?? [];
            if ((string)($meta['variants']['status'] ?? '') !== 'ready') {
                $meta['variants']['last_attempt_at'] = time();
            }
            $meta_json = $this->db->escape(json_encode($meta, JSON_UNESCAPED_UNICODE));
            $this->db->query(
                "UPDATE `{$this->prefix}message_attachments`
                 SET `file_name` = '{$new_e}', `mime_type` = '{$mime_e}', `file_size` = {$size}, `meta_json` = '{$meta_json}'
                 WHERE `id` = " . (int)$attachment_row['id']
            );
            if ((int)($attachment_row['message_id'] ?? 0) > 0) {
                $message_ids[(int)$attachment_row['message_id']] = (int)$attachment_row['message_id'];
            }
        }

        $where = "`chat_id` = " . (int)$chat_id . " AND `image` = '{$old_e}'";
        if ($message_id > 0) {
            $where .= " AND `message_id` = " . (int)$message_id;
            $message_ids[$message_id] = $message_id;
        }
        $now = $this->db->escape((new AppClock($this->registry))->nowSql());
        $this->db->query(
            "UPDATE `{$this->prefix}messages`
             SET `image` = '{$new_e}', `date_modified` = '{$now}'
             WHERE {$where}"
        );

        if ($message_ids) {
             
             
            $this->db->query(
                "UPDATE `{$this->prefix}messages`
                 SET `date_modified` = '{$now}'
                 WHERE `chat_id` = " . (int)$chat_id . "
                   AND `message_id` IN (" . implode(',', $message_ids) . ")"
            );
        }
    }

    private function metaHasUsableVariants(int $chat_id, array $meta): bool {
        $thumb = (string)($meta['variants']['thumb']['file'] ?? '');
        $preview = (string)($meta['variants']['preview']['file'] ?? '');
        if ($thumb === '' && $preview === '') {
            return false;
        }

        $dir = $this->chatUploadDir($chat_id);
        return ($thumb === '' || is_file($dir . DS . basename($thumb)))
            && ($preview === '' || is_file($dir . DS . basename($preview)));
    }

    private function discoverVariantFiles(int $chat_id, string $filename): array {
        $dir = $this->chatUploadDir($chat_id);
        $result = ['thumb' => '', 'preview' => ''];
        foreach (['webp', 'jpg'] as $format) {
            $thumb = $this->variant_service->variantFilename($filename, 'thumb', $format);
            $preview = $this->variant_service->variantFilename($filename, 'preview', $format);
            if ($result['thumb'] === '' && is_file($dir . DS . $thumb)) {
                $result['thumb'] = $thumb;
            }
            if ($result['preview'] === '' && is_file($dir . DS . $preview)) {
                $result['preview'] = $preview;
            }
        }
        return $result;
    }

    private function queueVariantJob(int $chat_id, string $filename, int $attachment_id): int {
        try {
            $queue = new QueueManager($this->registry);
            return $queue->pushUnique('image_variant_generate', [
                'chat_id' => $chat_id,
                'file_name' => basename($filename),
                'attachment_id' => $attachment_id,
            ], 'image_variant_' . $chat_id . '_' . $attachment_id . '_' . sha1(basename($filename)), 0, 1);
        } catch (\Throwable $e) {
             
             
             
            return 0;
        }
    }

    public function discardPendingUpload(int $chat_id, array $upload): bool {
        $upload_dir = $this->chatUploadDir($chat_id);
        $names = [(string)($upload['filename'] ?? '')];
        foreach ((array)($upload['variants'] ?? []) as $variant) {
            if (is_string($variant)) {
                $names[] = $variant;
            } elseif (is_array($variant)) {
                $names[] = (string)($variant['file'] ?? $variant['filename'] ?? '');
            }
        }
        $removed = true;
        foreach (array_unique($names) as $name) {
            $name = basename($name);
            if ($name === '') {
                continue;
            }
            $path = $upload_dir . DS . $name;
            if (is_file($path) && !SafeRuntime::unlink($path)) {
                $removed = false;
            }
        }
        return $removed;
    }
}
