<?php
/**
 * Funciones compartidas - Backup a Wasabi S3 v2.0
 * =================================================
 * Usado por aplicar.php (UI web) y backup_cron.php (CLI).
 *
 * Las funciones S3 (wasabi_sign_v4, wasabi_put, wasabi_list,
 * wasabi_delete_bulk) son las mismas probadas de v1.x.
 *
 * Nuevas funciones para v2.0:
 * - acquireLockWithTimeout() - lock con auto-expiracion
 * - getBackupFilesList() - retorna lista serializable para AJAX
 * - uploadBatch() - sube un lote de N archivos
 * - cleanupWiki() - borra obsoletos de Wasabi para un wiki
 */

require_once __DIR__ . '/config.php';

// ============================================================
// AWS SIGNATURE V4
// ============================================================

function wasabi_sign_v4($method, $uri, $queryString, $headers, $payloadHash,
                        $accessKey, $secretKey, $region, $dateShort, $dateLong) {
    $service = 's3';
    $scope = "$dateShort/$region/$service/aws4_request";
    ksort($headers);
    $signedHeadersList = implode(';', array_keys($headers));
    $canonicalHeaders = '';
    foreach ($headers as $k => $v) {
        $canonicalHeaders .= $k . ':' . trim($v) . "\n";
    }
    $canonicalRequest = "$method\n$uri\n$queryString\n$canonicalHeaders\n$signedHeadersList\n$payloadHash";
    $stringToSign = "AWS4-HMAC-SHA256\n$dateLong\n$scope\n" . hash('sha256', $canonicalRequest);
    $kDate    = hash_hmac('sha256', $dateShort, 'AWS4' . $secretKey, true);
    $kRegion  = hash_hmac('sha256', $region, $kDate, true);
    $kService = hash_hmac('sha256', $service, $kRegion, true);
    $kSigning = hash_hmac('sha256', 'aws4_request', $kService, true);
    $signature = hash_hmac('sha256', $stringToSign, $kSigning);
    return "AWS4-HMAC-SHA256 Credential=$accessKey/$scope, SignedHeaders=$signedHeadersList, Signature=$signature";
}

function rawurlencode_path($path) {
    return implode('/', array_map('rawurlencode', explode('/', $path)));
}

/**
 * Construye query string ordenado alfabeticamente (requerido por AWS Sig V4)
 */
function build_sorted_qs($params) {
    ksort($params);
    $parts = [];
    foreach ($params as $k => $v) {
        $parts[] = rawurlencode($k) . '=' . rawurlencode($v);
    }
    return implode('&', $parts);
}

// ============================================================
// S3 OPERATIONS
// ============================================================

/**
 * PUT object a Wasabi (con retry) - v2.1 usa UNSIGNED-PAYLOAD para velocidad
 */
function wasabi_put($key, $localPath, $mimeType = 'application/octet-stream', $maxRetries = 3) {
    $bucket = WASABI_BUCKET;
    $region = WASABI_REGION;
    $host   = $bucket . '.s3.' . $region . '.wasabisys.com';
    $uri    = '/' . rawurlencode_path($key);

    $fh = @fopen($localPath, 'rb');
    if ($fh === false) {
        return ['ok' => false, 'status' => 0, 'error' => 'No se pudo abrir archivo: ' . $localPath];
    }

    $fileSize    = filesize($localPath);
    // UNSIGNED-PAYLOAD evita leer el archivo completo para SHA256 (gran ahorro en archivos grandes)
    $payloadHash = 'UNSIGNED-PAYLOAD';

    $lastError = '';
    $httpCode  = 0;
    for ($attempt = 1; $attempt <= $maxRetries; $attempt++) {
        $dateLong  = gmdate('Ymd\THis\Z');
        $dateShort = gmdate('Ymd');

        $headers = [
            'content-length'       => (string)$fileSize,
            'content-type'         => $mimeType,
            'host'                 => $host,
            'x-amz-content-sha256' => $payloadHash,
            'x-amz-date'          => $dateLong,
        ];

        $authHeader = wasabi_sign_v4('PUT', $uri, '', $headers, $payloadHash,
            WASABI_ACCESS_KEY, WASABI_SECRET_KEY, $region, $dateShort, $dateLong);

        rewind($fh);
        $ch = curl_init('https://' . $host . $uri);
        curl_setopt_array($ch, [
            CURLOPT_PUT            => true,
            CURLOPT_INFILE         => $fh,
            CURLOPT_INFILESIZE     => $fileSize,
            CURLOPT_HTTPHEADER     => [
                'Authorization: ' . $authHeader,
                'Content-Length: ' . $fileSize,
                'Content-Type: ' . $mimeType,
                'Host: ' . $host,
                'x-amz-content-sha256: ' . $payloadHash,
                'x-amz-date: ' . $dateLong,
            ],
            CURLOPT_RETURNTRANSFER => true,
            CURLOPT_TIMEOUT        => 120,
            CURLOPT_CONNECTTIMEOUT => 15,
            CURLOPT_LOW_SPEED_LIMIT => 1024,
            CURLOPT_LOW_SPEED_TIME  => 30,
        ]);

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

        if ($httpCode >= 200 && $httpCode < 300) {
            fclose($fh);
            return ['ok' => true, 'status' => $httpCode, 'error' => ''];
        }

        $lastError = $error ?: $response;
        if ($attempt < $maxRetries) {
            usleep(500000 * $attempt); // Backoff: 0.5s, 1s (mas rapido)
        }
    }

    fclose($fh);
    return ['ok' => false, 'status' => $httpCode, 'error' => $lastError];
}

