<?php
/**
 * Helper: Cliente S3 ligero para Wasabi (AWS Signature V4)
 * Sin dependencias externas — solo PHP nativo + curl.
 *
 * @author NICC
 * @date   2026-03-16
 */

class helper_plugin_wasabisync extends \dokuwiki\Extension\Plugin {

    /** @var \helper_plugin_sqlite */
    private $sqlite = null;

    /** @var string wiki_id detectado de DOKU_INC */
    private $wikiId = '';

    /**
     * Inicializa SQLite y detecta wiki_id
     */
    public function __construct() {
        $this->wikiId = $this->detectWikiId();
    }

    // ─── SQLite ───────────────────────────────────────────────

    /**
     * Obtiene instancia SQLite (lazy init)
     * @return \helper_plugin_sqlite|null
     */
    public function getDB() {
        if ($this->sqlite !== null) return $this->sqlite;

        $this->sqlite = plugin_load('helper', 'sqlite');
        if (!$this->sqlite) return null;

        if (!$this->sqlite->init('wasabisync', DOKU_PLUGIN . 'wasabisync/db/')) {
            $this->sqlite = null;
            return null;
        }
        return $this->sqlite;
    }

    /**
     * Registra un archivo subido a Wasabi
     */
    public function dbRegister($mediaId, $wasabiKey, $fileSize, $fileMtime, $mimeType) {
        $db = $this->getDB();
        if (!$db) return false;

        $res = $db->query(
            'INSERT OR REPLACE INTO wasabi_files (media_id, wasabi_key, file_size, file_mtime, mime_type, uploaded_at)
             VALUES (?, ?, ?, ?, ?, ?)',
            [$mediaId, $wasabiKey, $fileSize, $fileMtime, $mimeType, date('c')]
        );

        // Verificar que el registro se inserto/actualizo correctamente
        if ($res === false) return false;

        // Confirmar re-lectura
        $check = $this->dbLookup($mediaId);
        return ($check !== false);
    }

    /**
     * Busca un archivo en la tabla wasabi_files
     * @return array|false
     */
    public function dbLookup($mediaId) {
        $db = $this->getDB();
        if (!$db) return false;

        $res = $db->query('SELECT * FROM wasabi_files WHERE media_id = ?', [$mediaId]);
        $rows = $db->res2arr($res);
        return !empty($rows) ? $rows[0] : false;
    }

    /**
     * Elimina registro de wasabi_files
     */
    public function dbRemove($mediaId) {
        $db = $this->getDB();
        if (!$db) return false;

        $db->query('DELETE FROM wasabi_files WHERE media_id = ?', [$mediaId]);
        return true;
    }

    /**
     * Cuenta total de archivos y espacio liberado
     * @return array ['count' => int, 'size' => int]
     */
    public function dbStats() {
        $db = $this->getDB();
        if (!$db) return ['count' => 0, 'size' => 0];

        $res = $db->query('SELECT COUNT(*) as cnt, COALESCE(SUM(file_size),0) as total FROM wasabi_files');
        $rows = $db->res2arr($res);
        return ['count' => (int)$rows[0]['cnt'], 'size' => (int)$rows[0]['total']];
    }

    // ─── Wiki ID ──────────────────────────────────────────────

    /**
     * Detecta el wiki_id a partir de DOKU_INC
     * Ejemplo: /home/systeng3/public_html/prueba/ → "prueba"
     */
    public function detectWikiId() {
        if (!defined('DOKU_INC')) return 'default';
        $path = rtrim(str_replace('\\', '/', realpath(DOKU_INC) ?: DOKU_INC), '/');
        $parts = explode('/', $path);
        return end($parts) ?: 'default';
    }

    /**
     * @return string
     */
    public function getWikiId() {
        return $this->wikiId;
    }

    // ─── S3 Key ───────────────────────────────────────────────

    /**
     * Convierte media_id de DokuWiki a key de Wasabi
     * Ejemplo: "compartidos:1:formato_1.docx" → "prueba/compartidos/1/formato_1.docx"
     */
    public function mediaIdToKey($mediaId) {
        $path = str_replace(':', '/', cleanID($mediaId));
        return $this->wikiId . '/' . $path;
    }

