<?php
// CLI Masivo: Rebuild SQLite de wasabisync en TODOS los wikis del servidor
// Recorre public_html buscando wikis DokuWiki,
// lista objetos en Wasabi por prefijo (wiki_id/) y registra en la SQLite local de cada wiki.
//
// Uso:
//   php cli_all.php rebuild    -- Rebuild SQLite en todos los wikis
//   php cli_all.php stats      -- Stats de todos los wikis
//   php cli_all.php install    -- Instala plugin wasabisync + conf en todos los wikis
//   php cli_all.php truncate   -- Trunca archivos locales en todos los wikis
//
// Ejemplo:
//   screen -S wasabi php cli_all.php rebuild
//
// @author NICC
// @date   2026-03-21

if (php_sapi_name() !== 'cli') {
    die("Solo ejecucion por CLI.\n");
}

// =====================================================================
//  CONFIGURACIÓN
// =====================================================================

$BASE_DIR = '/home/systeng3/public_html';

// Credenciales Wasabi — se leen de la BD del siedeployer o se usan estas por defecto
$WASABI_CREDS = null;

// Intentar leer credenciales de la BD de migracion masiva Wasabi
$deployerDb = $BASE_DIR . '/prueba/lib/plugins/siedeployer/diagnosticos/migracion_wasabi_estado.sqlite3';
if (file_exists($deployerDb)) {
    try {
        $pdo = new PDO('sqlite:' . $deployerDb);
        $pdo->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION);
        $creds = [];
        $q = $pdo->query("SELECT key, value FROM migration_config WHERE key LIKE 'wasabi_%'");
        while ($r = $q->fetch(PDO::FETCH_ASSOC)) {
            $k = str_replace('wasabi_', '', $r['key']);
            $creds[$k] = $r['value'];
        }
        if (!empty($creds['key']) && !empty($creds['secret'])) {
            $WASABI_CREDS = [
                'key'      => $creds['key'],
                'secret'   => $creds['secret'],
                'bucket'   => $creds['bucket'] ?? 'nicc-archivos',
                'region'   => $creds['region'] ?? 'us-east-1',
                'endpoint' => $creds['endpoint'] ?? 's3.wasabisys.com',
            ];
        }
    } catch (Exception $e) {
        // silencio
    }
}

if (!$WASABI_CREDS) {
    fwrite(STDERR, "ERROR: No se pudieron leer credenciales Wasabi del siedeployer.\n");
    fwrite(STDERR, "Verifica: $deployerDb\n");
    exit(1);
}

// =====================================================================
//  CLIENTE S3 INDEPENDIENTE (no depende de DokuWiki)
// =====================================================================

class WasabiS3Light {
    private $key, $secret, $bucket, $region, $endpoint;

    public function __construct($key, $secret, $bucket, $region, $endpoint) {
        $this->key      = $key;
        $this->secret   = $secret;
        $this->bucket   = $bucket;
        $this->region   = $region;
        $this->endpoint = $endpoint;
    }