/**
 * Prepara un curl handle para PUT sin ejecutarlo (para curl_multi)
 */
function wasabi_put_prepare($key, $localPath, $mimeType = 'application/octet-stream') {
    $bucket = WASABI_BUCKET;
    $region = WASABI_REGION;
    $host   = $bucket . '.s3.' . $region . '.wasabisys.com';
    $uri    = '/' . rawurlencode_path($key);

    $fh = @fopen($localPath, 'rb');
    if ($fh === false) {
        return false;
    }

    $fileSize    = filesize($localPath);
    $payloadHash = 'UNSIGNED-PAYLOAD';
    $dateLong    = gmdate('Ymd\THis\Z');
    $dateShort   = gmdate('Ymd');

    $headers = [
        'content-length'       => (string)$fileSize,
        'content-type'         => $mimeType,
        'host'                 => $host,
        'x-amz-content-sha256' => $payloadHash,
        'x-amz-date'          => $dateLong,
    ];

    $authHeader = wasabi_sign_v4('PUT', $uri, '', $headers, $payloadHash,
        WASABI_ACCESS_KEY, WASABI_SECRET_KEY, $region, $dateShort, $dateLong);

    $ch = curl_init('https://' . $host . $uri);
    curl_setopt_array($ch, [
        CURLOPT_PUT            => true,
        CURLOPT_INFILE         => $fh,
        CURLOPT_INFILESIZE     => $fileSize,
        CURLOPT_HTTPHEADER     => [
            'Authorization: ' . $authHeader,
            'Content-Length: ' . $fileSize,
            'Content-Type: ' . $mimeType,
            'Host: ' . $host,
            'x-amz-content-sha256: ' . $payloadHash,
            'x-amz-date: ' . $dateLong,
        ],
        CURLOPT_RETURNTRANSFER => true,
        CURLOPT_TIMEOUT        => 120,
        CURLOPT_CONNECTTIMEOUT => 15,
        CURLOPT_LOW_SPEED_LIMIT => 1024,
        CURLOPT_LOW_SPEED_TIME  => 30,
    ]);

    return ['ch' => $ch, 'fh' => $fh, 'key' => $key];
}

/**
 * LIST objects de Wasabi con prefix (paginacion con timestamps frescos)
 */
function wasabi_list($prefix, $maxKeys = 1000) {
    $bucket = WASABI_BUCKET;
    $region = WASABI_REGION;
    $host   = $bucket . '.s3.' . $region . '.wasabisys.com';

    $allKeys = [];
    $continuationToken = '';

    do {
        // Timestamps frescos por cada pagina
        $dateLong  = gmdate('Ymd\THis\Z');
        $dateShort = gmdate('Ymd');
        $payloadHash = hash('sha256', '');

        $qsParams = [
            'list-type' => '2',
            'max-keys'  => (string)$maxKeys,
            'prefix'    => $prefix,
        ];
        if ($continuationToken) {
            $qsParams['continuation-token'] = $continuationToken;
        }
        $qs = build_sorted_qs($qsParams);

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

        $authHeader = wasabi_sign_v4('GET', '/', $qs, $headers, $payloadHash,
            WASABI_ACCESS_KEY, WASABI_SECRET_KEY, $region, $dateShort, $dateLong);

        $ch = curl_init('https://' . $host . '/?' . $qs);
        curl_setopt_array($ch, [
            CURLOPT_HTTPHEADER     => [
                'Authorization: ' . $authHeader,
                'Host: ' . $host,
                'x-amz-content-sha256: ' . $payloadHash,
                'x-amz-date: ' . $dateLong,
            ],
            CURLOPT_RETURNTRANSFER => true,
            CURLOPT_TIMEOUT        => 60,
            CURLOPT_CONNECTTIMEOUT => 15,
        ]);
        $response = curl_exec($ch);
        $httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
        curl_close($ch);

        if (!$response || $httpCode >= 300) break;

        // Parsear XML con SimpleXML (mas robusto que regex)
        $xml = @simplexml_load_string($response);
        if ($xml === false) break;

        // Namespace handling
        $ns = $xml->getNamespaces(true);
        if (!empty($ns)) {
            $xml->registerXPathNamespace('s3', reset($ns));
            $keys = $xml->xpath('//s3:Key');
        } else {
            $keys = $xml->xpath('//Key');
        }

        if ($keys) {
            foreach ($keys as $k) {
                $allKeys[] = (string)$k;
            }
        }

        // Paginacion
        $continuationToken = '';
        if (isset($xml->NextContinuationToken)) {
            $continuationToken = (string)$xml->NextContinuationToken;
        } elseif (!empty($ns)) {
            $nct = $xml->xpath('//s3:NextContinuationToken');
            if ($nct) $continuationToken = (string)$nct[0];
        }
    } while ($continuationToken);

    return $allKeys;
}

/**
 * LIST objects con tamaños (key => size). Permite comparar con archivos locales
 * para determinar cuales ya estan subidos y cuales necesitan re-subir.
 */