    // ─── S3 Operations ───────────────────────────────────────

    /**
     * Sube un archivo a Wasabi (streaming — no carga en memoria)
     *
     * Usa CURLOPT_INFILE para enviar el archivo por streaming.
     * Soporta archivos de cualquier tamaño sin agotar memory_limit.
     * El SHA256 se calcula por streaming antes del upload.
     *
     * @param string $localPath  ruta local del archivo
     * @param string $key        key en Wasabi (wiki_id/ns/file)
     * @param string $mimeType   MIME type
     * @return array ['ok' => bool, 'error' => string, 'status' => int]
     */
    public function putObject($localPath, $key, $mimeType = 'application/octet-stream') {
        $bucket   = $this->getConf('wasabi_bucket');
        $region   = $this->getConf('wasabi_region');
        $endpoint = $this->getConf('wasabi_endpoint');
        $accessKey = $this->getConf('wasabi_key');
        $secretKey = $this->getConf('wasabi_secret');

        if (!file_exists($localPath) || !is_readable($localPath)) {
            return ['ok' => false, 'error' => 'No se pudo leer archivo local', 'status' => 0];
        }

        $fileSize = filesize($localPath);

        // Calcular SHA256 por streaming (no carga todo en RAM)
        $contentHash = hash_file('sha256', $localPath);
        if ($contentHash === false) {
            return ['ok' => false, 'error' => 'No se pudo calcular SHA256 del archivo', 'status' => 0];
        }

        $host = $bucket . '.' . $endpoint;
        $encodedKey = $this->encodeKeyForUrl($key);
        $url  = 'https://' . $host . '/' . $encodedKey;
        $date = gmdate('Ymd\THis\Z');
        $dateShort = gmdate('Ymd');

        $headers = [
            'Host'                 => $host,
            'Content-Type'         => $mimeType,
            'Content-Length'       => $fileSize,
            'x-amz-content-sha256' => $contentHash,
            'x-amz-date'           => $date,
        ];

        $canonicalUri = '/' . $this->uriEncode($key, false);
        $authHeader = $this->signV4('PUT', $canonicalUri, '', $headers, $contentHash,
                                     $accessKey, $secretKey, $region, $dateShort, $date);
        $headers['Authorization'] = $authHeader;

        $curlHeaders = [];
        foreach ($headers as $k => $v) {
            $curlHeaders[] = "$k: $v";
        }

        // Abrir archivo para streaming
        $fh = fopen($localPath, 'rb');
        if (!$fh) {
            return ['ok' => false, 'error' => 'No se pudo abrir archivo para lectura', 'status' => 0];
        }

        $ch = curl_init($url);
        curl_setopt_array($ch, [
            CURLOPT_PUT            => true,
            CURLOPT_INFILE         => $fh,
            CURLOPT_INFILESIZE     => $fileSize,
            CURLOPT_HTTPHEADER     => $curlHeaders,
            CURLOPT_RETURNTRANSFER => true,
            CURLOPT_TIMEOUT        => 600, // 10 min para archivos grandes
            CURLOPT_SSL_VERIFYPEER => true,
        ]);

        $response = curl_exec($ch);
        $httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
        $curlError = curl_error($ch);
        curl_close($ch);
        fclose($fh);

        if ($curlError) {
            return ['ok' => false, 'error' => 'curl: ' . $curlError, 'status' => 0];
        }
        if ($httpCode >= 200 && $httpCode < 300) {
            return ['ok' => true, 'error' => '', 'status' => $httpCode];
        }
        return ['ok' => false, 'error' => $this->parseS3Error($response), 'status' => $httpCode];
    }