    /**
     * Lista todos los objetos con un prefijo (paginación automática)
     */
    public function listObjects($prefix, $maxKeys = 1000) {
        $host = $this->bucket . '.' . $this->endpoint;
        $allObjects = [];
        $continuationToken = '';
        $truncated = true;

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

            $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, $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        => 120,
                CURLOPT_SSL_VERIFYPEER => false,
            ]);

            $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) {
                $err = '';
                if (preg_match('/<Message>(.*?)<\/Message>/s', $response, $m)) $err = $m[1];
                return ['ok' => false, 'objects' => $allObjects, 'error' => "HTTP $httpCode: $err"];
            }

            $xml = @simplexml_load_string($response);
            if (!$xml) return ['ok' => false, 'objects' => $allObjects, 'error' => 'XML invalido'];

            $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;
                    if (substr($key, -1) === '/') continue;
                    $allObjects[] = [
                        'key'          => $key,
                        'size'         => (int)(string)$item->Size,
                        'lastModified' => (string)$item->LastModified,
                    ];
                }
            }

            $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;
            } else {
                $truncated = false;
            }
        }

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

    /**
     * HEAD — verifica existencia y tamaño
     */
    public function headObject($key) {
        $host = $this->bucket . '.' . $this->endpoint;
        $encodedKey = implode('/', array_map('rawurlencode', explode('/', $key)));
        $url = 'https://' . $host . '/' . $encodedKey;
        $date = gmdate('Ymd\THis\Z');
        $dateShort = gmdate('Ymd');
        $contentHash = hash('sha256', '');

        $canonicalUri = '/' . implode('/', array_map('rawurlencode', explode('/', $key)));

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

        $authHeader = $this->signV4('HEAD', $canonicalUri, '', $headers, $contentHash, $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 => false,
        ]);

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

        if ($httpCode === 200) {
            $sizeInt = ($size !== false && $size >= 0) ? (int)$size : 0;
            return ['exists' => true, 'size' => $sizeInt];
        }
        return ['exists' => false, 'size' => 0];
    }

    private function signV4($method, $uri, $queryString, $headers, $payloadHash, $dateShort, $dateLong) {
        $scope = "$dateShort/{$this->region}/s3/aws4_request";
        $canonicalHeaders = '';
        $signedHeadersList = [];
        $sorted = [];
        foreach ($headers as $k => $v) $sorted[strtolower($k)] = trim($v);
        ksort($sorted);
        foreach ($sorted as $k => $v) {
            $canonicalHeaders .= "$k:$v\n";
            $signedHeadersList[] = $k;
        }
        $signedHeaders = implode(';', $signedHeadersList);

        $canonicalRequest = "$method\n$uri\n$queryString\n$canonicalHeaders\n$signedHeaders\n$payloadHash";
        $stringToSign = "AWS4-HMAC-SHA256\n$dateLong\n$scope\n" . hash('sha256', $canonicalRequest);

        $kDate    = hash_hmac('sha256', $dateShort, 'AWS4' . $this->secret, true);
        $kRegion  = hash_hmac('sha256', $this->region, $kDate, true);
        $kService = hash_hmac('sha256', 's3', $kRegion, true);
        $kSigning = hash_hmac('sha256', 'aws4_request', $kService, true);

        $signature = hash_hmac('sha256', $stringToSign, $kSigning);
        return "AWS4-HMAC-SHA256 Credential={$this->key}/$scope, SignedHeaders=$signedHeaders, Signature=$signature";
    }
}

// =====================================================================
//  FUNCIONES DE WIKI
// =====================================================================

/**
 * Detecta todos los wikis DokuWiki bajo $baseDir
 */
function detectWikis($baseDir) {
    $wikis = [];
    $baseDir = rtrim($baseDir, '/');
    $handle = @opendir($baseDir);
    if (!$handle) return $wikis;

    while (($entry = readdir($handle)) !== false) {
        if ($entry[0] === '.') continue;
        $path = $baseDir . '/' . $entry;
        if (!is_dir($path)) continue;
        // Verificar que sea DokuWiki (tiene doku.php)
        if (file_exists($path . '/doku.php')) {
            $wikis[] = [
                'id'   => $entry,
                'path' => $path,
            ];
        }
    }
    closedir($handle);
    sort($wikis);
    return $wikis;
}

/**
 * Obtiene o crea la SQLite de wasabisync para un wiki
 */
function getWikiSqlite($wikiPath) {
    // Buscar en la ruta estándar del plugin sqlite de DokuWiki
    $dbDir = $wikiPath . '/data/meta/';
    $dbFile = $dbDir . 'wasabisync.sqlite3';

    // Si ya existe, abrir
    if (file_exists($dbFile)) {
        try {
            $pdo = new PDO('sqlite:' . $dbFile);
            $pdo->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION);
            return $pdo;
        } catch (Exception $e) {
            return null;
        }
    }

    // Crear BD nueva
    if (!is_dir($dbDir)) return null;

    try {
        $pdo = new PDO('sqlite:' . $dbFile);
        $pdo->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION);
        $pdo->exec('CREATE TABLE IF NOT EXISTS wasabi_files (
            media_id    TEXT PRIMARY KEY,
            wasabi_key  TEXT NOT NULL,
            file_size   INTEGER NOT NULL DEFAULT 0,
            file_mtime  INTEGER NOT NULL DEFAULT 0,
            mime_type   TEXT DEFAULT \'\',
            uploaded_at TEXT NOT NULL DEFAULT \'\'
        )');
        return $pdo;
    } catch (Exception $e) {
        return null;
    }
}

/**
 * Registra un archivo en la SQLite del wiki
 */