function wasabi_list_with_sizes($prefix, $maxKeys = 1000) {
    $bucket = WASABI_BUCKET;
    $region = WASABI_REGION;
    $host   = $bucket . '.s3.' . $region . '.wasabisys.com';

    $allKeys = []; // key => size
    $continuationToken = '';

    do {
        $dateLong  = gmdate('Ymd\THis\Z');
        $dateShort = gmdate('Ymd');
        $payloadHash = hash('sha256', '');

        $qsParams = [
            'list-type' => '2',
            'max-keys'  => (string)$maxKeys,
            'prefix'    => $prefix,
        ];
        if ($continuationToken) {
            $qsParams['continuation-token'] = $continuationToken;
        }
        $qs = build_sorted_qs($qsParams);

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

        $authHeader = wasabi_sign_v4('GET', '/', $qs, $headers, $payloadHash,
            WASABI_ACCESS_KEY, WASABI_SECRET_KEY, $region, $dateShort, $dateLong);

        $ch = curl_init('https://' . $host . '/?' . $qs);
        curl_setopt_array($ch, [
            CURLOPT_HTTPHEADER     => [
                'Authorization: ' . $authHeader,
                'Host: ' . $host,
                'x-amz-content-sha256: ' . $payloadHash,
                'x-amz-date: ' . $dateLong,
            ],
            CURLOPT_RETURNTRANSFER => true,
            CURLOPT_TIMEOUT        => 60,
            CURLOPT_CONNECTTIMEOUT => 15,
        ]);
        $response = curl_exec($ch);
        $httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
        curl_close($ch);

        if (!$response || $httpCode >= 300) break;

        $xml = @simplexml_load_string($response);
        if ($xml === false) break;

        $ns = $xml->getNamespaces(true);
        if (!empty($ns)) {
            $xml->registerXPathNamespace('s3', reset($ns));
            $contents = $xml->xpath('//s3:Contents');
        } else {
            $contents = $xml->xpath('//Contents');
        }

        if ($contents) {
            foreach ($contents as $item) {
                if (!empty($ns)) {
                    $item->registerXPathNamespace('s3', reset($ns));
                    $keyNodes = $item->xpath('s3:Key');
                    $sizeNodes = $item->xpath('s3:Size');
                } else {
                    $keyNodes = $item->xpath('Key');
                    $sizeNodes = $item->xpath('Size');
                }
                if ($keyNodes && $sizeNodes) {
                    $allKeys[(string)$keyNodes[0]] = (int)(string)$sizeNodes[0];
                }
            }
        }

        $continuationToken = '';
        if (isset($xml->NextContinuationToken)) {
            $continuationToken = (string)$xml->NextContinuationToken;
        } elseif (!empty($ns)) {
            $nct = $xml->xpath('//s3:NextContinuationToken');
            if ($nct) $continuationToken = (string)$nct[0];
        }
    } while ($continuationToken);

    return $allKeys;
}

/**
 * Bulk delete (hasta 1000 keys por request, con verificacion de errores)
 */
function wasabi_delete_bulk($keys) {
    if (empty($keys)) return ['ok' => true, 'deleted' => 0, 'errors' => 0];

    $bucket = WASABI_BUCKET;
    $region = WASABI_REGION;
    $host   = $bucket . '.s3.' . $region . '.wasabisys.com';

    $totalDeleted = 0;
    $totalErrors  = 0;
    $chunks = array_chunk($keys, 1000);

    foreach ($chunks as $chunk) {
        $dateLong  = gmdate('Ymd\THis\Z');
        $dateShort = gmdate('Ymd');

        $xml = '<?xml version="1.0" encoding="UTF-8"?><Delete><Quiet>true</Quiet>';
        foreach ($chunk as $key) {
            $xml .= '<Object><Key>' . htmlspecialchars($key, ENT_XML1) . '</Key></Object>';
        }
        $xml .= '</Delete>';

        $payloadHash = hash('sha256', $xml);
        $md5 = base64_encode(md5($xml, true));

        $headers = [
            'content-md5'          => $md5,
            'content-type'         => 'application/xml',
            'host'                 => $host,
            'x-amz-content-sha256' => $payloadHash,
            'x-amz-date'          => $dateLong,
        ];

        $authHeader = wasabi_sign_v4('POST', '/', 'delete=', $headers, $payloadHash,
            WASABI_ACCESS_KEY, WASABI_SECRET_KEY, $region, $dateShort, $dateLong);

        $ch = curl_init('https://' . $host . '/?delete=');
        curl_setopt_array($ch, [
            CURLOPT_POST           => true,
            CURLOPT_POSTFIELDS     => $xml,
            CURLOPT_HTTPHEADER     => [
                'Authorization: ' . $authHeader,
                'Content-MD5: ' . $md5,
                'Content-Type: application/xml',
                'Host: ' . $host,
                'x-amz-content-sha256: ' . $payloadHash,
                'x-amz-date: ' . $dateLong,
                'Content-Length: ' . strlen($xml),
            ],
            CURLOPT_RETURNTRANSFER => true,
            CURLOPT_TIMEOUT        => 120,
            CURLOPT_CONNECTTIMEOUT => 15,
        ]);
        $response = curl_exec($ch);
        $httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
        curl_close($ch);

        if ($httpCode >= 200 && $httpCode < 300) {
            // Verificar errores en la respuesta
            if (strpos($response, '<Error>') !== false) {
                $errCount = substr_count($response, '<Error>');
                $totalErrors += $errCount;
                $totalDeleted += (count($chunk) - $errCount);
            } else {
                $totalDeleted += count($chunk);
            }
        } else {
            $totalErrors += count($chunk);
        }
    }

    return ['ok' => ($totalErrors === 0), 'deleted' => $totalDeleted, 'errors' => $totalErrors];
}