    /**
     * Elimina un objeto de Wasabi
     */
    public function deleteObject($key) {
        $bucket   = $this->getConf('wasabi_bucket');
        $region   = $this->getConf('wasabi_region');
        $endpoint = $this->getConf('wasabi_endpoint');
        $accessKey = $this->getConf('wasabi_key');
        $secretKey = $this->getConf('wasabi_secret');

        $host = $bucket . '.' . $endpoint;
        $encodedKey = $this->encodeKeyForUrl($key);
        $url  = 'https://' . $host . '/' . $encodedKey;
        $date = gmdate('Ymd\THis\Z');
        $dateShort = gmdate('Ymd');
        $contentHash = hash('sha256', '');

        $headers = [
            'Host'                 => $host,
            'x-amz-content-sha256' => $contentHash,
            'x-amz-date'           => $date,
        ];

        $canonicalUri = '/' . $this->uriEncode($key, false);
        $authHeader = $this->signV4('DELETE', $canonicalUri, '', $headers, $contentHash,
                                     $accessKey, $secretKey, $region, $dateShort, $date);
        $headers['Authorization'] = $authHeader;

        $curlHeaders = [];
        foreach ($headers as $k => $v) {
            $curlHeaders[] = "$k: $v";
        }

        $ch = curl_init($url);
        curl_setopt_array($ch, [
            CURLOPT_CUSTOMREQUEST  => 'DELETE',
            CURLOPT_HTTPHEADER     => $curlHeaders,
            CURLOPT_RETURNTRANSFER => true,
            CURLOPT_TIMEOUT        => 30,
            CURLOPT_SSL_VERIFYPEER => true,
        ]);

        $response = curl_exec($ch);
        $httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
        $curlError = curl_error($ch);
        curl_close($ch);

        if ($curlError) {
            return ['ok' => false, 'error' => 'curl: ' . $curlError, 'status' => 0];
        }
        // DELETE retorna 204 No Content en exito
        if ($httpCode >= 200 && $httpCode < 300) {
            return ['ok' => true, 'error' => '', 'status' => $httpCode];
        }
        return ['ok' => false, 'error' => $this->parseS3Error($response), 'status' => $httpCode];
    }

    /**
     * Verifica si un objeto existe en Wasabi (HEAD)
     * @return array ['exists' => bool, 'size' => int, 'error' => string]
     */
    public function headObject($key) {
        $bucket   = $this->getConf('wasabi_bucket');
        $region   = $this->getConf('wasabi_region');
        $endpoint = $this->getConf('wasabi_endpoint');
        $accessKey = $this->getConf('wasabi_key');
        $secretKey = $this->getConf('wasabi_secret');

        $host = $bucket . '.' . $endpoint;
        $encodedKey = $this->encodeKeyForUrl($key);
        $url  = 'https://' . $host . '/' . $encodedKey;
        $date = gmdate('Ymd\THis\Z');
        $dateShort = gmdate('Ymd');
        $contentHash = hash('sha256', '');

        $headers = [
            'Host'                 => $host,
            'x-amz-content-sha256' => $contentHash,
            'x-amz-date'           => $date,
        ];

        $canonicalUri = '/' . $this->uriEncode($key, false);
        $authHeader = $this->signV4('HEAD', $canonicalUri, '', $headers, $contentHash,
                                     $accessKey, $secretKey, $region, $dateShort, $date);
        $headers['Authorization'] = $authHeader;

        $curlHeaders = [];
        foreach ($headers as $k => $v) {
            $curlHeaders[] = "$k: $v";
        }

        $ch = curl_init($url);
        curl_setopt_array($ch, [
            CURLOPT_CUSTOMREQUEST  => 'HEAD',
            CURLOPT_HTTPHEADER     => $curlHeaders,
            CURLOPT_RETURNTRANSFER => true,
            CURLOPT_NOBODY         => true,
            CURLOPT_HEADER         => true,
            CURLOPT_TIMEOUT        => 15,
            CURLOPT_SSL_VERIFYPEER => true,
        ]);

        $response = curl_exec($ch);
        $httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
        $size = curl_getinfo($ch, CURLINFO_CONTENT_LENGTH_DOWNLOAD);
        $curlError = curl_error($ch);
        curl_close($ch);

        if ($curlError) {
            return ['exists' => false, 'size' => 0, 'error' => 'curl: ' . $curlError];
        }

        if ($httpCode === 200) {
            // CURLINFO_CONTENT_LENGTH_DOWNLOAD retorna float, -1.0 si no hay Content-Length
            $sizeInt = ($size !== false && $size >= 0) ? (int)$size : 0;
            return ['exists' => true, 'size' => $sizeInt, 'error' => ''];
        }
        return ['exists' => false, 'size' => 0, 'error' => "HTTP $httpCode"];
    }