function dbRegister($pdo, $mediaId, $wasabiKey, $fileSize, $fileMtime, $mimeType) {
    $stmt = $pdo->prepare('INSERT OR REPLACE INTO wasabi_files
        (media_id, wasabi_key, file_size, file_mtime, mime_type, uploaded_at)
        VALUES (?, ?, ?, ?, ?, ?)');
    return $stmt->execute([$mediaId, $wasabiKey, $fileSize, $fileMtime, $mimeType, date('c')]);
}

/**
 * Stats de un wiki
 */
function dbStats($pdo) {
    $q = $pdo->query('SELECT COUNT(*) as cnt, COALESCE(SUM(file_size),0) as total FROM wasabi_files');
    $r = $q->fetch(PDO::FETCH_ASSOC);
    return ['count' => (int)$r['cnt'], 'size' => (int)$r['total']];
}

/**
 * Convierte key de Wasabi a media_id
 */
function keyToMediaId($key, $wikiId) {
    $prefix = $wikiId . '/';
    if (strpos($key, $prefix) !== 0) return '';
    $relative = substr($key, strlen($prefix));
    return str_replace('/', ':', $relative);
}

/**
 * Instala el plugin wasabisync en un wiki (copia archivos + config)
 */
function installPlugin($wikiPath, $sourcePlugin, $wasabiCreds) {
    $destPlugin = $wikiPath . '/lib/plugins/wasabisync';

    // Crear directorio si no existe
    if (!is_dir($destPlugin)) {
        @mkdir($destPlugin, 0755, true);
    }

    // Copiar archivos del plugin
    $filesToCopy = [
        'plugin.info.txt', 'action.php', 'admin.php', 'helper.php', 'cli.php',
        'style.css',
    ];
    foreach ($filesToCopy as $f) {
        $src = $sourcePlugin . '/' . $f;
        if (file_exists($src)) {
            @copy($src, $destPlugin . '/' . $f);
        }
    }

    // Copiar subdirectorios
    $dirs = ['conf', 'db', 'lang'];
    foreach ($dirs as $d) {
        $srcDir = $sourcePlugin . '/' . $d;
        if (!is_dir($srcDir)) continue;
        copyDirRecursive($srcDir, $destPlugin . '/' . $d);
    }

    // Agregar configuracion a conf/local.php
    $localPhp = $wikiPath . '/conf/local.php';
    if (!file_exists($localPhp)) return false;

    $content = file_get_contents($localPhp);
    if ($content === false) return false;

    // Verificar si ya tiene configuracion wasabisync
    if (strpos($content, "wasabisync") !== false) return true; // Ya configurado

    $lines = "\n// Wasabi S3 Sync\n";
    $lines .= "\$conf['plugin']['wasabisync']['enabled'] = 1;\n";
    $lines .= "\$conf['plugin']['wasabisync']['wasabi_key'] = '" . addcslashes($wasabiCreds['key'], "'\\") . "';\n";
    $lines .= "\$conf['plugin']['wasabisync']['wasabi_secret'] = '" . addcslashes($wasabiCreds['secret'], "'\\") . "';\n";
    $lines .= "\$conf['plugin']['wasabisync']['wasabi_bucket'] = '" . addcslashes($wasabiCreds['bucket'], "'\\") . "';\n";
    $lines .= "\$conf['plugin']['wasabisync']['wasabi_region'] = '" . addcslashes($wasabiCreds['region'], "'\\") . "';\n";
    $lines .= "\$conf['plugin']['wasabisync']['wasabi_endpoint'] = '" . addcslashes($wasabiCreds['endpoint'], "'\\") . "';\n";
    $lines .= "\$conf['plugin']['wasabisync']['url_expiry'] = 3600;\n";
    $lines .= "\$conf['plugin']['wasabisync']['admin_password'] = 'nicc2026wasabi';\n";

    return @file_put_contents($localPhp, $content . $lines) !== false;
}

function copyDirRecursive($src, $dst) {
    if (!is_dir($dst)) @mkdir($dst, 0755, true);
    $handle = opendir($src);
    while (($entry = readdir($handle)) !== false) {
        if ($entry[0] === '.') continue;
        $srcPath = $src . '/' . $entry;
        $dstPath = $dst . '/' . $entry;
        if (is_dir($srcPath)) {
            copyDirRecursive($srcPath, $dstPath);
        } else {
            @copy($srcPath, $dstPath);
        }
    }
    closedir($handle);
}

/**
 * Cuenta archivos media locales
 */
function countLocalMedia($dir) {
    $result = ['total' => 0, 'with_content' => 0, 'stubs' => 0, 'content_size' => 0];
    _countRecursive($dir, $result);
    return $result;
}

function _countRecursive($dir, &$result) {
    $handle = @opendir($dir);
    if (!$handle) return;
    while (($entry = readdir($handle)) !== false) {
        if ($entry[0] === '.') continue;
        if ($entry === '_dummy') continue;
        $fullPath = $dir . '/' . $entry;
        if (is_dir($fullPath)) {
            _countRecursive($fullPath, $result);
        } elseif (is_file($fullPath)) {
            $result['total']++;
            $size = filesize($fullPath);
            if ($size > 0) {
                $result['with_content']++;
                $result['content_size'] += $size;
            } else {
                $result['stubs']++;
            }
        }
    }
    closedir($handle);
}

function formatSize($bytes) {
    if ($bytes >= 1099511627776) return round($bytes / 1099511627776, 2) . ' TB';
    if ($bytes >= 1073741824) return round($bytes / 1073741824, 2) . ' GB';
    if ($bytes >= 1048576) return round($bytes / 1048576, 2) . ' MB';
    if ($bytes >= 1024) return round($bytes / 1024, 1) . ' KB';
    return $bytes . ' B';
}

// MIME map
$MIME_MAP = [
    'pdf'  => 'application/pdf',
    'doc'  => 'application/msword',
    'docx' => 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
    'xls'  => 'application/vnd.ms-excel',
    'xlsx' => 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
    'ppt'  => 'application/vnd.ms-powerpoint',
    'pptx' => 'application/vnd.openxmlformats-officedocument.presentationml.presentation',
    'jpg'  => 'image/jpeg', 'jpeg' => 'image/jpeg',
    'png'  => 'image/png',  'gif'  => 'image/gif',
    'zip'  => 'application/zip',
    'rar'  => 'application/x-rar-compressed',
    'txt'  => 'text/plain', 'csv'  => 'text/csv',
    'html' => 'text/html',  'htm'  => 'text/html',
    'mp4'  => 'video/mp4',  'mp3'  => 'audio/mpeg',
    'webp' => 'image/webp', 'svg'  => 'image/svg+xml',
    'bmp'  => 'image/bmp',  'odt'  => 'application/vnd.oasis.opendocument.text',
    '7z'   => 'application/x-7z-compressed',
];

// =====================================================================
//  COMANDOS
// =====================================================================

$command = $argv[1] ?? '';

switch ($command) {
    case 'rebuild':  cmdRebuildAll(); break;
    case 'stats':    cmdStatsAll(); break;
    case 'install':  cmdInstallAll(); break;
    case 'truncate': cmdTruncateAll(); break;
    default:
        echo "Wasabi S3 Sync — CLI Masivo\n";
        echo "===========================\n";
        echo "Uso: php cli_all.php <comando>\n\n";
        echo "Comandos:\n";
        echo "  install    Instalar plugin + config en todos los wikis\n";
        echo "  rebuild    Reconstruir SQLite en todos los wikis desde Wasabi\n";
        echo "  stats      Estadísticas de todos los wikis\n";
        echo "  truncate   Truncar archivos locales en todos los wikis\n";
        echo "\nEjemplo:\n";
        echo "  screen -S wasabi php cli_all.php install\n";
        echo "  screen -S wasabi php cli_all.php rebuild\n";
        exit(0);
}

/**
 * Instala plugin wasabisync en todos los wikis + configura credenciales
 */
function cmdInstallAll() {
    global $BASE_DIR, $WASABI_CREDS;

    $wikis = detectWikis($BASE_DIR);

    echo "╔══════════════════════════════════════════════════╗\n";
    echo "║  INSTALAR WASABISYNC EN TODOS LOS WIKIS          ║\n";
    echo "╠══════════════════════════════════════════════════╣\n";
    echo "║  Wikis detectados: " . count($wikis) . "\n";
    echo "║  Fecha: " . date('Y-m-d H:i:s') . "\n";
    echo "╚══════════════════════════════════════════════════╝\n\n";

    // Usar el plugin del wiki 'prueba' como fuente
    $sourcePlugin = $BASE_DIR . '/prueba/lib/plugins/wasabisync';
    if (!is_dir($sourcePlugin)) {
        fwrite(STDERR, "ERROR: No se encuentra el plugin fuente en $sourcePlugin\n");
        exit(1);
    }

    $installed = 0;
    $skipped = 0;
    $errors = 0;

    foreach ($wikis as $wiki) {
        $hasPlugin = is_dir($wiki['path'] . '/lib/plugins/wasabisync');
        $hasConfig = false;
        if (file_exists($wiki['path'] . '/conf/local.php')) {
            $hasConfig = strpos(file_get_contents($wiki['path'] . '/conf/local.php'), 'wasabisync') !== false;
        }

        if ($hasPlugin && $hasConfig) {
            echo "  [SKIP] {$wiki['id']} — ya instalado\n";
            $skipped++;
            continue;
        }

        $ok = installPlugin($wiki['path'], $sourcePlugin, $WASABI_CREDS);
        if ($ok) {
            echo "  [OK]   {$wiki['id']}\n";
            $installed++;
        } else {
            echo "  [FAIL] {$wiki['id']}\n";
            $errors++;
        }
    }

    echo "\n--- Resultado ---\n";
    echo "Instalados: $installed | Ya existentes: $skipped | Errores: $errors\n";
}

/**
 * Rebuild SQLite en todos los wikis
 */
function cmdRebuildAll() {
    global $BASE_DIR, $WASABI_CREDS, $MIME_MAP;

    $wikis = detectWikis($BASE_DIR);
    $s3 = new WasabiS3Light(
        $WASABI_CREDS['key'], $WASABI_CREDS['secret'],
        $WASABI_CREDS['bucket'], $WASABI_CREDS['region'], $WASABI_CREDS['endpoint']
    );

    echo "╔══════════════════════════════════════════════════╗\n";
    echo "║  REBUILD SQLite — TODOS LOS WIKIS                ║\n";
    echo "╠══════════════════════════════════════════════════╣\n";
    echo "║  Wikis:    " . count($wikis) . "\n";
    echo "║  Bucket:   {$WASABI_CREDS['bucket']}\n";
    echo "║  Fecha:    " . date('Y-m-d H:i:s') . "\n";
    echo "╚══════════════════════════════════════════════════╝\n\n";

    $grandTotal = 0;
    $grandSize = 0;
    $grandErrors = 0;
    $wikisProcessed = 0;
    $wikisSkipped = 0;
    $startTime = microtime(true);

    foreach ($wikis as $wi => $wiki) {
        $wikiId = $wiki['id'];
        $prefix = $wikiId . '/';

        echo sprintf("[%s] [%d/%d] %s — ", date('H:i:s'), $wi + 1, count($wikis), $wikiId);

        // Obtener SQLite del wiki
        $pdo = getWikiSqlite($wiki['path']);
        if (!$pdo) {
            echo "ERROR: no se pudo abrir/crear SQLite\n";
            $grandErrors++;
            continue;
        }

        // Listar objetos en Wasabi
        $result = $s3->listObjects($prefix);
        if (!$result['ok']) {
            echo "ERROR listando: " . $result['error'] . "\n";
            $grandErrors++;
            continue;
        }

        $objects = $result['objects'];
        if (empty($objects)) {
            echo "0 objetos (vacío)\n";
            $wikisSkipped++;
            continue;
        }

        // Registrar en SQLite
        $registered = 0;
        $wikiSize = 0;

        // Usar transacción para velocidad
        $pdo->beginTransaction();

        foreach ($objects as $obj) {
            $key = $obj['key'];
            if (strpos($key, '_test_wasabisync_') !== false) continue;

            $mediaId = keyToMediaId($key, $wikiId);
            if (!$mediaId) continue;

            $ext = strtolower(pathinfo($key, PATHINFO_EXTENSION));
            $mimeType = $MIME_MAP[$ext] ?? 'application/octet-stream';
            $mtime = strtotime($obj['lastModified']) ?: time();

            if (dbRegister($pdo, $mediaId, $key, $obj['size'], $mtime, $mimeType)) {
                $registered++;
                $wikiSize += $obj['size'];
            }
        }

        $pdo->commit();

        echo sprintf("%s objetos -> %s registrados (%s)\n",
            number_format(count($objects)),
            number_format($registered),
            formatSize($wikiSize));

        $grandTotal += $registered;
        $grandSize += $wikiSize;
        $wikisProcessed++;
    }

    $elapsed = round(microtime(true) - $startTime, 1);

    echo "\n╔══════════════════════════════════════════════════╗\n";
    echo "║  RESULTADO GLOBAL                                 ║\n";
    echo "╠══════════════════════════════════════════════════╣\n";
    echo "║  Wikis procesados: " . str_pad($wikisProcessed, 6) . "                         ║\n";
    echo "║  Wikis vacíos:     " . str_pad($wikisSkipped, 6) . "                         ║\n";
    echo "║  Wikis con error:  " . str_pad($grandErrors, 6) . "                         ║\n";
    echo "║  Total registrado: " . str_pad(number_format($grandTotal), 10) . "                     ║\n";
    echo "║  Tamaño total:     " . str_pad(formatSize($grandSize), 10) . "                     ║\n";
    echo "║  Tiempo:           " . str_pad($elapsed . 's', 10) . "                     ║\n";
    echo "╚══════════════════════════════════════════════════╝\n";
}

/**
 * Stats de todos los wikis
 */
function cmdStatsAll() {
    global $BASE_DIR;

    $wikis = detectWikis($BASE_DIR);

    echo "╔═══════════════════════════════════════════════════════════════════════════╗\n";
    echo "║  ESTADÍSTICAS WASABISYNC — TODOS LOS WIKIS                               ║\n";
    echo "╠═══════════════════════════════════════════════════════════════════════════╣\n";
    printf("║  %-20s │ %10s │ %10s │ %10s │ %10s ║\n", 'Wiki', 'En SQLite', 'Tamaño WS', 'Con contenido', 'Stubs');
    echo "╠═══════════════════════════════════════════════════════════════════════════╣\n";

    $totalDb = 0;
    $totalDbSize = 0;
    $totalContent = 0;
    $totalStubs = 0;

    foreach ($wikis as $wiki) {
        $pdo = getWikiSqlite($wiki['path']);
        $dbCount = 0;
        $dbSize = 0;

        if ($pdo) {
            $stats = dbStats($pdo);
            $dbCount = $stats['count'];
            $dbSize = $stats['size'];
        }

        // Contar locales
        $mediaDir = $wiki['path'] . '/data/media';
        $local = ['with_content' => 0, 'stubs' => 0];
        if (is_dir($mediaDir)) {
            $local = countLocalMedia($mediaDir);
        }

        if ($dbCount > 0 || $local['with_content'] > 0 || $local['stubs'] > 0) {
            printf("║  %-20s │ %10s │ %10s │ %10s │ %10s ║\n",
                substr($wiki['id'], 0, 20),
                number_format($dbCount),
                formatSize($dbSize),
                number_format($local['with_content']),
                number_format($local['stubs'])
            );
            $totalDb += $dbCount;
            $totalDbSize += $dbSize;
            $totalContent += $local['with_content'];
            $totalStubs += $local['stubs'];
        }
    }

    echo "╠═══════════════════════════════════════════════════════════════════════════╣\n";
    printf("║  %-20s │ %10s │ %10s │ %10s │ %10s ║\n",
        'TOTAL', number_format($totalDb), formatSize($totalDbSize),
        number_format($totalContent), number_format($totalStubs));
    echo "╚═══════════════════════════════════════════════════════════════════════════╝\n";
}

/**
 * Trunca archivos locales en todos los wikis (con verificación HEAD)
 */
function cmdTruncateAll() {
    global $BASE_DIR, $WASABI_CREDS;

    $wikis = detectWikis($BASE_DIR);
    $s3 = new WasabiS3Light(
        $WASABI_CREDS['key'], $WASABI_CREDS['secret'],
        $WASABI_CREDS['bucket'], $WASABI_CREDS['region'], $WASABI_CREDS['endpoint']
    );

    echo "╔══════════════════════════════════════════════════╗\n";
    echo "║  TRUNCAR ARCHIVOS — TODOS LOS WIKIS              ║\n";
    echo "╠══════════════════════════════════════════════════╣\n";
    echo "║  Wikis:  " . count($wikis) . "\n";
    echo "║  Fecha:  " . date('Y-m-d H:i:s') . "\n";
    echo "║  Verificacion por listObjects (lote por wiki)    ║\n";
    echo "╚══════════════════════════════════════════════════╝\n\n";

    $grandTruncated = 0;
    $grandFreed = 0;
    $grandSkipped = 0;
    $grandFail = 0;
    $startTime = microtime(true);

    foreach ($wikis as $wi => $wiki) {
        $wikiId = $wiki['id'];
        $mediaDir = $wiki['path'] . '/data/media';

        if (!is_dir($mediaDir)) continue;

        $pdo = getWikiSqlite($wiki['path']);
        if (!$pdo) continue;

        $stats = dbStats($pdo);
        if ($stats['count'] === 0) continue;

        echo sprintf("[%s] [%d/%d] %s — ", date('H:i:s'), $wi + 1, count($wikis), $wikiId);

        // Escanear archivos con contenido
        $localFiles = [];
        scanMediaForTruncate($mediaDir, '', $localFiles);
        $withContent = array_filter($localFiles, function($f) { return $f['size'] > 0; });

        if (empty($withContent)) {
            echo "ya todo truncado\n";
            continue;
        }

        // listObjects: 1 llamada trae todos los archivos del wiki en Wasabi
        $prefix = $wikiId . '/';
        $result = $s3->listObjects($prefix);
        if (!$result['ok']) {
            echo "ERROR listObjects: " . $result['error'] . "\n";
            $grandFail++;
            continue;
        }

        // Construir mapa key => size de lo que existe en Wasabi
        $wasabiMap = [];
        foreach ($result['objects'] as $obj) {
            $wasabiMap[$obj['key']] = (int)$obj['size'];
        }

        $truncated = 0;
        $freed = 0;
        $skipped = 0;
        $fail = 0;

        foreach ($withContent as $f) {
            // 1. Buscar en SQLite
            $stmt = $pdo->prepare('SELECT wasabi_key, file_size FROM wasabi_files WHERE media_id = ?');
            $stmt->execute([$f['media_id']]);
            $record = $stmt->fetch(PDO::FETCH_ASSOC);
            if (!$record) { $skipped++; continue; }

            // 2. Verificar que existe en Wasabi (via listObjects)
            $wasabiKey = $record['wasabi_key'];
            if (!isset($wasabiMap[$wasabiKey])) { $skipped++; continue; }

            // 3. Comparar tamaños
            $dbSize = (int)$record['file_size'];
            $wasabiSize = $wasabiMap[$wasabiKey];
            if ($wasabiSize > 0 && $dbSize > 0 && $wasabiSize !== $dbSize) {
                $skipped++;
                continue;
            }

            // 4. Truncar
            $fh = @fopen($f['path'], 'w');
            if ($fh) {
                ftruncate($fh, 0);
                fclose($fh);
                clearstatcache(true, $f['path']);
                if (filesize($f['path']) === 0) {
                    $truncated++;
                    $freed += $f['size'];
                } else {
                    $fail++;
                }
            } else {
                $fail++;
            }
        }

        echo sprintf("%d truncados (%s liberados), %d saltados, %d fallos\n",
            $truncated, formatSize($freed), $skipped, $fail);

        $grandTruncated += $truncated;
        $grandFreed += $freed;
        $grandSkipped += $skipped;
        $grandFail += $fail;
    }

    $elapsed = round(microtime(true) - $startTime, 1);

    echo "\n╔══════════════════════════════════════════════════╗\n";
    echo "║  RESULTADO GLOBAL TRUNCAR                        ║\n";
    echo "╠══════════════════════════════════════════════════╣\n";
    echo "║  Truncados:   " . str_pad(number_format($grandTruncated), 10) . "                     ║\n";
    echo "║  Liberado:    " . str_pad(formatSize($grandFreed), 10) . "                     ║\n";
    echo "║  Saltados:    " . str_pad(number_format($grandSkipped), 10) . "                     ║\n";
    echo "║  Fallos:      " . str_pad(number_format($grandFail), 10) . "                     ║\n";
    echo "║  Tiempo:      " . str_pad($elapsed . 's', 10) . "                     ║\n";
    echo "╚══════════════════════════════════════════════════╝\n";
}

function scanMediaForTruncate($dir, $ns, &$files) {
    $dir = rtrim($dir, '/');
    $handle = @opendir($dir);
    if (!$handle) return;
    while (($entry = readdir($handle)) !== false) {
        if ($entry[0] === '.') continue;
        if ($entry === '_dummy') continue;
        $fullPath = $dir . '/' . $entry;
        if (is_dir($fullPath)) {
            $subNs = $ns ? ($ns . ':' . $entry) : $entry;
            scanMediaForTruncate($fullPath, $subNs, $files);
        } elseif (is_file($fullPath)) {
            $mediaId = $ns ? ($ns . ':' . $entry) : $entry;
            $files[] = ['path' => $fullPath, 'media_id' => $mediaId, 'size' => filesize($fullPath)];
        }
    }
    closedir($handle);
}