// ============================================================
// FUNCIONES DE ESCANEO Y ARCHIVOS
// ============================================================

function isDokuWiki($path) {
    return file_exists($path . '/doku.php')
        && (file_exists($path . '/conf/local.php') || file_exists($path . '/conf/dokuwiki.php'))
        && is_dir($path . '/data');
}

/**
 * Valida que un nombre de wiki sea seguro (sin path traversal)
 */
function isValidWikiName($name) {
    return (bool)preg_match('/^[a-zA-Z0-9_-]+$/', $name);
}

function scanWikis() {
    $sites = [];
    if (!is_dir(SEARCH_PATH)) return $sites;
    $items = @scandir(SEARCH_PATH);
    if (!$items) return $sites;
    foreach ($items as $item) {
        if (empty($item) || $item[0] === '.' || $item[0] === '_') continue;
        if (!isValidWikiName($item)) continue;
        $full = SEARCH_PATH . '/' . $item;
        if (is_dir($full) && isDokuWiki($full)) {
            $sites[] = ['name' => $item, 'path' => $full];
        }
    }
    usort($sites, function($a, $b) { return strcasecmp($a['name'], $b['name']); });
    return $sites;
}

function _log($msg) {
    if (!is_dir(LOG_DIR)) @mkdir(LOG_DIR, 0755, true);
    $file = LOG_DIR . '/backup_' . date('Y-m-d') . '.log';
    @file_put_contents($file, date('H:i:s') . ' ' . $msg . "\n", FILE_APPEND | LOCK_EX);
}

/**
 * Limpia logs antiguos (mas de 30 dias)
 */
function cleanOldLogs($days = 30) {
    if (!is_dir(LOG_DIR)) return;
    $cutoff = time() - ($days * 86400);
    foreach (glob(LOG_DIR . '/*.log') as $logFile) {
        if (filemtime($logFile) < $cutoff) {
            @unlink($logFile);
        }
    }
}

/**
 * Recorre recursivamente un directorio y devuelve archivos a respaldar.
 * Retorna array de arrays con 'full', 'relative', 'size'.
 */
function getBackupFiles($wikiPath) {
    global $EXCLUDE_DIRS, $EXCLUDE_FILES, $EXCLUDE_EXTENSIONS;

    $files = [];
    $basePath = realpath($wikiPath);
    if (!$basePath) return $files;

    $iterator = new RecursiveIteratorIterator(
        new RecursiveDirectoryIterator($basePath, RecursiveDirectoryIterator::SKIP_DOTS),
        RecursiveIteratorIterator::LEAVES_ONLY
    );

    foreach ($iterator as $file) {
        if (!$file->isFile()) continue;
        if ($file->isLink()) continue; // Saltar symlinks por seguridad

        $fullPath = $file->getPathname();
        $relativePath = str_replace('\\', '/', substr($fullPath, strlen($basePath) + 1));

        // Verificar exclusion de directorios
        $skip = false;
        foreach ($EXCLUDE_DIRS as $exDir) {
            if (strpos($relativePath, $exDir . '/') === 0 || $relativePath === $exDir) {
                $skip = true;
                break;
            }
        }
        if ($skip) continue;

        // Verificar exclusion de archivos
        $fileName = basename($relativePath);
        if (in_array($fileName, $EXCLUDE_FILES)) continue;

        // Verificar exclusion de extensiones
        $ext = strtolower(pathinfo($fileName, PATHINFO_EXTENSION));
        if (!empty($EXCLUDE_EXTENSIONS) && in_array($ext, $EXCLUDE_EXTENSIONS)) continue;

        // Verificar tamano maximo
        if ($file->getSize() > MAX_FILE_SIZE) continue;

        $files[] = [
            'full'     => $fullPath,
            'relative' => $relativePath,
            'size'     => $file->getSize(),
            'mtime'    => $file->getMTime(),
        ];
    }

    return $files;
}

/**
 * Detecta MIME type basico
 */
function detectMime($path) {
    $ext = strtolower(pathinfo($path, PATHINFO_EXTENSION));
    $map = [
        'php'     => 'text/x-php',
        'html'    => 'text/html',
        'htm'     => 'text/html',
        'css'     => 'text/css',
        'js'      => 'application/javascript',
        'json'    => 'application/json',
        'xml'     => 'application/xml',
        'txt'     => 'text/plain',
        'gz'      => 'application/gzip',
        'zip'     => 'application/zip',
        'png'     => 'image/png',
        'jpg'     => 'image/jpeg',
        'gif'     => 'image/gif',
        'svg'     => 'image/svg+xml',
        'pdf'     => 'application/pdf',
        'sqlite3' => 'application/x-sqlite3',
        'less'    => 'text/css',
        'conf'    => 'text/plain',
    ];
    return isset($map[$ext]) ? $map[$ext] : 'application/octet-stream';
}