    /**
     * Genera URL pre-firmada para descarga directa
     *
     * @param string $key      key en Wasabi
     * @param int    $expiry   segundos de validez (default: config url_expiry)
     * @return string URL firmada
     */
    public function getPresignedUrl($key, $expiry = 0) {
        $bucket   = $this->getConf('wasabi_bucket');
        $region   = $this->getConf('wasabi_region');
        $endpoint = $this->getConf('wasabi_endpoint');
        $accessKey = $this->getConf('wasabi_key');
        $secretKey = $this->getConf('wasabi_secret');

        if ($expiry <= 0) {
            $expiry = (int)$this->getConf('url_expiry') ?: 3600;
        }

        $host = $bucket . '.' . $endpoint;
        $date = gmdate('Ymd\THis\Z');
        $dateShort = gmdate('Ymd');
        $scope = $dateShort . '/' . $region . '/s3/aws4_request';

        // Query string para pre-signed URL
        $queryParams = [
            'X-Amz-Algorithm'     => 'AWS4-HMAC-SHA256',
            'X-Amz-Credential'    => $accessKey . '/' . $scope,
            'X-Amz-Date'          => $date,
            'X-Amz-Expires'       => $expiry,
            'X-Amz-SignedHeaders' => 'host',
        ];
        ksort($queryParams);

        $queryString = http_build_query($queryParams, '', '&', PHP_QUERY_RFC3986);

        // Canonical request
        $canonicalUri = '/' . $this->uriEncode($key, false);
        $canonicalHeaders = "host:$host\n";
        $signedHeaders = 'host';

        $canonicalRequest = "GET\n" .
            $canonicalUri . "\n" .
            $queryString . "\n" .
            $canonicalHeaders . "\n" .
            $signedHeaders . "\n" .
            "UNSIGNED-PAYLOAD";

        // String to sign
        $stringToSign = "AWS4-HMAC-SHA256\n$date\n$scope\n" . hash('sha256', $canonicalRequest);

        // Signing key
        $signingKey = $this->getSigningKey($secretKey, $dateShort, $region);

        // Signature
        $signature = hash_hmac('sha256', $stringToSign, $signingKey);

        return 'https://' . $host . $canonicalUri . '?' . $queryString . '&X-Amz-Signature=' . $signature;
    }

    /**
     * Descarga un archivo de Wasabi a un archivo temporal
     * Usado por ia_engine para procesar archivos que estan en Wasabi
     *
     * @param string $mediaId   media_id DokuWiki
     * @return string|false     ruta del archivo temporal, o false si falla
     */
    public function downloadToTemp($mediaId) {
        $record = $this->dbLookup($mediaId);
        if (!$record) return false;

        $url = $this->getPresignedUrl($record['wasabi_key'], 300);

        // Crear archivo temporal con extension correcta
        $ext = pathinfo($mediaId, PATHINFO_EXTENSION);
        $tmpBase = tempnam(sys_get_temp_dir(), 'wasabi_');
        if ($tmpBase === false) return false;
        $tmpFile = $tmpBase . '.' . $ext;
        if (!@rename($tmpBase, $tmpFile)) {
            // Si rename falla, usar el archivo original sin extension
            $tmpFile = $tmpBase;
        }

        $ch = curl_init($url);
        $fp = fopen($tmpFile, 'wb');
        curl_setopt_array($ch, [
            CURLOPT_FILE           => $fp,
            CURLOPT_FOLLOWLOCATION => true,
            CURLOPT_TIMEOUT        => 120,
            CURLOPT_SSL_VERIFYPEER => true,
        ]);

        curl_exec($ch);
        $httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
        $curlError = curl_error($ch);
        curl_close($ch);
        fclose($fp);

        if ($curlError || $httpCode !== 200) {
            @unlink($tmpFile);
            return false;
        }
        return $tmpFile;
    }

    // ─── S3 List & Key Reverse ─────────────────────────────────

    /**
     * Lista objetos en Wasabi con prefijo dado (ListObjectsV2)
     * Soporta paginacion automatica (ContinuationToken)
     *
     * @param string $prefix    prefijo para filtrar (ej: "prueba/")
     * @param int    $maxKeys   maximo por pagina (default 1000)
     * @return array ['ok' => bool, 'objects' => [['key'=>..,'size'=>..,'lastModified'=>..]], 'error' => string]
     */
    public function listObjects($prefix = '', $maxKeys = 1000) {
        $bucket   = $this->getConf('wasabi_bucket');
        $region   = $this->getConf('wasabi_region');
        $endpoint = $this->getConf('wasabi_endpoint');
        $accessKey = $this->getConf('wasabi_key');
        $secretKey = $this->getConf('wasabi_secret');

        $host = $bucket . '.' . $endpoint;
        $allObjects = [];
        $continuationToken = '';
        $truncated = true;

        while ($truncated) {
            $date = gmdate('Ymd\THis\Z');
            $dateShort = gmdate('Ymd');
            $contentHash = hash('sha256', '');

            // Query params para ListObjectsV2
            $queryParams = [
                'list-type' => '2',
                'prefix'    => $prefix,
                'max-keys'  => $maxKeys,
            ];
            if ($continuationToken) {
                $queryParams['continuation-token'] = $continuationToken;
            }
            ksort($queryParams);
            $queryString = http_build_query($queryParams, '', '&', PHP_QUERY_RFC3986);

            $headers = [
                'Host'                 => $host,
                'x-amz-content-sha256' => $contentHash,
                'x-amz-date'           => $date,
            ];

            $authHeader = $this->signV4('GET', '/', $queryString, $headers, $contentHash,
                                         $accessKey, $secretKey, $region, $dateShort, $date);
            $headers['Authorization'] = $authHeader;

            $curlHeaders = [];
            foreach ($headers as $k => $v) {
                $curlHeaders[] = "$k: $v";
            }

            $url = 'https://' . $host . '/?' . $queryString;

            $ch = curl_init($url);
            curl_setopt_array($ch, [
                CURLOPT_HTTPGET        => true,
                CURLOPT_HTTPHEADER     => $curlHeaders,
                CURLOPT_RETURNTRANSFER => true,
                CURLOPT_TIMEOUT        => 60,
                CURLOPT_SSL_VERIFYPEER => true,
            ]);

            $response = curl_exec($ch);
            $httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
            $curlError = curl_error($ch);
            curl_close($ch);

            if ($curlError) {
                return ['ok' => false, 'objects' => $allObjects, 'error' => 'curl: ' . $curlError];
            }
            if ($httpCode < 200 || $httpCode >= 300) {
                return ['ok' => false, 'objects' => $allObjects, 'error' => $this->parseS3Error($response)];
            }

            // Parsear XML de respuesta
            $xml = @simplexml_load_string($response);
            if (!$xml) {
                return ['ok' => false, 'objects' => $allObjects, 'error' => 'XML invalido en respuesta'];
            }

            // Namespace handling (S3 responses have xmlns)
            $ns = $xml->getNamespaces();
            $nsUri = reset($ns) ?: '';

            if ($nsUri) {
                $xml->registerXPathNamespace('s3', $nsUri);
                $contents = $xml->xpath('//s3:Contents');
            } else {
                $contents = $xml->Contents;
            }

            if ($contents) {
                foreach ($contents as $item) {
                    $key = (string)$item->Key;
                    // Ignorar "directorios" (keys que terminan en /)
                    if (substr($key, -1) === '/') continue;

                    $allObjects[] = [
                        'key'          => $key,
                        'size'         => (int)(string)$item->Size,
                        'lastModified' => (string)$item->LastModified,
                    ];
                }
            }

            // Verificar si hay mas paginas
            $isTruncated = '';
            if ($nsUri) {
                $truncNodes = $xml->xpath('//s3:IsTruncated');
                $isTruncated = $truncNodes ? (string)$truncNodes[0] : 'false';
            } else {
                $isTruncated = (string)$xml->IsTruncated;
            }

            if (strtolower($isTruncated) === 'true') {
                if ($nsUri) {
                    $tokenNodes = $xml->xpath('//s3:NextContinuationToken');
                    $continuationToken = $tokenNodes ? (string)$tokenNodes[0] : '';
                } else {
                    $continuationToken = (string)$xml->NextContinuationToken;
                }
                if (!$continuationToken) {
                    $truncated = false; // Safety: no token = no more pages
                }
            } else {
                $truncated = false;
            }
        }

        return ['ok' => true, 'objects' => $allObjects, 'error' => ''];
    }