// ============================================================
// v2.0 - FUNCIONES PARA BACKUP POR LOTES (AJAX)
// ============================================================

/**
 * Retorna lista de archivos de un wiki como array serializable para JSON.
 * Solo retorna 'relative' y 'size' (no 'full' por seguridad en JSON).
 */
function getBackupFilesList($wikiName) {
    $wikiPath = SEARCH_PATH . '/' . $wikiName;
    if (!isDokuWiki($wikiPath)) {
        return ['ok' => false, 'error' => 'No es un wiki DokuWiki'];
    }

    $files = getBackupFiles($wikiPath);
    $list = [];
    $totalSize = 0;
    foreach ($files as $f) {
        $list[] = $f['relative'];
        $totalSize += $f['size'];
    }

    return [
        'ok'        => true,
        'files'     => $list,
        'count'     => count($list),
        'totalSize' => $totalSize,
    ];
}

/**
 * Sube un lote de archivos para un wiki usando curl_multi (paralelo).
 * $files = array de paths relativos (e.g. ['conf/local.php', 'data/pages/start.txt'])
 * $existingKeys = array key=>size de archivos ya en Wasabi (para skip)
 * Retorna resultado con contadores.
 */
function uploadBatch($wikiName, $files, $existingKeys = []) {
    $wikiPath = SEARCH_PATH . '/' . $wikiName;
    $basePath = realpath($wikiPath);
    if (!$basePath) {
        return ['ok' => false, 'error' => 'Wiki path no existe', 'uploaded' => 0, 'skipped' => 0, 'errors' => 0, 'keys' => [], 'errorDetails' => []];
    }

    $uploaded = 0;
    $skipped = 0;
    $errors = 0;
    $errorDetails = [];
    $uploadedKeys = [];

    // Validar y filtrar archivos, separar los que necesitan upload
    $toUpload = []; // [{key, fullPath, mime}]
    foreach ($files as $relPath) {
        $relPath = str_replace('\\', '/', $relPath);
        if (strpos($relPath, '..') !== false || strpos($relPath, "\0") !== false) {
            $errors++;
            $errorDetails[] = "Path invalido: {$relPath}";
            continue;
        }

        $fullPath = $basePath . '/' . $relPath;
        $realFull = realpath($fullPath);
        if (!$realFull || strpos($realFull, $basePath) !== 0) {
            $errors++;
            $errorDetails[] = "Archivo fuera del wiki o no existe: {$relPath}";
            continue;
        }

        if (!is_file($realFull) || !is_readable($realFull)) {
            $errors++;
            $errorDetails[] = "No se puede leer: {$relPath}";
            continue;
        }

        $key = $wikiName . '/' . $relPath;
        $localSize = filesize($realFull);

        // Skip si ya existe en Wasabi con el mismo tamaño
        if (isset($existingKeys[$key]) && $existingKeys[$key] === $localSize) {
            $skipped++;
            $uploadedKeys[] = $key; // Contarlo como existente para cleanup
            continue;
        }

        $toUpload[] = [
            'key'      => $key,
            'fullPath' => $realFull,
            'relPath'  => $relPath,
            'mime'     => detectMime($realFull),
        ];
    }

    // Upload paralelo con curl_multi + compresion + retry
    $parallelCount = defined('PARALLEL_UPLOADS') ? PARALLEL_UPLOADS : 5;
    $maxRetries = 2;
    $chunks = array_chunk($toUpload, $parallelCount);
    $bytesSaved = 0;

    foreach ($chunks as $chunk) {
        $currentBatch = $chunk;

        for ($attempt = 0; $attempt <= $maxRetries; $attempt++) {
            if (empty($currentBatch)) break;

            $mh = curl_multi_init();
            $handles = [];

            foreach ($currentBatch as $item) {
                $prepared = wasabi_put_prepare_gz($item['key'], $item['fullPath'], $item['mime']);
                if ($prepared === false) {
                    $errors++;
                    $errorDetails[] = "No se pudo abrir: {$item['relPath']}";
                    continue;
                }
                $prepared['relPath'] = $item['relPath'];
                $prepared['item'] = $item;
                curl_multi_add_handle($mh, $prepared['ch']);
                $handles[] = $prepared;
            }

            $active = null;
            do {
                $status = curl_multi_exec($mh, $active);
                if ($active) {
                    curl_multi_select($mh, 1);
                }
            } while ($active && $status === CURLM_OK);

            $retryBatch = [];
            foreach ($handles as $h) {
                $httpCode = curl_getinfo($h['ch'], CURLINFO_HTTP_CODE);
                $error    = curl_error($h['ch']);

                if ($httpCode >= 200 && $httpCode < 300) {
                    $uploaded++;
                    $uploadedKeys[] = $h['key'];
                    $bytesSaved += $h['savedBytes'];
                } else {
                    if ($attempt < $maxRetries) {
                        $retryBatch[] = $h['item'];
                    } else {
                        $errors++;
                        $response = curl_multi_getcontent($h['ch']);
                        $errorMsg = $error ?: substr($response, 0, 200);
                        $errorDetails[] = "{$h['relPath']}: HTTP {$httpCode} {$errorMsg}";
                        _log("ERROR PUT batch: {$h['key']} -> HTTP {$httpCode} {$errorMsg}");
                    }
                }

                curl_multi_remove_handle($mh, $h['ch']);
                curl_close($h['ch']);
                fclose($h['fh']);
                // Limpiar archivo temporal de compresion
                if (!empty($h['tmpFile'])) @unlink($h['tmpFile']);
            }

            curl_multi_close($mh);
            $currentBatch = $retryBatch;

            if (!empty($retryBatch) && $attempt < $maxRetries) {
                usleep(500000);
            }
        }
    }

    return [
        'ok'           => ($errors === 0),
        'uploaded'     => $uploaded,
        'skipped'      => $skipped,
        'errors'       => $errors,
        'errorDetails' => $errorDetails,
        'keys'         => $uploadedKeys,
        'bytesSaved'   => $bytesSaved,
    ];
}

/**
 * Limpia archivos obsoletos de Wasabi para un wiki.
 * $uploadedKeys = todas las keys que se subieron exitosamente (acumuladas de todos los batches).
 */
function cleanupWiki($wikiName, $uploadedKeys) {
    $prefix = $wikiName . '/';

    // Obtener lo que hay en Wasabi
    $existingKeys = wasabi_list($prefix);
    if (empty($existingKeys)) {
        return ['ok' => true, 'deleted' => 0, 'errors' => 0, 'obsolete' => 0];
    }

    // Calcular obsoletos
    $obsoleteKeys = array_diff($existingKeys, $uploadedKeys);
    if (empty($obsoleteKeys)) {
        return ['ok' => true, 'deleted' => 0, 'errors' => 0, 'obsolete' => 0];
    }

    $deleteResult = wasabi_delete_bulk(array_values($obsoleteKeys));

    return [
        'ok'       => $deleteResult['ok'],
        'deleted'  => $deleteResult['deleted'],
        'errors'   => $deleteResult['errors'],
        'obsolete' => count($obsoleteKeys),
    ];
}

/**
 * Respalda un wiki completo (para uso desde cron/CLI).
 * v2.1: Skip existentes + uploads paralelos.
 */
function backupWiki($wikiName, $wikiPath) {
    $prefix = $wikiName . '/';

    // PASO 1: Obtener archivos locales a respaldar
    $localFiles = getBackupFiles($wikiPath);
    if (empty($localFiles)) {
        return ['ok' => true, 'uploaded' => 0, 'skipped' => 0, 'deleted' => 0, 'errors' => 0, 'total' => 0];
    }

    // PASO 2: Obtener lo que ya esta en Wasabi (key => size)
    $existingKeys = wasabi_list_with_sizes($prefix);

    // PASO 3: Filtrar - solo subir nuevos o modificados
    $toUpload = [];
    $uploadedKeys = []; // Incluye existentes que no cambiaron
    $skipped = 0;

    foreach ($localFiles as $f) {
        $key = $wikiName . '/' . $f['relative'];
        if (isset($existingKeys[$key]) && $existingKeys[$key] === $f['size']) {
            $skipped++;
            $uploadedKeys[] = $key;
            continue;
        }
        $toUpload[] = $f;
    }

    // PASO 4: Subir en paralelo los que faltan (con compresion)
    $uploaded = 0;
    $errors   = 0;
    $parallelCount = defined('PARALLEL_UPLOADS') ? PARALLEL_UPLOADS : 5;
    $chunks = array_chunk($toUpload, $parallelCount);

    foreach ($chunks as $chunk) {
        $mh = curl_multi_init();
        $handles = [];

        foreach ($chunk as $f) {
            $key = $wikiName . '/' . $f['relative'];
            $prepared = wasabi_put_prepare_gz($key, $f['full'], detectMime($f['full']));
            if ($prepared === false) {
                $errors++;
                continue;
            }
            curl_multi_add_handle($mh, $prepared['ch']);
            $handles[] = $prepared;
        }

        $active = null;
        do {
            $status = curl_multi_exec($mh, $active);
            if ($active) curl_multi_select($mh, 1);
        } while ($active && $status === CURLM_OK);

        foreach ($handles as $h) {
            $httpCode = curl_getinfo($h['ch'], CURLINFO_HTTP_CODE);
            if ($httpCode >= 200 && $httpCode < 300) {
                $uploaded++;
                $uploadedKeys[] = $h['key'];
            } else {
                $errors++;
                $error = curl_error($h['ch']);
                _log("ERROR PUT: {$h['key']} -> HTTP {$httpCode} {$error}");
            }
            curl_multi_remove_handle($mh, $h['ch']);
            curl_close($h['ch']);
            fclose($h['fh']);
            if (!empty($h['tmpFile'])) @unlink($h['tmpFile']);
        }
        curl_multi_close($mh);
    }

    // PASO 5: Solo si el upload fue exitoso, borrar archivos obsoletos
    $deleted = 0;
    if ($errors === 0 && !empty($uploadedKeys)) {
        $allExistingKeys = wasabi_list($prefix);
        $obsoleteKeys = array_diff($allExistingKeys, $uploadedKeys);
        if (!empty($obsoleteKeys)) {
            $deleteResult = wasabi_delete_bulk(array_values($obsoleteKeys));
            $deleted = $deleteResult['deleted'];
        }
    }

    return [
        'ok'       => ($errors === 0),
        'uploaded' => $uploaded,
        'skipped'  => $skipped,
        'deleted'  => $deleted,
        'total'    => count($localFiles),
        'errors'   => $errors,
    ];
}