    /**
     * Convierte key de Wasabi a media_id de DokuWiki (inverso de mediaIdToKey)
     * Ejemplo: "prueba/compartidos/1/formato_1.docx" → "compartidos:1:formato_1.docx"
     *
     * @param string $key  key de Wasabi
     * @return string media_id o '' si no es de este wiki
     */
    public function keyToMediaId($key) {
        $prefix = $this->wikiId . '/';
        if (strpos($key, $prefix) !== 0) return '';

        $relative = substr($key, strlen($prefix));
        return str_replace('/', ':', $relative);
    }

    // ─── Verificacion de Integridad ─────────────────────────

    /**
     * Verifica la integridad de UN archivo: compara SQLite vs Wasabi real (HEAD)
     *
     * @param string $mediaId   media_id del archivo
     * @param int    $localSize tamaño original del archivo local (si aun tiene contenido)
     * @return array [
     *   'status'       => 'verified'|'missing_db'|'missing_s3'|'size_mismatch'|'error',
     *   'db_size'      => int,     // tamaño registrado en SQLite
     *   'wasabi_size'  => int,     // tamaño real en Wasabi (HEAD)
     *   'local_size'   => int,     // tamaño local actual
     *   'detail'       => string,  // descripcion del resultado
     * ]
     */
    public function verifyFileIntegrity($mediaId, $localSize = 0) {
        $result = [
            'status'      => 'error',
            'db_size'     => 0,
            'wasabi_size' => 0,
            'local_size'  => $localSize,
            'detail'      => '',
        ];

        // 1. Verificar en SQLite
        $record = $this->dbLookup($mediaId);
        if (!$record) {
            $result['status'] = 'missing_db';
            $result['detail'] = 'No registrado en SQLite';
            return $result;
        }
        $result['db_size'] = (int)$record['file_size'];

        // 2. Verificar en Wasabi con HEAD
        $head = $this->headObject($record['wasabi_key']);
        if (!$head['exists']) {
            $result['status'] = 'missing_s3';
            $result['detail'] = 'Registrado en SQLite pero NO existe en Wasabi (HEAD fallo)';
            return $result;
        }
        $result['wasabi_size'] = (int)$head['size'];

        // 3. Comparar tamaños (SQLite vs Wasabi)
        if ($result['wasabi_size'] > 0 && $result['db_size'] > 0) {
            if ($result['wasabi_size'] !== $result['db_size']) {
                $result['status'] = 'size_mismatch';
                $result['detail'] = sprintf(
                    'Tamaño no coincide: SQLite=%s, Wasabi=%s',
                    $this->formatSize($result['db_size']),
                    $this->formatSize($result['wasabi_size'])
                );
                return $result;
            }
        }

        // 4. Todo OK — archivo existe en Wasabi
        $result['status'] = 'verified';
        if ($result['wasabi_size'] > 0) {
            $result['detail'] = sprintf('OK - %s en Wasabi (tamaño verificado)', $this->formatSize($result['wasabi_size']));
        } else {
            // HEAD no retorno Content-Length; al menos existe (HTTP 200)
            $result['detail'] = sprintf('OK - existe en Wasabi (sin Content-Length, tamaño SQLite: %s)', $this->formatSize($result['db_size']));
        }
        return $result;
    }

    /**
     * Verificacion masiva de integridad: compara TODOS los archivos locales contra Wasabi
     *
     * @param array $localFiles  array de ['path'=>..,'media_id'=>..,'size'=>..] (de scanMediaFiles)
     * @param callable|null $progressCallback  fn($current, $total, $mediaId) para progreso
     * @return array [
     *   'verified'       => int,     // archivos confirmados en Wasabi con tamaño correcto
     *   'missing_db'     => int,     // archivos locales sin registro en SQLite
     *   'missing_s3'     => int,     // registrados en SQLite pero NO en Wasabi
     *   'size_mismatch'  => int,     // tamaño diferente entre SQLite y Wasabi
     *   'errors'         => int,     // errores de red/conexion
     *   'stubs'          => int,     // archivos ya truncados (0 bytes)
     *   'total_checked'  => int,
     *   'total_verified_size' => int,
     *   'details'        => array,   // detalles de archivos problematicos
     *   'safe_to_truncate' => bool,  // true SOLO si todos los archivos con contenido estan verificados
     * ]
     */
    public function verifyBulkIntegrity($localFiles, $progressCallback = null) {
        $summary = [
            'verified'            => 0,
            'missing_db'          => 0,
            'missing_s3'          => 0,
            'size_mismatch'       => 0,
            'errors'              => 0,
            'stubs'               => 0,
            'total_checked'       => 0,
            'total_verified_size' => 0,
            'details'             => [],
            'safe_to_truncate'    => false,
        ];

        $total = count($localFiles);
        $filesWithContent = 0;

        foreach ($localFiles as $i => $f) {
            // Reportar progreso
            if ($progressCallback) {
                $progressCallback($i + 1, $total, $f['media_id']);
            }

            // Archivos ya truncados (stubs) no necesitan verificacion
            if ($f['size'] <= 0) {
                $summary['stubs']++;
                continue;
            }

            $filesWithContent++;
            $summary['total_checked']++;

            $check = $this->verifyFileIntegrity($f['media_id'], $f['size']);

            switch ($check['status']) {
                case 'verified':
                    $summary['verified']++;
                    $summary['total_verified_size'] += $check['wasabi_size'] ?: $check['db_size'];
                    break;

                case 'missing_db':
                    $summary['missing_db']++;
                    $summary['details'][] = [
                        'media_id' => $f['media_id'],
                        'status'   => 'missing_db',
                        'size'     => $f['size'],
                        'detail'   => $check['detail'],
                    ];
                    break;

                case 'missing_s3':
                    $summary['missing_s3']++;
                    $summary['details'][] = [
                        'media_id' => $f['media_id'],
                        'status'   => 'missing_s3',
                        'size'     => $f['size'],
                        'detail'   => $check['detail'],
                    ];
                    break;

                case 'size_mismatch':
                    $summary['size_mismatch']++;
                    $summary['details'][] = [
                        'media_id'    => $f['media_id'],
                        'status'      => 'size_mismatch',
                        'size'        => $f['size'],
                        'db_size'     => $check['db_size'],
                        'wasabi_size' => $check['wasabi_size'],
                        'detail'      => $check['detail'],
                    ];
                    break;

                default:
                    $summary['errors']++;
                    $summary['details'][] = [
                        'media_id' => $f['media_id'],
                        'status'   => 'error',
                        'size'     => $f['size'],
                        'detail'   => $check['detail'],
                    ];
                    break;
            }
        }

        // Es seguro truncar si:
        // - Los archivos que ESTÁN en SQLite fueron verificados en Wasabi (sin missing_s3 ni size_mismatch)
        // - missing_db = archivos que NO se han subido aun, el re-truncar los ignora (no bloquean)
        // - CRITICO: missing_s3 y size_mismatch SI bloquean (datos potencialmente perdidos)
        $summary['safe_to_truncate'] = (
            $summary['verified'] > 0 &&
            $summary['missing_s3'] === 0 &&
            $summary['size_mismatch'] === 0 &&
            $summary['errors'] === 0
        );

        return $summary;
    }

    // ─── Utilidades ───────────────────────────────────────────