// ============================================================
// v2.0 - LOCK CON TIMEOUT AUTOMATICO
// ============================================================

/**
 * Adquiere lock exclusivo con auto-expiracion.
 * Si el lock tiene mas de LOCK_TIMEOUT segundos, lo elimina y reintenta.
 * Retorna file handle o false.
 */
function acquireLockWithTimeout() {
    $lockFile = LOCK_FILE;

    // Verificar si hay lock expirado
    if (file_exists($lockFile)) {
        $lockAge = time() - filemtime($lockFile);
        if ($lockAge > LOCK_TIMEOUT) {
            _log("LOCK expirado ({$lockAge}s > " . LOCK_TIMEOUT . "s), eliminando...");
            @unlink($lockFile);
            // Pequena espera para que el filesystem libere
            usleep(100000);
        }
    }

    $fp = @fopen($lockFile, 'w');
    if (!$fp) return false;

    if (!flock($fp, LOCK_EX | LOCK_NB)) {
        fclose($fp);
        // Verificar de nuevo por si el lock file es viejo pero el flock sigue
        if (file_exists($lockFile)) {
            $lockAge = time() - filemtime($lockFile);
            if ($lockAge > LOCK_TIMEOUT) {
                _log("LOCK bloqueado pero expirado ({$lockAge}s), forzando eliminacion...");
                @unlink($lockFile);
                usleep(100000);
                // Reintento unico
                $fp = @fopen($lockFile, 'w');
                if (!$fp) return false;
                if (!flock($fp, LOCK_EX | LOCK_NB)) {
                    fclose($fp);
                    return false;
                }
            } else {
                return false;
            }
        } else {
            return false;
        }
    }

    fwrite($fp, getmypid() . "\n" . date('Y-m-d H:i:s'));
    fflush($fp);
    // Actualizar mtime para tracking de expiracion
    @touch($lockFile);
    return $fp;
}

/**
 * Renueva el timestamp del lock (para operaciones largas).
 * Evita que otro proceso lo considere expirado.
 */
function touchLock() {
    if (file_exists(LOCK_FILE)) {
        @touch(LOCK_FILE);
    }
}

/**
 * Libera el lock.
 */
function releaseLock($fp) {
    if ($fp) {
        flock($fp, LOCK_UN);
        fclose($fp);
        @unlink(LOCK_FILE);
    }
}

/**
 * Adquiere lock (alias legacy para backup_cron.php)
 */
function acquireLock() {
    return acquireLockWithTimeout();
}

// ============================================================
// v2.1 - PER-WIKI LOCKS (para procesamiento paralelo)
// ============================================================

/**
 * Adquiere lock exclusivo por wiki (permite procesar N wikis en paralelo).
 * Retorna array {fp, file} o false.
 */
function acquireWikiLock($wikiName) {
    $safeName = preg_replace('/[^a-zA-Z0-9_-]/', '', $wikiName);
    $lockFile = LOCK_FILE . '.' . $safeName;

    if (file_exists($lockFile)) {
        $lockAge = time() - filemtime($lockFile);
        if ($lockAge > LOCK_TIMEOUT) {
            _log("WIKI LOCK expirado para {$wikiName} ({$lockAge}s), eliminando...");
            @unlink($lockFile);
            usleep(100000);
        }
    }

    $fp = @fopen($lockFile, 'w');
    if (!$fp) return false;

    if (!flock($fp, LOCK_EX | LOCK_NB)) {
        fclose($fp);
        return false;
    }

    fwrite($fp, getmypid() . "\n" . date('Y-m-d H:i:s') . "\n" . $wikiName);
    fflush($fp);
    @touch($lockFile);
    return ['fp' => $fp, 'file' => $lockFile];
}

function touchWikiLock($lock) {
    if ($lock && isset($lock['file']) && file_exists($lock['file'])) {
        @touch($lock['file']);
    }
}

function releaseWikiLock($lock) {
    if (!$lock || !isset($lock['fp'])) return;
    flock($lock['fp'], LOCK_UN);
    fclose($lock['fp']);
    @unlink($lock['file']);
}

// ============================================================
// v2.1 - MANIFEST LOCAL (evita consultar Wasabi en cada ejecucion)
// ============================================================

/**
 * Carga manifest de un wiki (devuelve array relPath => {s:size, m:mtime} o null)
 */
function loadManifest($wikiName) {
    $file = MANIFEST_DIR . '/' . $wikiName . '.json';
    if (!file_exists($file)) return null;
    $data = @json_decode(@file_get_contents($file), true);
    return is_array($data) ? $data : null;
}

/**
 * Guarda manifest de un wiki. Escanea archivos locales actuales.
 */
function saveManifest($wikiName) {
    $wikiPath = SEARCH_PATH . '/' . $wikiName;
    $files = getBackupFiles($wikiPath);

    $manifest = [];
    foreach ($files as $f) {
        $manifest[$f['relative']] = ['s' => $f['size'], 'm' => $f['mtime']];
    }

    if (!is_dir(MANIFEST_DIR)) @mkdir(MANIFEST_DIR, 0755, true);
    $file = MANIFEST_DIR . '/' . $wikiName . '.json';
    return @file_put_contents($file, json_encode($manifest, JSON_UNESCAPED_UNICODE));
}