    /**
     * Verifica si el plugin esta habilitado y configurado
     * @return bool
     */
    public function isReady() {
        return $this->getConf('enabled')
            && $this->getConf('wasabi_key')
            && $this->getConf('wasabi_secret')
            && $this->getConf('wasabi_bucket');
    }

    /**
     * Formatea bytes a formato legible
     */
    public function formatSize($bytes) {
        $units = ['B', 'KB', 'MB', 'GB', 'TB'];
        $i = 0;
        while ($bytes >= 1024 && $i < count($units) - 1) {
            $bytes /= 1024;
            $i++;
        }
        return round($bytes, 2) . ' ' . $units[$i];
    }

    // ─── AWS Signature V4 ─────────────────────────────────────

    /**
     * Genera el header Authorization con AWS Signature V4
     */
    private function signV4($method, $uri, $queryString, $headers, $payloadHash,
                            $accessKey, $secretKey, $region, $dateShort, $dateLong) {
        $service = 's3';
        $scope = "$dateShort/$region/$service/aws4_request";

        // Canonical headers (sorted, lowercase)
        $canonicalHeaders = '';
        $signedHeadersList = [];
        $sortedHeaders = [];
        foreach ($headers as $k => $v) {
            $sortedHeaders[strtolower($k)] = trim($v);
        }
        ksort($sortedHeaders);
        foreach ($sortedHeaders as $k => $v) {
            $canonicalHeaders .= "$k:$v\n";
            $signedHeadersList[] = $k;
        }
        $signedHeaders = implode(';', $signedHeadersList);

        // Canonical request ($uri ya viene pre-encoded desde los métodos que llaman)
        $canonicalRequest = "$method\n" .
            $uri . "\n" .
            $queryString . "\n" .
            $canonicalHeaders . "\n" .
            $signedHeaders . "\n" .
            $payloadHash;

        // String to sign
        $stringToSign = "AWS4-HMAC-SHA256\n$dateLong\n$scope\n" . hash('sha256', $canonicalRequest);

        // Signing key
        $signingKey = $this->getSigningKey($secretKey, $dateShort, $region);

        // Signature
        $signature = hash_hmac('sha256', $stringToSign, $signingKey);

        return "AWS4-HMAC-SHA256 Credential=$accessKey/$scope, SignedHeaders=$signedHeaders, Signature=$signature";
    }

    /**
     * Deriva la signing key para AWS Sig V4
     */
    private function getSigningKey($secretKey, $dateShort, $region) {
        $kDate    = hash_hmac('sha256', $dateShort, 'AWS4' . $secretKey, true);
        $kRegion  = hash_hmac('sha256', $region, $kDate, true);
        $kService = hash_hmac('sha256', 's3', $kRegion, true);
        $kSigning = hash_hmac('sha256', 'aws4_request', $kService, true);
        return $kSigning;
    }

    /**
     * URI encode para S3 (RFC 3986)
     * Usa rawurlencode para manejar correctamente caracteres UTF-8 multibyte
     */
    private function uriEncode($str, $encodeSlash = true) {
        // Dividir por / para preservar slashes si se pide
        if (!$encodeSlash) {
            return implode('/', array_map('rawurlencode', explode('/', $str)));
        }
        return rawurlencode($str);
    }

    /**
     * Codifica cada segmento del key para usar en URL de curl
     * Ejemplo: "prueba/ns/archivo con espacio.pdf" → "prueba/ns/archivo%20con%20espacio.pdf"
     */
    private function encodeKeyForUrl($key) {
        return implode('/', array_map('rawurlencode', explode('/', $key)));
    }

    /**
     * Extrae mensaje de error de respuesta XML de S3
     */
    private function parseS3Error($xml) {
        if (empty($xml)) return 'Respuesta vacia';
        if (preg_match('/<Message>(.*?)<\/Message>/s', $xml, $m)) {
            return $m[1];
        }
        if (preg_match('/<Code>(.*?)<\/Code>/s', $xml, $m)) {
            return $m[1];
        }
        return substr($xml, 0, 200);
    }
}