/**
 * Borra manifest de un wiki (para forzar resync completo)
 */
function deleteManifest($wikiName) {
    $file = MANIFEST_DIR . '/' . $wikiName . '.json';
    if (file_exists($file)) @unlink($file);
}

/**
 * Borra todos los manifests
 */
function deleteAllManifests() {
    if (!is_dir(MANIFEST_DIR)) return;
    foreach (glob(MANIFEST_DIR . '/*.json') as $f) {
        @unlink($f);
    }
}

// ============================================================
// v2.1 - COMPRESION GZIP PARA ARCHIVOS DE TEXTO
// ============================================================

/**
 * Determina si un archivo debe comprimirse antes de subir.
 */
function shouldCompress($localPath, $fileSize) {
    if (!defined('COMPRESS_ENABLED') || !COMPRESS_ENABLED) return false;
    if ($fileSize < (defined('COMPRESS_MIN_SIZE') ? COMPRESS_MIN_SIZE : 1024)) return false;

    global $COMPRESS_EXTENSIONS;
    if (empty($COMPRESS_EXTENSIONS)) return false;

    $ext = strtolower(pathinfo($localPath, PATHINFO_EXTENSION));
    return in_array($ext, $COMPRESS_EXTENSIONS);
}

/**
 * Prepara un curl handle con compresion gzip opcional (para curl_multi).
 * Retorna array con ch, fh, key, tmpFile (si comprimio).
 */
function wasabi_put_prepare_gz($key, $localPath, $mimeType = 'application/octet-stream') {
    $bucket = WASABI_BUCKET;
    $region = WASABI_REGION;
    $host   = $bucket . '.s3.' . $region . '.wasabisys.com';
    $uri    = '/' . rawurlencode_path($key);

    $fh = @fopen($localPath, 'rb');
    if ($fh === false) return false;

    $fileSize    = filesize($localPath);
    $payloadHash = 'UNSIGNED-PAYLOAD';
    $tmpFile     = null;
    $compressed  = false;
    $originalSize = $fileSize;

    // Intentar comprimir si es archivo de texto
    if (shouldCompress($localPath, $fileSize)) {
        $content = @file_get_contents($localPath);
        if ($content !== false) {
            $gz = @gzencode($content, 6);
            if ($gz !== false && strlen($gz) < $fileSize * 0.85) {
                $tmpFile = @tempnam(sys_get_temp_dir(), 'wgz_');
                if ($tmpFile && @file_put_contents($tmpFile, $gz) !== false) {
                    fclose($fh);
                    $fh = @fopen($tmpFile, 'rb');
                    if ($fh === false) {
                        @unlink($tmpFile);
                        $fh = @fopen($localPath, 'rb');
                        if ($fh === false) return false;
                        $tmpFile = null;
                    } else {
                        $fileSize = strlen($gz);
                        $compressed = true;
                    }
                } else {
                    if ($tmpFile) @unlink($tmpFile);
                    $tmpFile = null;
                }
            }
        }
    }

    $dateLong  = gmdate('Ymd\THis\Z');
    $dateShort = gmdate('Ymd');

    $headers = [
        'content-length'       => (string)$fileSize,
        'content-type'         => $mimeType,
        'host'                 => $host,
        'x-amz-content-sha256' => $payloadHash,
        'x-amz-date'          => $dateLong,
    ];

    $curlHeaders = [
        'Content-Length: ' . $fileSize,
        'Content-Type: ' . $mimeType,
        'Host: ' . $host,
        'x-amz-content-sha256: ' . $payloadHash,
        'x-amz-date: ' . $dateLong,
    ];

    if ($compressed) {
        $headers['content-encoding'] = 'gzip';
        $headers['x-amz-meta-original-size'] = (string)$originalSize;
        $curlHeaders[] = 'Content-Encoding: gzip';
        $curlHeaders[] = 'x-amz-meta-original-size: ' . $originalSize;
    }

    $authHeader = wasabi_sign_v4('PUT', $uri, '', $headers, $payloadHash,
        WASABI_ACCESS_KEY, WASABI_SECRET_KEY, $region, $dateShort, $dateLong);

    array_unshift($curlHeaders, 'Authorization: ' . $authHeader);

    $ch = curl_init('https://' . $host . $uri);
    curl_setopt_array($ch, [
        CURLOPT_PUT            => true,
        CURLOPT_INFILE         => $fh,
        CURLOPT_INFILESIZE     => $fileSize,
        CURLOPT_HTTPHEADER     => $curlHeaders,
        CURLOPT_RETURNTRANSFER => true,
        CURLOPT_TIMEOUT        => 120,
        CURLOPT_CONNECTTIMEOUT => 15,
        CURLOPT_LOW_SPEED_LIMIT => 1024,
        CURLOPT_LOW_SPEED_TIME  => 30,
    ]);

    return [
        'ch'         => $ch,
        'fh'         => $fh,
        'key'        => $key,
        'tmpFile'    => $tmpFile,
        'compressed' => $compressed,
        'savedBytes' => $compressed ? ($originalSize - $fileSize) : 0,
    ];
}
