<?php
require_once __DIR__ . '/path_security.php';
require_once __DIR__ . '/explorer_storage_stats_cache.php';
require_once __DIR__ . '/explorer_customization.php';
require_once __DIR__ . '/user_settings_helpers.php';
require_once __DIR__ . '/explorer_shared_spaces.php';

if (isset($_GET['action'])) {
    require_once __DIR__ . '/os_traffic_meter.php';
    grid_traffic_meter_begin();

    $__antaraAction = (string) $_GET['action'];
    if (strncmp($__antaraAction, 'gridAuth', 8) === 0) {
        require_once __DIR__ . '/../core/auth/gridauth_compat.php';
        antara_gridauth_handle_and_exit();
    }

    $explorerConnPreviewAction = ($_GET['action'] === 'explorerConnPreview');
    $explorerStorageFilesStreamAction = ($_GET['action'] === 'explorerStorageFilesStream');
    if (!$explorerConnPreviewAction && !$explorerStorageFilesStreamAction) {
        header('Content-Type: application/json');
    }

    if ($explorerStorageFilesStreamAction) {
        $forceStreamRefresh = isset($_GET['refresh']) && (string) $_GET['refresh'] === '1';
        if (!$forceStreamRefresh) {
            $cHit = grid_explorer_storage_cache_fresh();
            if ($cHit !== null) {
                @set_time_limit(0);
                @ini_set('output_buffering', '0');
                @ini_set('zlib.output_compression', '0');
                header('Content-Type: application/x-ndjson; charset=utf-8', true);
                header('Cache-Control: no-store');
                header('X-Accel-Buffering: no');
                $fStat = is_array($cHit['files'] ?? null) ? $cHit['files'] : ['bytes' => 0, 'files' => 0];
                echo json_encode(
                    [
                        'type' => 'done',
                        'success' => true,
                        'part' => 'files',
                        'fromCache' => true,
                        'files' => [
                            'bytes' => (int) ($fStat['bytes'] ?? 0),
                            'files' => (int) ($fStat['files'] ?? 0),
                        ],
                    ],
                    JSON_UNESCAPED_UNICODE
                ) . "\n";
                exit;
            }
        }
        @set_time_limit(0);
        @ini_set('output_buffering', '0');
        @ini_set('zlib.output_compression', '0');
        header('Content-Type: application/x-ndjson; charset=utf-8', true);
        header('Cache-Control: no-store');
        header('X-Accel-Buffering: no');

        $filesRoot = rtrim((string) grid_explorer_local_files_base(), '\/\\');
        $baseNorm = str_replace(['\\', '/'], DIRECTORY_SEPARATOR, $filesRoot);

        $flush = function (array $row) {
            echo json_encode($row, JSON_UNESCAPED_UNICODE) . "\n";
            if (function_exists('ob_get_level')) {
                while (@ob_get_level() > 0) {
                    @ob_end_flush();
                }
            }
            @flush();
        };

        $pathRel = function (string $abs) use ($baseNorm) {
            $a = str_replace(['\\', '/'], DIRECTORY_SEPARATOR, $abs);
            $a = rtrim($a, DIRECTORY_SEPARATOR);
            $b = rtrim($baseNorm, DIRECTORY_SEPARATOR);
            if ($b !== '' && (stripos($a, $b) === 0)) {
                $r = ltrim(substr($a, strlen($b)), DIRECTORY_SEPARATOR);
                return $r === '' ? '' : str_replace(DIRECTORY_SEPARATOR, '/', $r);
            }

            return basename($a);
        };

        $walk = function ($path, array &$seen) use (&$walk, $flush, $pathRel) {
            $path = str_replace(['\\', '/'], DIRECTORY_SEPARATOR, (string) $path);
            $key = @realpath($path);
            if ($key === false) {
                $key = rtrim($path, DIRECTORY_SEPARATOR);
            }
            if (isset($seen[$key])) {
                return ['bytes' => 0, 'files' => 0];
            }
            if (!is_dir($path)) {
                return ['bytes' => 0, 'files' => 0];
            }
            $seen[$key] = true;

            $bytes = 0;
            $files = 0;
            $items = @scandir($path);
            if (!$items) {
                return ['bytes' => 0, 'files' => 0];
            }
            foreach ($items as $it) {
                if ($it === '.' || $it === '..') {
                    continue;
                }
                $full = $path . DIRECTORY_SEPARATOR . $it;
                if (is_dir($full)) {
                    $r = (string) $pathRel($full);
                    if ($r === '') {
                        $r = $it;
                    }
                    $flush([
                        'type' => 'entry',
                        'name' => (string) $it,
                        'pathRel' => $r,
                        'isDir' => true,
                    ]);
                    $sub = $walk($full, $seen);
                    $bytes += $sub['bytes'];
                    $files += $sub['files'];
                } elseif (is_file($full)) {
                    $r = (string) $pathRel($full);
                    if ($r === '') {
                        $r = $it;
                    }
                    $sz = @filesize($full);
                    $b = ($sz !== false) ? (int) $sz : 0;
                    $bytes += $b;
                    $files++;
                    $flush([
                        'type' => 'entry',
                        'name' => (string) $it,
                        'pathRel' => $r,
                        'isDir' => false,
                    ]);
                }
            }

            return ['bytes' => $bytes, 'files' => $files];
        };

        $seen = [];
        $tot = $walk($baseNorm, $seen);
        $flush([
            'type' => 'done',
            'success' => true,
            'part' => 'files',
            'files' => $tot,
        ]);
        exit;
    }
    
    if ($_GET['action'] === 'getSettings') {
        $workspace = grid_read_settings_for_api($settingsFile, $settingsBackupFile, $defaultSettings);
        $out = grid_settings_merged_for_session($workspace);
        echo json_encode($out, JSON_UNESCAPED_UNICODE);
        exit;
    }

    if ($_GET['action'] === 'flexiaInstance') {
        require_once GRID_ROOT . '/includes/install_public.php';
        require_once GRID_ROOT . '/includes/flexia_instance.php';
        header('Cache-Control: no-store, no-cache, must-revalidate');
        $inst = antara_flexia_instance_for_client();
        echo json_encode($inst ?? ['generation' => null, 'formatCount' => 0], JSON_UNESCAPED_UNICODE);
        exit;
    }
    
    if ($_GET['action'] === 'saveSettings') {
        require_once GRID_ROOT . '/core/auth/bootstrap_auth.php';
        try {
            $cfg = AntaraStore::configRead();
        } catch (\Throwable $e) {
            $cfg = ['login_required' => false];
        }
        if (!empty($cfg['login_required']) && !AntaraSession::isLoggedIn()) {
            echo json_encode(['success' => false, 'error' => 'Inicia sesión para guardar tu perfil.', 'code' => 'LOGIN_REQUIRED'], JSON_UNESCAPED_UNICODE);
            exit;
        }

        $input = json_decode(file_get_contents('php://input'), true);
        if ($input) {
            $current = grid_read_settings_for_api($settingsFile, $settingsBackupFile, $defaultSettings);
            if (is_array($input)) {
                // La papelera de archivos solo la actualizan las acciones explorer* (borrar/restaurar/vaciar).
                unset($input['fileExplorerTrash']);
            }
            $result = grid_user_settings_save_patch($current, is_array($input) ? $input : []);
            if (empty($result['success'])) {
                echo json_encode([
                    'success' => false,
                    'error' => $result['error'] ?? 'Error al guardar',
                    'code' => $result['code'] ?? 'SAVE_FAILED',
                ], JSON_UNESCAPED_UNICODE);
                exit;
            }
            echo json_encode(['success' => true, 'data' => $result['data'] ?? []], JSON_UNESCAPED_UNICODE);
        } else {
            echo json_encode(['success' => false, 'error' => 'Invalid JSON']);
        }
        exit;
    }

    if ($_GET['action'] === 'settingsDiskEnrichment') {
        $root = defined('GRID_ROOT') ? GRID_ROOT : dirname(__DIR__);
        $method = strtoupper((string)($_SERVER['REQUEST_METHOD'] ?? 'GET'));
        $current = grid_read_settings_for_api($settingsFile, $settingsBackupFile, $defaultSettings);
        $scan = grid_settings_scan_disk_personalization($root);
        $proposed = grid_settings_merge_scan_into_settings($current, $scan);
        $diff = grid_settings_enrichment_diff($current, $proposed);

        if ($method === 'GET') {
            echo json_encode([
                'success' => true,
                'scan' => $scan,
                'diff' => $diff,
                'canApply' => count($diff) > 0,
            ], JSON_UNESCAPED_UNICODE);
            exit;
        }

        if ($method === 'POST') {
            $input = json_decode((string)file_get_contents('php://input'), true);
            if (!is_array($input) || empty($input['apply'])) {
                echo json_encode(['success' => false, 'error' => 'invalid_body'], JSON_UNESCAPED_UNICODE);
                exit;
            }
            if (count($diff) === 0) {
                echo json_encode([
                    'success' => true,
                    'applied' => false,
                    'message' => 'nothing_to_apply',
                    'settings' => $current,
                    'scan' => $scan,
                    'diff' => [],
                ], JSON_UNESCAPED_UNICODE);
                exit;
            }
            $ok = grid_persist_settings($settingsFile, $settingsBackupFile, $proposed);
            if (!$ok) {
                echo json_encode(['success' => false, 'error' => 'persist_failed'], JSON_UNESCAPED_UNICODE);
                exit;
            }
            $fresh = grid_read_settings_for_api($settingsFile, $settingsBackupFile, $defaultSettings);
            echo json_encode([
                'success' => true,
                'applied' => true,
                'settings' => $fresh,
                'scan' => $scan,
                'diff' => $diff,
            ], JSON_UNESCAPED_UNICODE);
            exit;
        }

        echo json_encode(['success' => false, 'error' => 'method_not_allowed'], JSON_UNESCAPED_UNICODE);
        exit;
    }

    if ($_GET['action'] === 'explorerCustomizationGet') {
        $data = grid_explorer_customization_read();
        echo json_encode(['success' => true, 'data' => $data], JSON_UNESCAPED_UNICODE);
        exit;
    }

    if ($_GET['action'] === 'explorerCustomizationSave') {
        $input = json_decode(file_get_contents('php://input'), true);
        if (!is_array($input)) {
            echo json_encode(['success' => false, 'error' => 'Invalid JSON']);
            exit;
        }
        $patch = (isset($input['patch']) && is_array($input['patch'])) ? $input['patch'] : $input;
        $out = grid_explorer_customization_apply_patch($patch);
        echo json_encode($out, JSON_UNESCAPED_UNICODE);
        exit;
    }

    if ($_GET['action'] === 'explorerCustomizationUpload') {
        $cat = isset($_POST['category']) ? (string)$_POST['category'] : '';
        $r = grid_explorer_customization_handle_upload($cat);
        if (empty($r['success'])) {
            echo json_encode(['success' => false, 'error' => $r['error'] ?? 'Error']);
            exit;
        }
        echo json_encode(['success' => true, 'relPath' => $r['relPath'] ?? '']);
        exit;
    }

    if ($_GET['action'] === 'osEnvironment') {
        if (!function_exists('grid_os_build_environment_payload')) {
            function grid_os_build_environment_payload(): array {
                $root = defined('GRID_ROOT') ? (string) GRID_ROOT : dirname(__DIR__);
                $doc = isset($_SERVER['DOCUMENT_ROOT']) ? (string) $_SERVER['DOCUMENT_ROOT'] : '';
                $host = isset($_SERVER['HTTP_HOST']) ? (string) $_SERVER['HTTP_HOST'] : '';
                $serverSoftware = isset($_SERVER['SERVER_SOFTWARE']) ? (string) $_SERVER['SERVER_SOFTWARE'] : '';
                $localHost = $host === '' || (bool) preg_match('/^(localhost|127\.0\.0\.1)(\:|$)/i', explode(':', $host, 2)[0]);
                $pathProbe = strtolower(str_replace('\\', '/', $root . '|' . $doc));
                $xamppHint = (strpos($pathProbe, 'xampp') !== false)
                    || (strpos($pathProbe, 'htdocs') !== false && preg_match('/(xampp|wamp|laragon|lamp)/i', $pathProbe));

                $envKind = $localHost ? 'local' : 'hosting';

                $free = @disk_free_space($root);
                $total = @disk_total_space($root);
                $disk = null;
                if ($free !== false && $total !== false && $total > 0) {
                    $used = $total - $free;
                    $disk = [
                        'usedBytes' => (int) $used,
                        'totalBytes' => (int) $total,
                        'usedPercent' => round(100 * ($used / $total), 1),
                    ];
                }

                $memLimitStr = (string) ini_get('memory_limit');
                $usage = (int) memory_get_usage(true);
                $peak = (int) memory_get_peak_usage(true);
                $memLimitBytes = null;
                if ($memLimitStr !== '' && $memLimitStr !== '-1') {
                    $memLimitBytes = grid_os_parse_ini_bytes($memLimitStr);
                }
                $phpMemory = [
                    'usageBytes' => $usage,
                    'peakBytes' => $peak,
                    'limitBytes' => $memLimitBytes,
                    'limitLabel' => $memLimitStr,
                    'usagePercentOfLimit' => ($memLimitBytes && $memLimitBytes > 0)
                        ? round(min(100, 100 * ($peak / $memLimitBytes)), 1)
                        : null,
                ];

                $load = function_exists('sys_getloadavg') ? @sys_getloadavg() : null;
                $loadAvg = (is_array($load) && isset($load[0])) ? [
                    '1m' => (float) $load[0],
                    '5m' => isset($load[1]) ? (float) $load[1] : null,
                    '15m' => isset($load[2]) ? (float) $load[2] : null,
                ] : null;

                $shellOk = function_exists('shell_exec') && !grid_os_ini_flag('shell_exec');
                $apache = null;
                $mysql = null;
                if ($shellOk && PHP_OS_FAMILY === 'Windows') {
                    $out = @shell_exec('tasklist /FO CSV /NH 2>nul');
                    if (is_string($out) && $out !== '') {
                        $apache = (bool) (preg_match('/httpd\\.exe|apache/i', $out) === 1);
                        $mysql = (bool) (preg_match('/mysqld\\.exe|mariadbd\\.exe/i', $out) === 1);
                    }
                } elseif ($shellOk && PHP_OS_FAMILY !== 'Windows') {
                    $out = @shell_exec('ps -eo comm 2>/dev/null | head -n 80');
                    if (is_string($out)) {
                        $apache = (bool) (preg_match('/(httpd|apache2|nginx)/i', $out) === 1);
                        $mysql = (bool) (preg_match('/(mysqld|mariadbd)/i', $out) === 1);
                    }
                }

                $localIp = null;
                if ($localHost) {
                    $localIp = gethostbyname(gethostname() ?: 'localhost');
                    if ($localIp === gethostname()) {
                        $localIp = '127.0.0.1';
                    }
                }

                return [
                    'success' => true,
                    'environment' => $envKind,
                    'labels' => [
                        'local' => 'Entorno local',
                        'hosting' => 'Hosting / servidor',
                    ],
                    'php' => [
                        'version' => PHP_VERSION,
                        'osFamily' => PHP_OS_FAMILY,
                        'sapi' => PHP_SAPI,
                    ],
                    'server' => [
                        'host' => $host,
                        'software' => $serverSoftware,
                        'documentRootTail' => $doc !== '' ? basename(str_replace('\\', '/', $doc)) : null,
                        'httpsActive' => grid_os_detect_https_active(),
                    ],
                    'paths' => [
                        'gridRootTail' => basename(str_replace('\\', '/', $root)),
                        'xamppLike' => $xamppHint,
                    ],
                    'network' => [
                        'clientIp' => $_SERVER['REMOTE_ADDR'] ?? null,
                        'localIp' => $localIp,
                    ],
                    'disk' => $disk,
                    'phpMemory' => $phpMemory,
                    'loadAvg' => $loadAvg,
                    'services' => [
                        'apacheDetected' => $apache,
                        'mysqlDetected' => $mysql,
                        'probeAvailable' => $shellOk,
                    ],
                    'timestamp' => time(),
                ];
            }
            function grid_os_ini_flag(string $fn): bool {
                $d = (string) ini_get('disable_functions');
                if ($d === '') {
                    return false;
                }
                return (bool) preg_match('/(?:^|,)\s*' . preg_quote($fn, '/') . '\s*(?:,|$)/', $d);
            }
            function grid_os_parse_ini_bytes(string $val): ?int {
                $val = trim($val);
                if ($val === '' || $val === '-1') {
                    return null;
                }
                $u = strtoupper(substr($val, -1));
                $n = (float) $val;
                if ($u === 'G') {
                    return (int) round($n * 1073741824);
                }
                if ($u === 'M') {
                    return (int) round($n * 1048576);
                }
                if ($u === 'K') {
                    return (int) round($n * 1024);
                }
                return (int) round($n);
            }
        }
        $payload = grid_os_build_environment_payload();
        $payload['traffic'] = grid_traffic_meter_snapshot(grid_traffic_meter_user_id());
        global $settings;
        $payload['alerts'] = grid_traffic_meter_alerts(
            $payload,
            is_array($settings ?? null) ? $settings : []
        );
        echo json_encode($payload, JSON_UNESCAPED_UNICODE);
        exit;
    }
    
    if ($_GET['action'] === 'uploadMedia') {
        if (isset($_FILES['file'])) {
            $file = $_FILES['file'];
            $extension = pathinfo($file['name'], PATHINFO_EXTENSION);
            $filename = uniqid() . '_' . time() . '.' . $extension;
            $targetPath = 'media/' . $filename;
            if (move_uploaded_file($file['tmp_name'], $targetPath)) {
                echo json_encode(['success' => true, 'url' => $targetPath, 'filename' => $filename]);
            } else {
                echo json_encode(['success' => false, 'error' => 'Upload failed']);
            }
        } else {
            echo json_encode(['success' => false, 'error' => 'No file uploaded']);
        }
        exit;
    }
    
    if ($_GET['action'] === 'deleteMedia') {
        $input = json_decode(file_get_contents('php://input'), true);
        if (!isset($input['url']) || $input['url'] === '') {
            echo json_encode(['success' => false, 'error' => 'No file specified']);
            exit;
        }
        $raw = (string)$input['url'];
        $rel = '';
        if (preg_match('/[?&]f=([^&]+)/', $raw, $m)) {
            $decoded = base64_decode(rawurldecode($m[1]), true);
            if ($decoded !== false && $decoded !== '') {
                $rel = str_replace(["\0", '\\'], ['', '/'], $decoded);
            }
        }
        if ($rel === '' && preg_match('#^https?://#i', $raw)) {
            $p = parse_url($raw, PHP_URL_PATH);
            if (is_string($p) && $p !== '') {
                $p = str_replace('\\', '/', $p);
                $pos = stripos($p, '/media/');
                if ($pos !== false) {
                    $rel = ltrim(substr($p, $pos + 1), '/');
                }
            }
        }
        if ($rel === '') {
            $rel = str_replace(["\0", '\\'], ['', '/'], $raw);
        }
        $rel = str_replace('\\', '/', (string)$rel);
        $rel = ltrim($rel, '/');
        $mediaIdx = stripos($rel, 'media/');
        if ($mediaIdx !== false && $mediaIdx > 0) {
            $rel = substr($rel, $mediaIdx);
        }
        if ($rel === '' || strpos($rel, '..') !== false) {
            echo json_encode(['success' => false, 'error' => 'Invalid path']);
            exit;
        }
        if (stripos($rel, 'media/') !== 0) {
            echo json_encode(['success' => false, 'error' => 'File not found']);
            exit;
        }
        $root = defined('GRID_ROOT') ? GRID_ROOT : dirname(__DIR__);
        $full = $root . DIRECTORY_SEPARATOR . str_replace('/', DIRECTORY_SEPARATOR, $rel);
        $real = realpath($full);
        $delChk = validarRutaSegura($real !== false ? $real : $full);
        if ($real === false || !$delChk['ok']) {
            echo json_encode(['success' => false, 'error' => 'File not found']);
            exit;
        }
        $real = $delChk['real'];
        if (is_file($real) || is_link($real)) {
            @unlink($real);
            echo json_encode(['success' => true]);
        } elseif (is_dir($real)) {
            echo json_encode(['success' => false, 'error' => 'Not a file']);
        } else {
            echo json_encode(['success' => false, 'error' => 'File not found']);
        }
        exit;
    }

    // ========== EXPLORADOR DE ARCHIVOS (WebDAV/FTP helpers) ==========
    function explorerNormalizeRelPath($path) {
        $path = (string)$path;
        $path = str_replace(['\\', "\0"], ['/', ''], $path);
        $path = ltrim($path, '/');
        if (strpos($path, '..') !== false) return '';
        $parts = array_values(array_filter(explode('/', $path)));
        $safeParts = [];
        foreach ($parts as $p) {
            if ($p === '.' || $p === '' || $p === '..') continue;
            $safeParts[] = $p;
        }
        return implode('/', $safeParts);
    }

    function explorerSafeBasename($name) {
        $name = (string)$name;
        $name = str_replace(["\0"], '', $name);
        $base = basename($name);
        if ($base === '.' || $base === '..') return '';
        return $base;
    }

    function explorerGetConnectionById($currentSettings, $connId) {
        $connections = isset($currentSettings['fileExplorerConnections']) && is_array($currentSettings['fileExplorerConnections'])
            ? $currentSettings['fileExplorerConnections']
            : [];
        foreach ($connections as $c) {
            if (!isset($c['id'])) continue;
            if ((string)$c['id'] === (string)$connId) return $c;
        }
        return null;
    }

    function explorerWebdavJoinUrl($baseUrl, $relPath, $name = null) {
        $b = rtrim((string)$baseUrl, '/');
        $p = explorerNormalizeRelPath($relPath);
        $encoded = $p !== '' ? implode('/', array_map('rawurlencode', explode('/', $p))) : '';
        $u = $b . ($encoded !== '' ? '/' . $encoded : '');
        if ($name !== null) {
            $bn = explorerSafeBasename($name);
            if ($bn === '') return $b;
            $u .= '/' . rawurlencode($bn);
        }
        return $u;
    }

    function explorerDavRequest($method, $url, $username = '', $password = '', $body = null, $extraHeaders = []) {
        $ch = curl_init($url);
        curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
        curl_setopt($ch, CURLOPT_CUSTOMREQUEST, $method);
        curl_setopt($ch, CURLOPT_FOLLOWLOCATION, true);
        curl_setopt($ch, CURLOPT_CONNECTTIMEOUT, 15);
        curl_setopt($ch, CURLOPT_TIMEOUT, 60);
        if ($username !== '' || $password !== '') {
            curl_setopt($ch, CURLOPT_USERPWD, $username . ':' . $password);
        }
        if ($body !== null) {
            curl_setopt($ch, CURLOPT_POSTFIELDS, $body);
        }
        if (is_array($extraHeaders) && count($extraHeaders) > 0) {
            curl_setopt($ch, CURLOPT_HTTPHEADER, $extraHeaders);
        }
        $respBody = curl_exec($ch);
        $err = curl_error($ch);
        $code = curl_getinfo($ch, CURLINFO_HTTP_CODE);
        curl_close($ch);
        return ['success' => $respBody !== false, 'body' => $respBody ?: '', 'error' => $err ?: '', 'code' => $code];
    }

    function explorerEnsureDirRecursiveFtp($conn, $remoteDir) {
        $remoteDir = explorerNormalizeRelPath($remoteDir);
        if ($remoteDir === '') return true;
        $parts = explode('/', $remoteDir);
        $curr = '';
        foreach ($parts as $p) {
            $curr = ($curr === '' ? $p : $curr . '/' . $p);
            @ftp_mkdir($conn, $curr);
        }
        return true;
    }

    /**
     * Parsea una línea de LIST (ftp_rawlist) para obtener nombre y tipo (misma heurística que explorerConnList).
     * @return array{name:string,type:string}|null
     */
    function explorerFtpParseRawListLine(string $line): ?array {
        $line = trim($line);
        if ($line === '') return null;
        if (preg_match('/^([\-ld])([rwx\-]{9})\s+\d+\s+\S+\s+\S+\s+(\d+)\s+([A-Za-z]{3})\s+(\d{1,2})\s+([\d:]{4,5}|\d{4})\s+(.*)$/', $line, $m)) {
            $kind = $m[1];
            $name = (string)$m[8];
            $arrowPos = strpos($name, ' -> ');
            if ($arrowPos !== false) $name = substr($name, 0, $arrowPos);
            $name = trim($name);
            if ($name === '' || $name === '.' || $name === '..') return null;
            return ['name' => $name, 'type' => ($kind === 'd') ? 'dir' : 'file'];
        }
        if (preg_match('/^(\d{2}-\d{2}-\d{2})\s+(\d{2}:\d{2}[AP]M)\s+(<DIR>|\d+)\s+(.*)$/', $line, $m)) {
            $dirOrSize = strtoupper((string)$m[3]);
            $name = trim((string)$m[4]);
            if ($name === '' || $name === '.' || $name === '..') return null;
            $isDir = ($dirOrSize === '<DIR>');
            return ['name' => $name, 'type' => $isDir ? 'dir' : 'file'];
        }
        $name = $line;
        $arrowPos = strpos($name, ' -> ');
        if ($arrowPos !== false) $name = substr($name, 0, $arrowPos);
        $name = trim($name);
        if ($name === '' || $name === '.' || $name === '..') return null;
        return ['name' => $name, 'type' => 'file'];
    }

    /**
     * Lista hijos de un directorio FTP (MLSD con fallback a LIST), para borrado recursivo.
     * @return array<int, array{name:string,isDir:bool}>
     */
    function explorerFtpListChildrenMeta($ftp, string $remoteDir): array {
        $remoteDir = explorerNormalizeRelPath($remoteDir);
        $out = [];
        $listArg = ($remoteDir === '' ? '.' : $remoteDir);

        if (function_exists('ftp_mlsd')) {
            $mlsd = @ftp_mlsd($ftp, $listArg);
            if (is_array($mlsd) && count($mlsd) > 0) {
                foreach ($mlsd as $e) {
                    if (!is_array($e)) continue;
                    $name = isset($e['name']) ? (string)$e['name'] : '';
                    if ($name === '' || $name === '.' || $name === '..') continue;
                    $typeFact = strtolower((string)($e['type'] ?? ''));
                    $isDir = ($typeFact === 'dir' || $typeFact === 'cdir' || $typeFact === 'pdir');
                    $out[] = ['name' => $name, 'isDir' => $isDir];
                }
                return $out;
            }
        }

        $tryRaw = function ($dir) use ($ftp) {
            $raw = @ftp_rawlist($ftp, $dir);
            return is_array($raw) ? $raw : [];
        };
        $raw = $tryRaw($remoteDir);
        if (count($raw) === 0 && $remoteDir !== '') {
            $raw = $tryRaw(rtrim($remoteDir, '/') . '/');
        }
        if (count($raw) === 0 && $remoteDir !== '' && $remoteDir !== '.') {
            $origPwd = @ftp_pwd($ftp);
            if (@ftp_chdir($ftp, $remoteDir)) {
                $raw = $tryRaw('.');
                if ($origPwd !== false && $origPwd !== '') {
                    @ftp_chdir($ftp, (string)$origPwd);
                }
            }
        }
        foreach ($raw as $line) {
            $p = explorerFtpParseRawListLine((string)$line);
            if (!$p) continue;
            $out[] = ['name' => $p['name'], 'isDir' => ($p['type'] === 'dir')];
        }
        return $out;
    }

    /**
     * Elimina un directorio FTP aunque no esté vacío (archivos y subcarpetas).
     */
    function explorerFtpDeleteRecursive($ftp, string $remoteTarget): bool {
        $remoteTarget = explorerNormalizeRelPath($remoteTarget);
        if ($remoteTarget === '') {
            return false;
        }
        $children = explorerFtpListChildrenMeta($ftp, $remoteTarget);
        foreach ($children as $c) {
            $child = $remoteTarget . '/' . $c['name'];
            if ($c['isDir']) {
                if (!explorerFtpDeleteRecursive($ftp, $child)) {
                    return false;
                }
            } else {
                if (!@ftp_delete($ftp, $child)) {
                    return false;
                }
            }
        }
        return (bool)@ftp_rmdir($ftp, $remoteTarget);
    }

    function explorerGuessMimeType($name) {
        $n = strtolower((string)$name);
        $ext = pathinfo($n, PATHINFO_EXTENSION);
        if ($ext === '') return 'application/octet-stream';
        $map = [
            'jpg' => 'image/jpeg',
            'jpeg' => 'image/jpeg',
            'png' => 'image/png',
            'gif' => 'image/gif',
            'webp' => 'image/webp',
            'svg' => 'image/svg+xml',
            'mp4' => 'video/mp4',
            'webm' => 'video/webm',
            'ogg' => 'video/ogg',
            'ogv' => 'video/ogg',
            'mp3' => 'audio/mpeg',
            'wav' => 'audio/wav',
            'flac' => 'audio/flac',
            'pdf' => 'application/pdf',
            'txt' => 'text/plain',
            'md' => 'text/plain',
            'csv' => 'text/csv',
            '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',
        ];
        if (isset($map[$ext])) return $map[$ext];
        return 'application/octet-stream';
    }

    /**
     * Descarga un archivo remoto (WebDAV/FTP) a una ruta local de caché.
     * @return array{ok:bool, error?:string}
     */
    function explorerRemoteDownloadToCache($conn, $relPath, $name, $cachePath) {
        $type = strtolower((string)($conn['type'] ?? ''));
        if ($type === 'webdav') {
            $baseUrl = isset($conn['baseUrl']) ? (string)$conn['baseUrl'] : (string)($conn['url'] ?? '');
            if ($baseUrl === '') {
                return ['ok' => false, 'error' => 'WebDAV baseUrl faltante'];
            }
            $url = explorerWebdavJoinUrl($baseUrl, $relPath, $name);
            $user = isset($conn['username']) ? (string)$conn['username'] : '';
            $pass = isset($conn['password']) ? (string)$conn['password'] : '';
            $ch = curl_init($url);
            curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
            curl_setopt($ch, CURLOPT_FOLLOWLOCATION, true);
            curl_setopt($ch, CURLOPT_CONNECTTIMEOUT, 15);
            curl_setopt($ch, CURLOPT_TIMEOUT, 120);
            if ($user !== '' || $pass !== '') {
                curl_setopt($ch, CURLOPT_USERPWD, $user . ':' . $pass);
            }
            $data = curl_exec($ch);
            $err = curl_error($ch);
            $code = curl_getinfo($ch, CURLINFO_HTTP_CODE);
            curl_close($ch);
            if ($data === false || $code >= 400) {
                return ['ok' => false, 'error' => 'No se pudo descargar desde WebDAV', 'detail' => $err, 'code' => $code];
            }
            file_put_contents($cachePath, $data);
            return ['ok' => true];
        }
        if ($type === 'ftp') {
            $host = isset($conn['host']) ? (string)$conn['host'] : '';
            if ($host === '') {
                return ['ok' => false, 'error' => 'FTP host faltante'];
            }
            $port = isset($conn['port']) ? (int)$conn['port'] : 21;
            $user = isset($conn['username']) ? (string)$conn['username'] : '';
            $pass = isset($conn['password']) ? (string)$conn['password'] : '';
            $root = isset($conn['root']) ? (string)$conn['root'] : '';
            $root = explorerNormalizeRelPath($root);
            $remoteDir = $root;
            if ($relPath !== '') {
                $remoteDir = ($remoteDir !== '' ? $remoteDir . '/' : '') . $relPath;
            }
            $remoteFile = ($remoteDir !== '' ? $remoteDir . '/' : '') . $name;

            $ftp = @ftp_connect($host, $port, 15);
            if (!$ftp) {
                return ['ok' => false, 'error' => 'No se pudo conectar por FTP'];
            }
            @ftp_login($ftp, $user, $pass);
            $pasv = isset($conn['pasv']) ? (bool)$conn['pasv'] : true;
            @ftp_pasv($ftp, $pasv);
            $ok = @ftp_get($ftp, $cachePath, $remoteFile, FTP_BINARY);
            @ftp_close($ftp);
            if (!$ok) {
                return ['ok' => false, 'error' => 'No se pudo descargar desde FTP'];
            }
            return ['ok' => true];
        }
        return ['ok' => false, 'error' => 'Tipo de conexión remota no soportado'];
    }

    /**
     * Invalida caché de listados remotos (WebDAV/FTP) para una conexión.
     * Se guarda en media/explorer/remote-cache/<connId>/list_*.json
     */
    function explorerInvalidateRemoteListCache(string $connId): void {
        $connKey = preg_replace('/[^a-zA-Z0-9_\-]/', '_', $connId);
        $cacheDir = GRID_ROOT . '/media/explorer/remote-cache/' . $connKey;
        if (!is_dir($cacheDir)) return;
        $items = @scandir($cacheDir);
        if (!is_array($items)) return;
        foreach ($items as $it) {
            if ($it === '.' || $it === '..') continue;
            if (strpos($it, 'list_') !== 0) continue;
            $full = $cacheDir . DIRECTORY_SEPARATOR . $it;
            if (is_file($full)) @unlink($full);
        }
    }

    if (isset($_GET['action']) && $_GET['action'] === 'explorerConnPreview') {
        $connId = isset($_GET['connId']) ? (string)$_GET['connId'] : '';
        $relPath = isset($_GET['path']) ? (string)$_GET['path'] : '';
        $name = isset($_GET['name']) ? (string)$_GET['name'] : '';
        $sizeHint = isset($_GET['size']) ? (int)$_GET['size'] : 0;
        $relPath = explorerNormalizeRelPath($relPath);
        $name = explorerSafeBasename($name);

        $maxPreviewBytes = 15 * 1024 * 1024;
        if ($connId === '' || $name === '') {
            http_response_code(400);
            exit;
        }
        if ($sizeHint > $maxPreviewBytes) {
            http_response_code(413);
            exit;
        }

        $currentSettings = grid_runtime_settings_read($settingsFile, $settingsBackupFile, $defaultSettings);
        $conn = explorerGetConnectionById($currentSettings, $connId);
        if (!$conn) {
            http_response_code(404);
            exit;
        }

        $guessMime = explorerGuessMimeType($name);
        if (strpos($guessMime, 'image/') !== 0 && strpos($guessMime, 'video/') !== 0) {
            http_response_code(400);
            exit;
        }

        $connKey = preg_replace('/[^a-zA-Z0-9_\-]/', '_', $connId);
        $cacheDir = GRID_ROOT . '/media/explorer/remote-cache/' . $connKey;
        if (!is_dir($cacheDir)) {
            mkdir($cacheDir, 0755, true);
        }
        $hash = md5($connId . "\0" . $relPath . "\0" . $name);
        $ext = pathinfo($name, PATHINFO_EXTENSION);
        $safeExt = ($ext !== '' && preg_match('/^[a-zA-Z0-9]+$/', (string)$ext)) ? ('.' . strtolower($ext)) : '';
        $cachePath = $cacheDir . '/pv_' . $hash . $safeExt;

        if (!is_file($cachePath) || filesize($cachePath) === 0) {
            $dl = explorerRemoteDownloadToCache($conn, $relPath, $name, $cachePath);
            if (!$dl['ok']) {
                @unlink($cachePath);
                http_response_code(404);
                exit;
            }
            $finfo = finfo_open(FILEINFO_MIME_TYPE);
            $detected = $finfo ? finfo_file($finfo, $cachePath) : '';
            if ($finfo) {
                finfo_close($finfo);
            }
            $detected = $detected ?: explorerGuessMimeType($name);
            if (strpos($detected, 'image/') !== 0 && strpos($detected, 'video/') !== 0) {
                @unlink($cachePath);
                http_response_code(400);
                exit;
            }
        }

        $relMedia = 'media/explorer/remote-cache/' . $connKey . '/' . basename($cachePath);
        $encoded = base64_encode($relMedia);
        $scriptDir = rtrim(dirname($_SERVER['SCRIPT_NAME'] ?? ''), '/\\');
        $prefix = ($scriptDir === '' || $scriptDir === '.') ? '' : $scriptDir;
        $loc = $prefix . '/file.php?f=' . rawurlencode($encoded);
        header('Location: ' . $loc, true, 302);
        exit;
    }

    // ========== EXPLORADOR DE ARCHIVOS (local) ==========
    // grid_explorer_local_base_dir() y helpers en user_settings_helpers.php

    if ($_GET['action'] === 'explorerSharedSpacesGet') {
        $workspace = grid_read_settings_for_api($settingsFile, $settingsBackupFile, $defaultSettings);
        $actor = grid_explorer_shared_actor();
        echo json_encode([
            'success' => true,
            'data' => grid_explorer_shared_spaces_payload_for_actor($workspace, $actor),
        ], JSON_UNESCAPED_UNICODE);
        exit;
    }

    if ($_GET['action'] === 'explorerSharedSpacesSave' && $_SERVER['REQUEST_METHOD'] === 'POST') {
        $input = json_decode(file_get_contents('php://input'), true);
        if (!is_array($input)) {
            echo json_encode(['success' => false, 'error' => 'JSON inválido']);
            exit;
        }
        $workspace = grid_read_settings_for_api($settingsFile, $settingsBackupFile, $defaultSettings);
        $actor = grid_explorer_shared_actor();
        $patch = isset($input['patch']) && is_array($input['patch']) ? $input['patch'] : $input;
        $applied = grid_explorer_shared_spaces_apply_patch($workspace, $patch, $actor);
        if (empty($applied['ok'])) {
            echo json_encode(['success' => false, 'error' => (string) ($applied['error'] ?? 'Error')], JSON_UNESCAPED_UNICODE);
            exit;
        }
        $workspaceOnly = grid_workspace_settings_only($applied['settings']);
        $ok = grid_runtime_settings_save_patch($settingsFile, $settingsBackupFile, $defaultSettings, [
            'explorerSharedSpaces' => $workspaceOnly['explorerSharedSpaces'] ?? grid_explorer_shared_spaces_defaults(),
        ]);
        if (!$ok) {
            echo json_encode(['success' => false, 'error' => 'No se pudo guardar la configuración'], JSON_UNESCAPED_UNICODE);
            exit;
        }
        $fresh = grid_read_settings_for_api($settingsFile, $settingsBackupFile, $defaultSettings);
        echo json_encode([
            'success' => true,
            'data' => grid_explorer_shared_spaces_payload_for_actor($fresh, $actor),
        ], JSON_UNESCAPED_UNICODE);
        exit;
    }

    if ($_GET['action'] === 'explorerList') {
        $input = json_decode(file_get_contents('php://input'), true);
        if (!is_array($input)) {
            $input = [];
        }
        $bucket = isset($input['bucket']) ? (string)$input['bucket'] : 'files'; // files | trash | cache | community | department
        $relPath = isset($input['path']) ? (string)$input['path'] : '';
        $workspace = grid_read_settings_for_api($settingsFile, $settingsBackupFile, $defaultSettings);

        if ($bucket === 'trash') {
            $baseDir = grid_explorer_local_trash_base();
            $urlPrefix = grid_explorer_local_url_prefix('trash');
        } else {
            $ctx = grid_explorer_resolve_storage_context($input, $workspace);
            if (empty($ctx['ok'])) {
                echo json_encode(['success' => false, 'error' => (string) ($ctx['error'] ?? 'Sin acceso')], JSON_UNESCAPED_UNICODE);
                exit;
            }
            $baseDir = (string) $ctx['baseDir'];
            $urlPrefix = (string) $ctx['urlPrefix'];
            $bucket = (string) $ctx['bucket'];
        }

        // Normalizar ruta relativa (sin ..)
        $relPath = str_replace(['\\', "\0"], ['/', ''], $relPath);
        $relPath = ltrim($relPath, '/');
        $parts = array_values(array_filter(explode('/', $relPath)));
        $safeParts = [];
        foreach ($parts as $p) {
            if ($p === '.' || $p === '') continue;
            if ($p === '..') continue;
            $safeParts[] = $p;
        }
        $safeRelPath = implode(DIRECTORY_SEPARATOR, $safeParts);
        $targetDir = $baseDir . ($safeRelPath !== '' ? DIRECTORY_SEPARATOR . $safeRelPath : '');

        if (!is_dir($targetDir)) {
            echo json_encode(['success' => false, 'error' => 'Directorio no encontrado']);
            exit;
        }

        $entries = scandir($targetDir);
        if ($entries === false) {
            echo json_encode(['success' => false, 'error' => 'No se pudo leer el directorio']);
            exit;
        }

        $items = [];
        $finfo = finfo_open(FILEINFO_MIME_TYPE);
        foreach ($entries as $name) {
            if ($name === '.' || $name === '..') continue;
            $full = $targetDir . DIRECTORY_SEPARATOR . $name;
            $isDir = is_dir($full);
            $mime = '';
            $size = 0;
            $mtime = filemtime($full) ?: 0;
            if (!$isDir) {
                $mime = finfo_file($finfo, $full) ?: '';
                $size = filesize($full) ?: 0;
            }
            $itemRel = ($safeRelPath !== '' ? $safeRelPath . DIRECTORY_SEPARATOR . $name : $name);
            $urlPath = '';
            if ($bucket === 'trash') {
                $urlPath = $urlPrefix . str_replace(DIRECTORY_SEPARATOR, '/', $itemRel);
            } elseif ($bucket === 'cache') {
                $urlPath = 'media/explorer/remote-cache/' . str_replace(DIRECTORY_SEPARATOR, '/', $itemRel);
            } else {
                $urlPath = $urlPrefix . str_replace(DIRECTORY_SEPARATOR, '/', $itemRel);
            }
            $items[] = [
                'name' => $name,
                'type' => $isDir ? 'dir' : 'file',
                'mimeType' => $mime,
                'size' => $size,
                'mtime' => $mtime,
                'path' => ($safeRelPath !== '' ? $safeRelPath : ''),
                'url' => $urlPath
            ];
        }
        finfo_close($finfo);

        if ($bucket === 'files') {
            require_once GRID_ROOT . '/includes/flexia_disk.php';
            $actor = grid_explorer_shared_actor();
            $items = grid_flexia_disk_append_root_listing_entry($items, $safeRelPath, $actor, $urlPrefix);
        }

        echo json_encode(['success' => true, 'items' => $items, 'path' => $relPath, 'bucket' => $bucket]);
        exit;
    }

    /**
     * Metadatos de un elemento (archivo o carpeta) bajo explorerList: tamaño, fechas, conteo hijos en directorios.
     */
    if ($_GET['action'] === 'explorerStat') {
        $input = json_decode(file_get_contents('php://input'), true);
        if (!is_array($input)) {
            $input = [];
        }
        $bucket = isset($input['bucket']) ? (string)$input['bucket'] : 'files';
        $relPath = isset($input['path']) ? (string)$input['path'] : '';
        $name = isset($input['name']) ? (string)$input['name'] : '';
        if ($name === '' || strpos($name, '/') !== false || strpos($name, '\\') !== false || $name === '.' || $name === '..') {
            echo json_encode(['success' => false, 'error' => 'Nombre no válido']);
            exit;
        }
        $workspace = grid_read_settings_for_api($settingsFile, $settingsBackupFile, $defaultSettings);
        $ctx = grid_explorer_resolve_storage_context($input, $workspace);
        if (empty($ctx['ok'])) {
            echo json_encode(['success' => false, 'error' => (string) ($ctx['error'] ?? 'Sin acceso')], JSON_UNESCAPED_UNICODE);
            exit;
        }
        $baseDir = (string) $ctx['baseDir'];
        $relPath = str_replace(['\\', "\0"], ['/', ''], $relPath);
        $relPath = ltrim($relPath, '/');
        $parts = array_values(array_filter(explode('/', $relPath)));
        $safeParts = [];
        foreach ($parts as $p) {
            if ($p === '.' || $p === '') {
                continue;
            }
            if ($p === '..') {
                continue;
            }
            $safeParts[] = $p;
        }
        $safeRelPath = implode(DIRECTORY_SEPARATOR, $safeParts);
        $targetDir = $baseDir . ($safeRelPath !== '' ? DIRECTORY_SEPARATOR . $safeRelPath : '');
        if (!is_dir($targetDir)) {
            echo json_encode(['success' => false, 'error' => 'Directorio no encontrado']);
            exit;
        }
        $full = $targetDir . DIRECTORY_SEPARATOR . $name;
        if (!file_exists($full)) {
            echo json_encode(['success' => false, 'error' => 'No encontrado']);
            exit;
        }
        $realBase = realpath($baseDir);
        $realFull = realpath($full);
        if ($realBase === false || $realFull === false) {
            echo json_encode(['success' => false, 'error' => 'Ruta no válida']);
            exit;
        }
        $statChk = validarRutaSegura($realFull);
        if (!$statChk['ok']) {
            echo json_encode(['success' => false, 'error' => 'Acceso denegado']);
            exit;
        }
        if (is_dir($full)) {
            $entries = @scandir($full);
            $fileCount = 0;
            $dirCount = 0;
            $totalBytes = 0;
            if (is_array($entries)) {
                foreach ($entries as $e) {
                    if ($e === '.' || $e === '..') {
                        continue;
                    }
                    $fp = $full . DIRECTORY_SEPARATOR . $e;
                    if (is_dir($fp)) {
                        $dirCount++;
                    } else {
                        $fileCount++;
                        $totalBytes += (int) (@filesize($fp) ?: 0);
                    }
                }
            }
            $st = @stat($full);
            echo json_encode([
                'success' => true,
                'kind' => 'dir',
                'name' => $name,
                'fileCount' => $fileCount,
                'dirCount' => $dirCount,
                'childCount' => $fileCount + $dirCount,
                'totalBytes' => $totalBytes,
                'mtime' => isset($st['mtime']) ? (int) $st['mtime'] : 0,
                'ctime' => isset($st['ctime']) ? (int) $st['ctime'] : 0,
            ]);
            exit;
        }
        $st = @stat($full);
        $finfo = finfo_open(FILEINFO_MIME_TYPE);
        $mime = $finfo ? (finfo_file($finfo, $full) ?: '') : '';
        if ($finfo) {
            finfo_close($finfo);
        }
        $ext = pathinfo($name, PATHINFO_EXTENSION);
        echo json_encode([
            'success' => true,
            'kind' => 'file',
            'name' => $name,
            'size' => isset($st['size']) ? (int) $st['size'] : 0,
            'mtime' => isset($st['mtime']) ? (int) $st['mtime'] : 0,
            'ctime' => isset($st['ctime']) ? (int) $st['ctime'] : 0,
            'mimeType' => $mime,
            'extension' => is_string($ext) ? $ext : '',
        ]);
        exit;
    }

    if ($_GET['action'] === 'shareCreate') {
        require_once __DIR__ . '/share_crypto.php';
        $input = json_decode(file_get_contents('php://input'), true);
        $bucket = isset($input['bucket']) ? (string)$input['bucket'] : 'files';
        if ($bucket !== 'files' && $bucket !== 'cache') {
            echo json_encode(['success' => false, 'error' => 'Bucket no válido']);
            exit;
        }
        $relPath = explorerNormalizeRelPath(isset($input['path']) ? (string)$input['path'] : '');
        $name = explorerSafeBasename(isset($input['name']) ? (string)$input['name'] : '');
        $type = isset($input['type']) && $input['type'] === 'dir' ? 'dir' : 'file';
        if ($name === '') {
            echo json_encode(['success' => false, 'error' => 'Nombre no válido']);
            exit;
        }
        $mediaPrefix = $bucket === 'cache' ? 'media/explorer/remote-cache' : rtrim(grid_explorer_local_url_prefix('files'), '/');
        $mediaRel = $mediaPrefix . ($relPath !== '' ? '/' . $relPath : '') . '/' . $name;
        $mediaRel = grid_share_normalize_media_rel($mediaRel);
        $fullFs = GRID_ROOT . DIRECTORY_SEPARATOR . str_replace('/', DIRECTORY_SEPARATOR, $mediaRel);
        $real = realpath($fullFs);
        if ($real === false) {
            echo json_encode(['success' => false, 'error' => 'No encontrado']);
            exit;
        }
        $shareChk = validarRutaSegura($real);
        if (!$shareChk['ok']) {
            echo json_encode(['success' => false, 'error' => 'Acceso denegado']);
            exit;
        }
        $real = $shareChk['real'];
        if ($type === 'dir' && !is_dir($real)) {
            echo json_encode(['success' => false, 'error' => 'No es una carpeta']);
            exit;
        }
        if ($type === 'file' && !is_file($real)) {
            echo json_encode(['success' => false, 'error' => 'No es un archivo']);
            exit;
        }
        $exp = time() + (86400 * 30);
        if ($type === 'file') {
            $payload = ['v' => 1, 'exp' => $exp, 'kind' => 'file', 'mediaPath' => $mediaRel];
        } else {
            $payload = ['v' => 1, 'exp' => $exp, 'kind' => 'folder', 'rootMediaPath' => $mediaRel];
        }
        $token = grid_share_pack_token($payload);
        if ($token === '') {
            echo json_encode(['success' => false, 'error' => 'No se pudo crear el enlace']);
            exit;
        }
        $scriptDir = rtrim(dirname($_SERVER['SCRIPT_NAME'] ?? ''), '/\\');
        $prefix = ($scriptDir === '' || $scriptDir === '.') ? '' : $scriptDir;
        if ($type === 'file') {
            $url = $prefix . '/file.php?t=' . rawurlencode($token);
        } else {
            $url = $prefix . '/share.php?t=' . rawurlencode($token);
        }
        echo json_encode(['success' => true, 'url' => $url, 'token' => $token]);
        exit;
    }

    if ($_GET['action'] === 'shareList') {
        require_once __DIR__ . '/share_crypto.php';
        $input = json_decode(file_get_contents('php://input'), true);
        $token = isset($input['token']) ? (string)$input['token'] : '';
        $subPath = isset($input['path']) ? (string)$input['path'] : '';
        $subPath = str_replace(["\0", '\\'], ['', '/'], $subPath);
        $subPath = ltrim($subPath, '/');
        if (strpos($subPath, '..') !== false) {
            echo json_encode(['success' => false, 'error' => 'Ruta no válida']);
            exit;
        }
        $payload = grid_share_unpack_token($token);
        if (!$payload || !isset($payload['kind']) || $payload['kind'] !== 'folder' || !isset($payload['rootMediaPath'])) {
            echo json_encode(['success' => false, 'error' => 'Enlace inválido o caducado']);
            exit;
        }
        $rootReal = grid_share_resolve_under_media($payload['rootMediaPath']);
        if ($rootReal === false || !is_dir($rootReal)) {
            echo json_encode(['success' => false, 'error' => 'Carpeta no encontrada']);
            exit;
        }
        $parts = array_values(array_filter(explode('/', $subPath), function ($p) {
            return $p !== '' && $p !== '.' && $p !== '..';
        }));
        $suffix = implode(DIRECTORY_SEPARATOR, $parts);
        $targetDir = $suffix === '' ? $rootReal : ($rootReal . DIRECTORY_SEPARATOR . $suffix);
        $targetDir = realpath($targetDir);
        if ($targetDir === false || !is_dir($targetDir)) {
            echo json_encode(['success' => false, 'error' => 'Directorio no encontrado']);
            exit;
        }
        if (!grid_path_resolved_under_parent($targetDir, $rootReal)) {
            echo json_encode(['success' => false, 'error' => 'Acceso denegado']);
            exit;
        }
        $entries = scandir($targetDir);
        if ($entries === false) {
            echo json_encode(['success' => false, 'error' => 'No se pudo leer el directorio']);
            exit;
        }
        $relSubDisplay = $suffix === '' ? '' : str_replace(DIRECTORY_SEPARATOR, '/', $suffix);
        $items = [];
        $finfo = finfo_open(FILEINFO_MIME_TYPE);
        foreach ($entries as $name) {
            if ($name === '.' || $name === '..') {
                continue;
            }
            $full = $targetDir . DIRECTORY_SEPARATOR . $name;
            $isDir = is_dir($full);
            $mime = '';
            $size = 0;
            $mtime = filemtime($full) ?: 0;
            if (!$isDir) {
                $mime = finfo_file($finfo, $full) ?: '';
                $size = filesize($full) ?: 0;
            }
            $childRel = $relSubDisplay === '' ? $name : ($relSubDisplay . '/' . $name);
            $items[] = [
                'name' => $name,
                'type' => $isDir ? 'dir' : 'file',
                'mimeType' => $mime,
                'size' => $size,
                'mtime' => $mtime,
                'relPath' => $childRel,
            ];
        }
        finfo_close($finfo);
        echo json_encode([
            'success' => true,
            'items' => $items,
            'path' => $relSubDisplay,
            'rootName' => basename($rootReal),
        ]);
        exit;
    }

    if ($_GET['action'] === 'explorerSearch') {
        $input = json_decode(file_get_contents('php://input'), true);
        if (!is_array($input)) {
            $input = [];
        }
        $q = isset($input['q']) ? strtolower(trim((string)$input['q'])) : '';
        $bucket = isset($input['bucket']) ? (string)$input['bucket'] : 'files';
        if ($q === '' || strlen($q) > 200) {
            echo json_encode(['success' => true, 'items' => []]);
            exit;
        }
        $workspace = grid_read_settings_for_api($settingsFile, $settingsBackupFile, $defaultSettings);
        $ctx = grid_explorer_resolve_storage_context(array_merge($input, ['bucket' => $bucket]), $workspace);
        if (empty($ctx['ok'])) {
            echo json_encode(['success' => true, 'items' => []]);
            exit;
        }
        $filesBasePath = (string) $ctx['baseDir'];
        $urlPrefix = (string) $ctx['urlPrefix'];
        $bucket = (string) $ctx['bucket'];
        $filesBase = realpath($filesBasePath);
        if (!$filesBase || !is_dir($filesBase)) {
            echo json_encode(['success' => true, 'items' => []]);
            exit;
        }
        $results = [];
        $max = 400;
        $count = 0;
        $finfo = finfo_open(FILEINFO_MIME_TYPE);
        $walk = function ($dir, $rel) use (&$walk, &$results, &$count, $max, $q, $filesBase, $finfo, $urlPrefix) {
            if ($count >= $max) {
                return;
            }
            $items = @scandir($dir);
            if ($items === false) {
                return;
            }
            foreach ($items as $it) {
                if ($it === '.' || $it === '..') {
                    continue;
                }
                if ($count >= $max) {
                    break;
                }
                $full = $dir . DIRECTORY_SEPARATOR . $it;
                $relPath = $rel === '' ? $it : $rel . '/' . $it;
                $match = stripos($it, $q) !== false;
                if (is_dir($full)) {
                    if ($match) {
                        $results[] = [
                            'name' => $it,
                            'type' => 'dir',
                            'parentPath' => $rel,
                            'relPath' => $relPath
                        ];
                        $count++;
                    }
                    $walk($full, $relPath);
                } else {
                    if ($match) {
                        $mime = finfo_file($finfo, $full) ?: '';
                        $size = filesize($full) ?: 0;
                        $parentRel = $rel;
                        $urlPath = $urlPrefix . str_replace(DIRECTORY_SEPARATOR, '/', $relPath);
                        $results[] = [
                            'name' => $it,
                            'type' => 'file',
                            'parentPath' => $parentRel,
                            'relPath' => $relPath,
                            'mimeType' => $mime,
                            'size' => $size,
                            'url' => $urlPath
                        ];
                        $count++;
                    }
                }
            }
        };
        $walk($filesBase, '');
        finfo_close($finfo);
        echo json_encode(['success' => true, 'items' => $results, 'truncated' => $count >= $max, 'bucket' => $bucket]);
        exit;
    }

    if ($_GET['action'] === 'explorerCreateDir') {
        $input = json_decode(file_get_contents('php://input'), true);
        if (!is_array($input)) {
            $input = [];
        }
        $path = isset($input['path']) ? (string)$input['path'] : '';
        $name = isset($input['name']) ? (string)$input['name'] : '';
        $bucket = isset($input['bucket']) ? (string)$input['bucket'] : 'files';

        if ($name === '') {
            echo json_encode(['success' => false, 'error' => 'Nombre inválido']);
            exit;
        }

        $workspace = grid_read_settings_for_api($settingsFile, $settingsBackupFile, $defaultSettings);
        $ctx = grid_explorer_resolve_storage_context($input, $workspace);
        if (empty($ctx['ok'])) {
            echo json_encode(['success' => false, 'error' => (string) ($ctx['error'] ?? 'Sin acceso')], JSON_UNESCAPED_UNICODE);
            exit;
        }
        $filesBase = (string) $ctx['baseDir'];
        $path = str_replace(['\\', "\0"], ['/', ''], $path);
        $path = ltrim($path, '/');
        if (strpos($path, '..') !== false) {
            echo json_encode(['success' => false, 'error' => 'Ruta inválida']);
            exit;
        }

        $targetParent = $filesBase . ($path !== '' ? '/' . str_replace('/', '/', $path) : '');
        $targetDir = $targetParent . '/' . $name;

        if (!is_dir($targetParent)) {
            if (!mkdir($targetParent, 0755, true)) {
                echo json_encode(['success' => false, 'error' => 'No se pudo crear la ruta']);
                exit;
            }
        }

        if (file_exists($targetDir)) {
            echo json_encode(['success' => false, 'error' => 'Ya existe']);
            exit;
        }

        if (!mkdir($targetDir, 0755, true)) {
            echo json_encode(['success' => false, 'error' => 'No se pudo crear carpeta']);
            exit;
        }

        echo json_encode(['success' => true]);
        exit;
    }

    if ($_GET['action'] === 'explorerUpload') {
        $path = isset($_POST['path']) ? (string)$_POST['path'] : '';
        $uploadBucket = isset($_POST['bucket']) ? (string)$_POST['bucket'] : 'files';
        $path = str_replace(['\\', "\0"], ['/', ''], $path);
        $path = ltrim($path, '/');
        if (strpos($path, '..') !== false) {
            echo json_encode(['success' => false, 'error' => 'Ruta inválida']);
            exit;
        }

        $uploadInput = [
            'bucket' => $uploadBucket,
            'departmentId' => isset($_POST['departmentId']) ? (string)$_POST['departmentId'] : '',
        ];
        $workspace = grid_read_settings_for_api($settingsFile, $settingsBackupFile, $defaultSettings);
        $ctx = grid_explorer_resolve_storage_context($uploadInput, $workspace);
        if (empty($ctx['ok'])) {
            echo json_encode(['success' => false, 'error' => (string) ($ctx['error'] ?? 'Sin acceso')], JSON_UNESCAPED_UNICODE);
            exit;
        }
        $filesBase = (string) $ctx['baseDir'];
        $targetParent = $filesBase . ($path !== '' ? '/' . $path : '');
        if (!is_dir($targetParent)) {
            if (!mkdir($targetParent, 0755, true)) {
                echo json_encode(['success' => false, 'error' => 'No se pudo preparar la carpeta']);
                exit;
            }
        }

        $results = [];
        if (isset($_FILES['files'])) {
            $files = $_FILES['files'];
            $count = is_array($files['name']) ? count($files['name']) : 0;
            for ($i = 0; $i < $count; $i++) {
                if ($files['error'][$i] !== UPLOAD_ERR_OK) {
                    $results[] = ['name' => $files['name'][$i], 'success' => false, 'error' => 'Upload error'];
                    continue;
                }
                $origName = $files['name'][$i];
                $tmp = $files['tmp_name'][$i];
                $safeOrigName = basename($origName);
                $dest = $targetParent . '/' . $safeOrigName;
                // Evitar colisión: si existe, prefijar timestamp
                if (file_exists($dest)) {
                    $ext = pathinfo($safeOrigName, PATHINFO_EXTENSION);
                    $base = pathinfo($safeOrigName, PATHINFO_FILENAME);
                    $dest = $targetParent . '/' . $base . '_' . time() . ($ext !== '' ? '.' . $ext : '');
                }
                $ok = move_uploaded_file($tmp, $dest);
                $results[] = [
                    'name' => $safeOrigName,
                    'success' => $ok,
                    'storedAs' => basename($dest)
                ];
            }
        }

        echo json_encode(['success' => true, 'results' => $results]);
        exit;
    }

    if ($_GET['action'] === 'explorerRename') {
        $input = json_decode(file_get_contents('php://input'), true);
        if (!is_array($input)) {
            $input = [];
        }
        $path = isset($input['path']) ? (string)$input['path'] : '';
        $oldName = isset($input['oldName']) ? (string)$input['oldName'] : '';
        $newName = isset($input['newName']) ? (string)$input['newName'] : '';
        $bucket = isset($input['bucket']) ? (string)$input['bucket'] : 'files';
        if ($oldName === '' || $newName === '') {
            echo json_encode(['success' => false, 'error' => 'Nombres inválidos']);
            exit;
        }
        if (!in_array($bucket, ['files', 'cache', 'community', 'department'], true)) {
            echo json_encode(['success' => false, 'error' => 'Bucket no soportado']);
            exit;
        }

        $workspace = grid_read_settings_for_api($settingsFile, $settingsBackupFile, $defaultSettings);
        $ctx = grid_explorer_resolve_storage_context($input, $workspace);
        if (empty($ctx['ok'])) {
            echo json_encode(['success' => false, 'error' => (string) ($ctx['error'] ?? 'Sin acceso')], JSON_UNESCAPED_UNICODE);
            exit;
        }
        $filesBase = (string) $ctx['baseDir'];
        $path = str_replace(['\\', "\0"], ['/', ''], $path);
        $path = ltrim($path, '/');
        if (strpos($path, '..') !== false) {
            echo json_encode(['success' => false, 'error' => 'Ruta inválida']);
            exit;
        }

        $parent = $filesBase . ($path !== '' ? '/' . $path : '');
        $src = $parent . '/' . basename($oldName);
        $dst = $parent . '/' . basename($newName);

        if (!file_exists($src)) {
            echo json_encode(['success' => false, 'error' => 'Origen no existe']);
            exit;
        }
        if (file_exists($dst)) {
            echo json_encode(['success' => false, 'error' => 'Destino ya existe']);
            exit;
        }

        if (!rename($src, $dst)) {
            echo json_encode(['success' => false, 'error' => 'No se pudo renombrar']);
            exit;
        }

        echo json_encode(['success' => true]);
        exit;
    }

    if ($_GET['action'] === 'explorerMove') {
        $input = json_decode(file_get_contents('php://input'), true);
        $fromPath = isset($input['fromPath']) ? (string)$input['fromPath'] : '';
        $toPath = isset($input['toPath']) ? (string)$input['toPath'] : '';
        $name = isset($input['name']) ? (string)$input['name'] : '';
        if ($name === '') {
            echo json_encode(['success' => false, 'error' => 'Nombre inválido']);
            exit;
        }

        $fromBucket = isset($input['fromBucket']) ? (string)$input['fromBucket'] : 'files';
        $toBucket = isset($input['toBucket']) ? (string)$input['toBucket'] : 'files';
        $allowedBuckets = ['files', 'cache', 'community', 'department'];
        if (!in_array($fromBucket, $allowedBuckets, true) || !in_array($toBucket, $allowedBuckets, true)) {
            echo json_encode(['success' => false, 'error' => 'Buckets inválidos']);
            exit;
        }

        $workspace = grid_read_settings_for_api($settingsFile, $settingsBackupFile, $defaultSettings);
        $fromCtx = grid_explorer_resolve_storage_context([
            'bucket' => $fromBucket,
            'departmentId' => isset($input['fromDepartmentId']) ? (string)$input['fromDepartmentId'] : (isset($input['departmentId']) ? (string)$input['departmentId'] : ''),
        ], $workspace);
        $toCtx = grid_explorer_resolve_storage_context([
            'bucket' => $toBucket,
            'departmentId' => isset($input['toDepartmentId']) ? (string)$input['toDepartmentId'] : (isset($input['departmentId']) ? (string)$input['departmentId'] : ''),
        ], $workspace);
        if (empty($fromCtx['ok']) || empty($toCtx['ok'])) {
            echo json_encode(['success' => false, 'error' => 'Sin acceso al origen o destino'], JSON_UNESCAPED_UNICODE);
            exit;
        }

        $sanitize = function ($p) {
            $p = str_replace(['\\', "\0"], ['/', ''], $p);
            return ltrim($p, '/');
        };
        $fromPath = $sanitize($fromPath);
        $toPath = $sanitize($toPath);
        if (strpos($fromPath, '..') !== false || strpos($toPath, '..') !== false) {
            echo json_encode(['success' => false, 'error' => 'Ruta inválida']);
            exit;
        }

        $fromBase = (string) $fromCtx['baseDir'];
        $toBase = (string) $toCtx['baseDir'];
        $srcDir = $fromBase . ($fromPath !== '' ? '/' . $fromPath : '');
        $dstParent = $toBase . ($toPath !== '' ? '/' . $toPath : '');
        $baseName = basename($name);
        $src = $srcDir . '/' . $baseName;
        $dst = $dstParent . '/' . $baseName;

        if ($srcDir === $dstParent && $fromBucket === $toBucket) {
            echo json_encode(['success' => false, 'error' => 'Origen y destino iguales']);
            exit;
        }
        if (!file_exists($src)) {
            echo json_encode(['success' => false, 'error' => 'Origen no existe']);
            exit;
        }
        $overwrite = !empty($input['overwrite']);
        $destNameRaw = isset($input['destName']) ? (string)$input['destName'] : '';
        if ($destNameRaw !== '') {
            $dstBase = basename(str_replace(["\0", '\\'], ['', '/'], $destNameRaw));
            if ($dstBase === '' || $dstBase === '.' || $dstBase === '..') {
                echo json_encode(['success' => false, 'error' => 'Nombre de destino inválido']);
                exit;
            }
            $dst = $dstParent . '/' . $dstBase;
        }
        if (file_exists($dst)) {
            if ($overwrite) {
                $delFs = function ($target) use (&$delFs) {
                    if (is_file($target) || is_link($target)) {
                        return @unlink($target);
                    }
                    if (!is_dir($target)) {
                        return true;
                    }
                    $items = @scandir($target);
                    if ($items) {
                        foreach ($items as $it) {
                            if ($it === '.' || $it === '..') {
                                continue;
                            }
                            if (!$delFs($target . DIRECTORY_SEPARATOR . $it)) {
                                return false;
                            }
                        }
                    }
                    return @rmdir($target);
                };
                if (!$delFs($dst)) {
                    echo json_encode(['success' => false, 'error' => 'No se pudo reemplazar el destino existente']);
                    exit;
                }
            } else {
                echo json_encode(['success' => false, 'error' => 'Ya existe en destino']);
                exit;
            }
        }
        if (!is_dir($dstParent)) {
            if (!mkdir($dstParent, 0755, true)) {
                echo json_encode(['success' => false, 'error' => 'No se pudo crear destino']);
                exit;
            }
        }
        if (is_dir($src)) {
            $realSrc = realpath($src);
            $realDstParent = realpath($dstParent);
            if ($realSrc && $realDstParent && strpos($realDstParent . DIRECTORY_SEPARATOR, $realSrc . DIRECTORY_SEPARATOR) === 0) {
                echo json_encode(['success' => false, 'error' => 'No se puede mover una carpeta dentro de sí misma']);
                exit;
            }
        }
        if (!rename($src, $dst)) {
            echo json_encode(['success' => false, 'error' => 'No se pudo mover']);
            exit;
        }

        echo json_encode(['success' => true]);
        exit;
    }

    if ($_GET['action'] === 'explorerCopy') {
        $input = json_decode(file_get_contents('php://input'), true);
        if (!is_array($input)) {
            $input = [];
        }
        $path = isset($input['path']) ? (string)$input['path'] : '';
        $oldName = isset($input['oldName']) ? (string)$input['oldName'] : '';
        $newName = isset($input['newName']) ? (string)$input['newName'] : '';
        $bucket = isset($input['bucket']) ? (string)$input['bucket'] : 'files';
        if ($oldName === '' || $newName === '') {
            echo json_encode(['success' => false, 'error' => 'Nombres inválidos']);
            exit;
        }
        if (!in_array($bucket, ['files', 'cache', 'community', 'department'], true)) {
            echo json_encode(['success' => false, 'error' => 'Bucket no soportado']);
            exit;
        }

        $workspace = grid_read_settings_for_api($settingsFile, $settingsBackupFile, $defaultSettings);
        $ctx = grid_explorer_resolve_storage_context($input, $workspace);
        if (empty($ctx['ok'])) {
            echo json_encode(['success' => false, 'error' => (string) ($ctx['error'] ?? 'Sin acceso')], JSON_UNESCAPED_UNICODE);
            exit;
        }
        $filesBase = (string) $ctx['baseDir'];
        $path = str_replace(['\\', "\0"], ['/', ''], $path);
        $path = ltrim($path, '/');
        if (strpos($path, '..') !== false) {
            echo json_encode(['success' => false, 'error' => 'Ruta inválida']);
            exit;
        }

        $parent = $filesBase . ($path !== '' ? '/' . $path : '');
        $src = $parent . '/' . basename($oldName);
        $dst = $parent . '/' . basename($newName);

        if (!file_exists($src)) {
            echo json_encode(['success' => false, 'error' => 'Origen no existe']);
            exit;
        }
        $overwrite = !empty($input['overwrite']);
        if (file_exists($dst)) {
            if ($overwrite) {
                $delFs = function ($target) use (&$delFs) {
                    if (is_file($target) || is_link($target)) {
                        return @unlink($target);
                    }
                    if (!is_dir($target)) {
                        return true;
                    }
                    $items = @scandir($target);
                    if ($items) {
                        foreach ($items as $it) {
                            if ($it === '.' || $it === '..') {
                                continue;
                            }
                            if (!$delFs($target . DIRECTORY_SEPARATOR . $it)) {
                                return false;
                            }
                        }
                    }
                    return @rmdir($target);
                };
                if (!$delFs($dst)) {
                    echo json_encode(['success' => false, 'error' => 'No se pudo reemplazar el destino existente']);
                    exit;
                }
            } else {
                echo json_encode(['success' => false, 'error' => 'Destino ya existe']);
                exit;
            }
        }

        $recursiveCopy = function($from, $to) use (&$recursiveCopy) {
            if (is_file($from)) {
                return @copy($from, $to);
            }
            if (!is_dir($to) && !@mkdir($to, 0755, true)) return false;
            $items = scandir($from);
            if (!$items) return false;
            foreach ($items as $it) {
                if ($it === '.' || $it === '..') continue;
                $recursiveCopy($from . DIRECTORY_SEPARATOR . $it, $to . DIRECTORY_SEPARATOR . $it);
            }
            return true;
        };

        $ok = $recursiveCopy($src, $dst);
        if (!$ok) {
            echo json_encode(['success' => false, 'error' => 'No se pudo copiar']);
            exit;
        }

        echo json_encode(['success' => true]);
        exit;
    }

    if ($_GET['action'] === 'explorerCopyTo') {
        $input = json_decode(file_get_contents('php://input'), true);
        if (!is_array($input)) {
            $input = [];
        }
        $fromPath = isset($input['fromPath']) ? (string)$input['fromPath'] : '';
        $toPath = isset($input['toPath']) ? (string)$input['toPath'] : '';
        $name = isset($input['name']) ? (string)$input['name'] : '';
        if ($name === '') {
            echo json_encode(['success' => false, 'error' => 'Nombre inválido']);
            exit;
        }
        $fromBucket = isset($input['fromBucket']) ? (string)$input['fromBucket'] : 'files';
        $toBucket = isset($input['toBucket']) ? (string)$input['toBucket'] : 'files';
        $allowedBuckets = ['files', 'cache', 'community', 'department'];
        if (!in_array($fromBucket, $allowedBuckets, true) || !in_array($toBucket, $allowedBuckets, true)) {
            echo json_encode(['success' => false, 'error' => 'Buckets inválidos']);
            exit;
        }
        $workspace = grid_read_settings_for_api($settingsFile, $settingsBackupFile, $defaultSettings);
        $fromCtx = grid_explorer_resolve_storage_context([
            'bucket' => $fromBucket,
            'departmentId' => isset($input['fromDepartmentId']) ? (string)$input['fromDepartmentId'] : (isset($input['departmentId']) ? (string)$input['departmentId'] : ''),
        ], $workspace);
        $toCtx = grid_explorer_resolve_storage_context([
            'bucket' => $toBucket,
            'departmentId' => isset($input['toDepartmentId']) ? (string)$input['toDepartmentId'] : (isset($input['departmentId']) ? (string)$input['departmentId'] : ''),
        ], $workspace);
        if (empty($fromCtx['ok']) || empty($toCtx['ok'])) {
            echo json_encode(['success' => false, 'error' => 'Sin acceso al origen o destino'], JSON_UNESCAPED_UNICODE);
            exit;
        }
        $sanitize = function ($p) {
            $p = str_replace(['\\', "\0"], ['/', ''], $p);
            return ltrim($p, '/');
        };
        $fromPath = $sanitize($fromPath);
        $toPath = $sanitize($toPath);
        if (strpos($fromPath, '..') !== false || strpos($toPath, '..') !== false) {
            echo json_encode(['success' => false, 'error' => 'Ruta inválida']);
            exit;
        }
        $autoRename = !empty($input['autoRename']);
        $fromBase = (string) $fromCtx['baseDir'];
        $toBase = (string) $toCtx['baseDir'];
        $srcDir = $fromBase . ($fromPath !== '' ? '/' . $fromPath : '');
        $dstParent = $toBase . ($toPath !== '' ? '/' . $toPath : '');
        $baseName = basename($name);
        $src = $srcDir . '/' . $baseName;
        $destNameIn = isset($input['destName']) ? (string)$input['destName'] : '';
        $dstBase = $baseName;
        if ($destNameIn !== '') {
            $dstBase = basename(str_replace(["\0", '\\'], ['', '/'], $destNameIn));
            if ($dstBase === '' || $dstBase === '.' || $dstBase === '..') {
                echo json_encode(['success' => false, 'error' => 'Nombre de destino inválido']);
                exit;
            }
        }
        $dst = $dstParent . '/' . $dstBase;
        $overwrite = !empty($input['overwrite']);
        if (!file_exists($src)) {
            echo json_encode(['success' => false, 'error' => 'Origen no existe']);
            exit;
        }
        if (file_exists($dst)) {
            if ($overwrite) {
                $delFs = function ($target) use (&$delFs) {
                    if (is_file($target) || is_link($target)) {
                        return @unlink($target);
                    }
                    if (!is_dir($target)) {
                        return true;
                    }
                    $items = @scandir($target);
                    if ($items) {
                        foreach ($items as $it) {
                            if ($it === '.' || $it === '..') {
                                continue;
                            }
                            if (!$delFs($target . DIRECTORY_SEPARATOR . $it)) {
                                return false;
                            }
                        }
                    }
                    return @rmdir($target);
                };
                if (!$delFs($dst)) {
                    echo json_encode(['success' => false, 'error' => 'No se pudo reemplazar el destino existente']);
                    exit;
                }
            } elseif ($autoRename) {
                $uniqueBase = function ($parentDir, $filename) {
                    if (!file_exists($parentDir . '/' . $filename)) return $filename;
                    $pi = pathinfo($filename);
                    $stem = isset($pi['filename']) ? $pi['filename'] : $filename;
                    $ext = isset($pi['extension']) && $pi['extension'] !== '' ? '.' . $pi['extension'] : '';
                    $n = 1;
                    do {
                        $candidate = $stem . ' (' . $n . ')' . $ext;
                        $n++;
                    } while (file_exists($parentDir . '/' . $candidate) && $n < 5000);
                    return $candidate;
                };
                $newBase = $uniqueBase($dstParent, $dstBase);
                $dst = $dstParent . '/' . $newBase;
            } else {
                echo json_encode(['success' => false, 'error' => 'Ya existe en destino']);
                exit;
            }
        }
        if (!is_dir($dstParent)) {
            if (!mkdir($dstParent, 0755, true)) {
                echo json_encode(['success' => false, 'error' => 'No se pudo crear destino']);
                exit;
            }
        }
        $recursiveCopy = function ($from, $to) use (&$recursiveCopy) {
            if (is_file($from)) {
                return @copy($from, $to);
            }
            if (!is_dir($to) && !@mkdir($to, 0755, true)) return false;
            $items = scandir($from);
            if (!$items) return false;
            foreach ($items as $it) {
                if ($it === '.' || $it === '..') continue;
                if (!$recursiveCopy($from . DIRECTORY_SEPARATOR . $it, $to . DIRECTORY_SEPARATOR . $it)) return false;
            }
            return true;
        };
        if (!$recursiveCopy($src, $dst)) {
            echo json_encode(['success' => false, 'error' => 'No se pudo copiar']);
            exit;
        }
        echo json_encode(['success' => true, 'storedAs' => basename($dst)]);
        exit;
    }

    if ($_GET['action'] === 'explorerDelete') {
        $input = json_decode(file_get_contents('php://input'), true);
        if (!is_array($input)) {
            $input = [];
        }
        $path = isset($input['path']) ? (string)$input['path'] : '';
        $name = isset($input['name']) ? (string)$input['name'] : '';
        $bucket = isset($input['bucket']) ? (string)$input['bucket'] : 'files';
        if ($name === '') {
            echo json_encode(['success' => false, 'error' => 'Nombre inválido']);
            exit;
        }
        if (!in_array($bucket, ['files', 'cache', 'community', 'department'], true)) {
            echo json_encode(['success' => false, 'error' => 'Bucket no soportado']);
            exit;
        }

        $workspace = grid_read_settings_for_api($settingsFile, $settingsBackupFile, $defaultSettings);
        $ctx = grid_explorer_resolve_storage_context($input, $workspace);
        if (empty($ctx['ok'])) {
            echo json_encode(['success' => false, 'error' => (string) ($ctx['error'] ?? 'Sin acceso')], JSON_UNESCAPED_UNICODE);
            exit;
        }
        $filesBase = (string) $ctx['baseDir'];
        $trashBase = grid_explorer_local_trash_base();
        $path = str_replace(['\\', "\0"], ['/', ''], $path);
        $path = ltrim($path, '/');
        if (strpos($path, '..') !== false) {
            echo json_encode(['success' => false, 'error' => 'Ruta inválida']);
            exit;
        }

        $srcParent = $filesBase . ($path !== '' ? '/' . $path : '');
        $src = $srcParent . '/' . basename($name);
        if (!file_exists($src)) {
            echo json_encode(['success' => false, 'error' => 'Origen no existe']);
            exit;
        }

        $id = uniqid('trash_', true);
        $destBase = $trashBase . '/' . $id;
        mkdir($destBase, 0755, true);
        $kind = is_dir($src) ? 'dir' : 'file';
        $dest = $destBase . '/' . basename($name);

        if (!rename($src, $dest)) {
            echo json_encode(['success' => false, 'error' => 'No se pudo eliminar/mover a papelera']);
            exit;
        }

        // Guardar metadata en settings.json
        $currentSettings = grid_runtime_settings_read($settingsFile, $settingsBackupFile, $defaultSettings);
        if (!isset($currentSettings['fileExplorerTrash']) || !is_array($currentSettings['fileExplorerTrash'])) {
            $currentSettings['fileExplorerTrash'] = [];
        }

        $mime = '';
        $size = 0;
        if ($kind === 'file') {
            $finfo = finfo_open(FILEINFO_MIME_TYPE);
            $mime = finfo_file($finfo, $dest) ?: '';
            finfo_close($finfo);
            $size = filesize($dest) ?: 0;
        }

        $entry = [
            'id' => $id,
            'name' => basename($name),
            'kind' => $kind,
            'originalPath' => $path,
            'mimeType' => $mime,
            'size' => $size,
            'deletedAt' => time()
        ];
        if ($bucket === 'cache') {
            $entry['originalBucket'] = 'cache';
        } elseif ($bucket === 'community') {
            $entry['originalBucket'] = 'community';
        } elseif ($bucket === 'department') {
            $entry['originalBucket'] = 'department';
            $entry['originalDepartmentId'] = (string) ($ctx['departmentId'] ?? '');
        }

        $currentSettings['fileExplorerTrash'][] = $entry;

        $persistOk = grid_runtime_settings_save_patch($settingsFile, $settingsBackupFile, $defaultSettings, [
            'fileExplorerTrash' => $currentSettings['fileExplorerTrash'],
        ]);
        if (!$persistOk) {
            // El contenido ya se movió a /media/explorer/trash; si no podemos persistir settings.json,
            // la UI podría no mostrar el elemento. Devolvemos éxito pero con advertencia.
            echo json_encode([
                'success' => true,
                'id' => $id,
                'persisted' => false,
                'warning' => 'No se pudo guardar settings.json (permisos/solo lectura). El elemento sigue en la carpeta de papelera.'
            ]);
            exit;
        }
        echo json_encode(['success' => true, 'id' => $id, 'persisted' => true]);
        exit;
    }

    if ($_GET['action'] === 'explorerDeletePermanent') {
        $input = json_decode(file_get_contents('php://input'), true);
        if (!is_array($input)) {
            $input = [];
        }
        $path = isset($input['path']) ? (string)$input['path'] : '';
        $name = isset($input['name']) ? (string)$input['name'] : '';
        $bucket = isset($input['bucket']) ? (string)$input['bucket'] : 'files';
        if ($name === '') {
            echo json_encode(['success' => false, 'error' => 'Nombre inválido']);
            exit;
        }
        if (!in_array($bucket, ['files', 'cache', 'community', 'department'], true)) {
            echo json_encode(['success' => false, 'error' => 'Bucket no soportado']);
            exit;
        }

        $workspace = grid_read_settings_for_api($settingsFile, $settingsBackupFile, $defaultSettings);
        $ctx = grid_explorer_resolve_storage_context($input, $workspace);
        if (empty($ctx['ok'])) {
            echo json_encode(['success' => false, 'error' => (string) ($ctx['error'] ?? 'Sin acceso')], JSON_UNESCAPED_UNICODE);
            exit;
        }
        $filesBase = (string) $ctx['baseDir'];
        $path = str_replace(['\\', "\0"], ['/', ''], $path);
        $path = ltrim($path, '/');
        if (strpos($path, '..') !== false) {
            echo json_encode(['success' => false, 'error' => 'Ruta inválida']);
            exit;
        }

        $srcParent = $filesBase . ($path !== '' ? '/' . $path : '');
        $src = $srcParent . '/' . basename($name);
        if (!file_exists($src)) {
            echo json_encode(['success' => false, 'error' => 'Origen no existe']);
            exit;
        }

        $deleteRecursive = function ($target) use (&$deleteRecursive) {
            if (is_file($target) || is_link($target)) {
                return @unlink($target);
            }
            if (!is_dir($target)) {
                return true;
            }
            $items = @scandir($target);
            if ($items) {
                foreach ($items as $it) {
                    if ($it === '.' || $it === '..') {
                        continue;
                    }
                    if (!$deleteRecursive($target . DIRECTORY_SEPARATOR . $it)) {
                        return false;
                    }
                }
            }
            return @rmdir($target);
        };

        if (!$deleteRecursive($src)) {
            echo json_encode(['success' => false, 'error' => 'No se pudo eliminar permanentemente']);
            exit;
        }

        echo json_encode(['success' => true]);
        exit;
    }

    if ($_GET['action'] === 'explorerListTrash') {
        $currentSettings = grid_runtime_settings_read($settingsFile, $settingsBackupFile, $defaultSettings);
        $trashArr = isset($currentSettings['fileExplorerTrash']) && is_array($currentSettings['fileExplorerTrash'])
            ? $currentSettings['fileExplorerTrash']
            : [];

        $items = [];
        $seen = [];
        foreach ($trashArr as $t) {
            if (!isset($t['id'], $t['name'], $t['kind'])) continue;
            $id = (string)$t['id'];
            $kind = (string)$t['kind'];
            $url = grid_explorer_local_url_prefix('trash') . $id . '/' . $t['name'];
            $items[] = [
                'id' => $id,
                'name' => (string)$t['name'],
                'kind' => $kind,
                'originalPath' => isset($t['originalPath']) ? (string)$t['originalPath'] : '',
                'mimeType' => isset($t['mimeType']) ? (string)$t['mimeType'] : '',
                'size' => isset($t['size']) ? (int)$t['size'] : 0,
                'deletedAt' => isset($t['deletedAt']) ? (int)$t['deletedAt'] : 0,
                'url' => $url
            ];
            $seen[$id] = true;
        }

        // Fallback: si settings.json no tiene metadata (p. ej. no se pudo persistir),
        // reconstruimos desde el FS (/media/explorer/trash/<id>/<name>).
        $trashBase = grid_explorer_local_trash_base();
        if (is_dir($trashBase)) {
            $dirs = @scandir($trashBase);
            if (is_array($dirs)) {
                foreach ($dirs as $d) {
                    if ($d === '.' || $d === '..') continue;
                    if (isset($seen[$d])) continue;
                    if (strpos($d, 'trash_') !== 0 && strpos($d, 'trash_remote_') !== 0) continue;
                    $fullDir = $trashBase . DIRECTORY_SEPARATOR . $d;
                    if (!is_dir($fullDir)) continue;
                    $children = @scandir($fullDir);
                    if (!is_array($children)) continue;
                    foreach ($children as $ch) {
                        if ($ch === '.' || $ch === '..') continue;
                        $full = $fullDir . DIRECTORY_SEPARATOR . $ch;
                        $kind = is_dir($full) ? 'dir' : 'file';
                        $mime = '';
                        $size = 0;
                        if ($kind === 'file') {
                            $finfo = finfo_open(FILEINFO_MIME_TYPE);
                            $mime = $finfo ? (finfo_file($finfo, $full) ?: '') : '';
                            if ($finfo) finfo_close($finfo);
                            $size = filesize($full) ?: 0;
                        }
                        $deletedAt = filemtime($fullDir) ?: (filemtime($full) ?: 0);
                        $items[] = [
                            'id' => $d,
                            'name' => (string)$ch,
                            'kind' => $kind,
                            'originalPath' => '',
                            'mimeType' => $mime,
                            'size' => $size,
                            'deletedAt' => (int)$deletedAt,
                            'url' => grid_explorer_local_url_prefix('trash') . $d . '/' . rawurlencode($ch)
                        ];
                    }
                }
            }
        }

        echo json_encode(['success' => true, 'items' => $items]);
        exit;
    }

    if ($_GET['action'] === 'explorerTrashListDir') {
        $input = json_decode(file_get_contents('php://input'), true);
        $id = isset($input['id']) ? (string)$input['id'] : '';
        $rootName = isset($input['name']) ? (string)$input['name'] : '';
        $rel = isset($input['path']) ? (string)$input['path'] : '';

        if ($id === '' || $rootName === '') {
            echo json_encode(['success' => false, 'error' => 'Parámetros inválidos']);
            exit;
        }
        // Validación básica del id para evitar traversal.
        if (strpos($id, 'trash_') !== 0 && strpos($id, 'trash_remote_') !== 0) {
            echo json_encode(['success' => false, 'error' => 'ID inválido']);
            exit;
        }

        $trashBase = grid_explorer_local_trash_base();
        $baseDir = $trashBase . DIRECTORY_SEPARATOR . $id . DIRECTORY_SEPARATOR . $rootName;
        $baseReal = realpath($baseDir);
        if ($baseReal === false || !is_dir($baseReal)) {
            echo json_encode(['success' => true, 'items' => []]);
            exit;
        }

        $rel = str_replace('\\', '/', $rel);
        $rel = preg_replace('#/+#', '/', $rel);
        $rel = trim($rel, '/');
        if ($rel !== '' && (strpos($rel, '..') !== false || strpos($rel, "\0") !== false)) {
            echo json_encode(['success' => false, 'error' => 'Ruta inválida']);
            exit;
        }

        $target = $baseReal . ($rel !== '' ? (DIRECTORY_SEPARATOR . str_replace('/', DIRECTORY_SEPARATOR, $rel)) : '');
        $targetReal = realpath($target);
        if ($targetReal === false || strpos($targetReal, $baseReal) !== 0 || !is_dir($targetReal)) {
            echo json_encode(['success' => false, 'error' => 'Ruta inválida']);
            exit;
        }

        $out = [];
        $entries = @scandir($targetReal);
        if (is_array($entries)) {
            foreach ($entries as $ch) {
                if ($ch === '.' || $ch === '..') continue;
                $full = $targetReal . DIRECTORY_SEPARATOR . $ch;
                $kind = is_dir($full) ? 'dir' : 'file';
                $mime = '';
                $size = 0;
                if ($kind === 'file') {
                    $finfo = finfo_open(FILEINFO_MIME_TYPE);
                    $mime = $finfo ? (finfo_file($finfo, $full) ?: '') : '';
                    if ($finfo) finfo_close($finfo);
                    $size = filesize($full) ?: 0;
                }
                $mtime = filemtime($full) ?: 0;
                // URL pública dentro de /media/explorer/trash/<id>/<rootName>/<rel>/<ch>
                $urlRel = ($rel !== '' ? ($rel . '/' . $ch) : $ch);
                $url = grid_explorer_local_url_prefix('trash') . rawurlencode($id) . '/' . rawurlencode($rootName) . '/' . implode('/', array_map('rawurlencode', explode('/', $urlRel)));
                $out[] = [
                    'type' => $kind,
                    'name' => (string)$ch,
                    'mimeType' => $mime,
                    'size' => (int)$size,
                    'mtime' => (int)$mtime,
                    'url' => $url
                ];
            }
        }
        echo json_encode(['success' => true, 'items' => $out]);
        exit;
    }

    if ($_GET['action'] === 'explorerRestoreTrashItem') {
        $input = json_decode(file_get_contents('php://input'), true);
        $id = isset($input['id']) ? (string)$input['id'] : '';
        if ($id === '') {
            echo json_encode(['success' => false, 'error' => 'ID inválido']);
            exit;
        }
        $overwrite = isset($input['overwrite']) ? (bool)$input['overwrite'] : false;
        $merge = isset($input['merge']) ? (bool)$input['merge'] : false;
        $newName = isset($input['newName']) ? (string)$input['newName'] : '';
        $newName = trim(str_replace(["\0", '\\', '/'], ['', '', ''], $newName));

        $filesBase = grid_explorer_local_files_base();
        $trashBase = grid_explorer_local_trash_base();
        $currentSettings = grid_runtime_settings_read($settingsFile, $settingsBackupFile, $defaultSettings);
        $trashArr = isset($currentSettings['fileExplorerTrash']) && is_array($currentSettings['fileExplorerTrash'])
            ? $currentSettings['fileExplorerTrash']
            : [];

        $idx = -1;
        foreach ($trashArr as $i => $t) {
            if (isset($t['id']) && (string)$t['id'] === $id) {
                $idx = $i;
                break;
            }
        }
        if ($idx === -1) {
            echo json_encode(['success' => false, 'error' => 'Elemento no encontrado']);
            exit;
        }

        $t = $trashArr[$idx];
        $name = (string)$t['name'];
        $kind = (string)$t['kind'];
        if ($newName !== '') {
            // Solo nombre, sin rutas.
            $name = basename($newName);
        }
        $originalPath = isset($t['originalPath']) ? (string)$t['originalPath'] : '';
        $originalPath = str_replace(['\\', "\0"], ['/', ''], $originalPath);
        $originalPath = ltrim($originalPath, '/');
        if (strpos($originalPath, '..') !== false) {
            echo json_encode(['success' => false, 'error' => 'Ruta inválida']);
            exit;
        }

        $src = $trashBase . '/' . $id . '/' . basename($name);
        if (!file_exists($src)) {
            echo json_encode(['success' => false, 'error' => 'Contenido no existe']);
            exit;
        }

        // Si el origen fue un borrado remoto (WebDAV/FTP), restaurar implica subir al servidor remoto.
        $sourceType = isset($t['sourceType']) ? (string)$t['sourceType'] : 'local';
        $origBucket = isset($t['originalBucket']) ? (string)$t['originalBucket'] : 'files';
        if (!in_array($origBucket, ['files', 'cache', 'community', 'department'], true)) {
            $origBucket = 'files';
        }
        $origDepartmentId = isset($t['originalDepartmentId']) ? (string)$t['originalDepartmentId'] : '';
        if ($sourceType === 'remote') {
            $connId = isset($t['connectionId']) ? (string)$t['connectionId'] : '';
            $remoteDir = isset($t['originalRemotePath']) ? (string)$t['originalRemotePath'] : '';
            $remoteDir = explorerNormalizeRelPath($remoteDir);

            $conn = explorerGetConnectionById($currentSettings, $connId);
            if (!$conn) {
                echo json_encode(['success' => false, 'error' => 'Conexión remota no encontrada']);
                exit;
            }

            $connType = strtolower((string)($conn['type'] ?? ''));
            $mime = isset($t['mimeType']) && is_string($t['mimeType']) ? (string)$t['mimeType'] : explorerGuessMimeType($name);

            if ($connType === 'webdav') {
                $baseUrl = isset($conn['baseUrl']) ? (string)$conn['baseUrl'] : (string)($conn['url'] ?? '');
                if ($baseUrl === '') {
                    echo json_encode(['success' => false, 'error' => 'WebDAV baseUrl faltante']);
                    exit;
                }
                $remoteUrl = explorerWebdavJoinUrl($baseUrl, $remoteDir, $name);

                $user = isset($conn['username']) ? (string)$conn['username'] : '';
                $pass = isset($conn['password']) ? (string)$conn['password'] : '';

                $fp = fopen($src, 'rb');
                if (!$fp) {
                    echo json_encode(['success' => false, 'error' => 'No se pudo leer el archivo']);
                    exit;
                }

                $ch = curl_init($remoteUrl);
                curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
                curl_setopt($ch, CURLOPT_CUSTOMREQUEST, 'PUT');
                curl_setopt($ch, CURLOPT_UPLOAD, true);
                curl_setopt($ch, CURLOPT_INFILE, $fp);
                curl_setopt($ch, CURLOPT_INFILESIZE, filesize($src));
                curl_setopt($ch, CURLOPT_CONNECTTIMEOUT, 15);
                curl_setopt($ch, CURLOPT_TIMEOUT, 60);
                curl_setopt($ch, CURLOPT_HTTPHEADER, ['Content-Type: ' . $mime]);
                if ($user !== '' || $pass !== '') {
                    curl_setopt($ch, CURLOPT_USERPWD, $user . ':' . $pass);
                }

                $resp = curl_exec($ch);
                $code = curl_getinfo($ch, CURLINFO_HTTP_CODE);
                $err = curl_error($ch);
                curl_close($ch);
                fclose($fp);

                if ($resp === false || $code >= 400) {
                    echo json_encode(['success' => false, 'error' => 'No se pudo restaurar en WebDAV', 'detail' => $err]);
                    exit;
                }
            } elseif ($connType === 'ftp') {
                $host = isset($conn['host']) ? (string)$conn['host'] : '';
                if ($host === '') {
                    echo json_encode(['success' => false, 'error' => 'FTP host faltante']);
                    exit;
                }
                $port = isset($conn['port']) ? (int)$conn['port'] : 21;
                $user = isset($conn['username']) ? (string)$conn['username'] : '';
                $pass = isset($conn['password']) ? (string)$conn['password'] : '';
                $root = isset($conn['root']) ? (string)$conn['root'] : '';
                $root = explorerNormalizeRelPath($root);

                $ftp = @ftp_connect($host, $port, 15);
                if (!$ftp) {
                    echo json_encode(['success' => false, 'error' => 'No se pudo conectar por FTP']);
                    exit;
                }
                @ftp_login($ftp, $user, $pass);
                $pasv = isset($conn['pasv']) ? (bool)$conn['pasv'] : true;
                @ftp_pasv($ftp, $pasv);

                $remoteFullDir = $root;
                if ($remoteDir !== '') {
                    $remoteFullDir = ($remoteFullDir !== '' ? $remoteFullDir . '/' : '') . $remoteDir;
                }

                explorerEnsureDirRecursiveFtp($ftp, $remoteFullDir);
                $remoteFilePath = ($remoteFullDir !== '' ? $remoteFullDir . '/' : '') . $name;

                $ok = @ftp_put($ftp, $remoteFilePath, $src, FTP_BINARY);
                @ftp_close($ftp);
                if (!$ok) {
                    echo json_encode(['success' => false, 'error' => 'No se pudo restaurar en FTP']);
                    exit;
                }
            } else {
                echo json_encode(['success' => false, 'error' => 'Tipo de conexión remota no soportado']);
                exit;
            }

            // Eliminar metadata
            array_splice($trashArr, $idx, 1);
            $currentSettings['fileExplorerTrash'] = $trashArr;
            grid_runtime_settings_save_patch($settingsFile, $settingsBackupFile, $defaultSettings, [
                'fileExplorerTrash' => $trashArr,
            ]);

            // Limpiar carpeta vacía del trash
            $emptyFolder = $trashBase . '/' . $id;
            if (is_dir($emptyFolder)) {
                $items = @scandir($emptyFolder);
                if ($items) {
                    foreach ($items as $it) {
                        if ($it === '.' || $it === '..') continue;
                        @unlink($emptyFolder . '/' . $it);
                    }
                }
                @rmdir($emptyFolder);
            }

            echo json_encode(['success' => true]);
            exit;
        }

        $restoreBase = grid_explorer_restore_base_dir($origBucket, $origDepartmentId);
        $destParent = $restoreBase . ($originalPath !== '' ? '/' . $originalPath : '');
        if (!is_dir($destParent)) {
            mkdir($destParent, 0755, true);
        }
        $dst = $destParent . '/' . basename($name);

        $deleteRecursive = function($path) use (&$deleteRecursive) {
            if (is_file($path) || is_link($path)) return @unlink($path);
            if (!is_dir($path)) return true;
            $items = @scandir($path);
            if ($items) {
                foreach ($items as $it) {
                    if ($it === '.' || $it === '..') continue;
                    if (!$deleteRecursive($path . DIRECTORY_SEPARATOR . $it)) return false;
                }
            }
            return @rmdir($path);
        };

        $mergeDirs = function($srcDir, $dstDir) use (&$mergeDirs, $deleteRecursive) {
            if (!is_dir($dstDir)) @mkdir($dstDir, 0755, true);
            $items = @scandir($srcDir);
            if (!$items) return true;
            foreach ($items as $it) {
                if ($it === '.' || $it === '..') continue;
                $s = $srcDir . DIRECTORY_SEPARATOR . $it;
                $d = $dstDir . DIRECTORY_SEPARATOR . $it;
                if (is_dir($s)) {
                    if (file_exists($d) && !is_dir($d)) {
                        if (!$deleteRecursive($d)) return false;
                    }
                    if (!$mergeDirs($s, $d)) return false;
                    // limpiar carpeta origen si quedó vacía
                    @rmdir($s);
                    continue;
                }
                // archivo
                if (file_exists($d)) {
                    if (!$deleteRecursive($d)) return false;
                }
                if (!@rename($s, $d)) return false;
            }
            return true;
        };

        $didMerge = false;
        if (file_exists($dst)) {
            // Conflicto de nombre: si no se pidió overwrite/merge, pedir decisión al UI.
            if (!$overwrite && !($merge && $kind === 'dir' && is_dir($dst))) {
                echo json_encode(['success' => false, 'conflict' => true, 'name' => basename($name), 'kind' => $kind, 'destPath' => $originalPath, 'bucket' => $origBucket]);
                exit;
            }
            if ($merge && $kind === 'dir' && is_dir($dst) && is_dir($src)) {
                if (!$mergeDirs($src, $dst)) {
                    echo json_encode(['success' => false, 'error' => 'No se pudo fusionar la carpeta']);
                    exit;
                }
                // Si la carpeta trash queda vacía, eliminarla.
                @rmdir($src);
                $didMerge = true;
            } else {
                if (!$deleteRecursive($dst)) {
                    echo json_encode(['success' => false, 'error' => 'No se pudo reemplazar el existente']);
                    exit;
                }
            }
        }

        if (!$didMerge) {
            if (file_exists($dst)) {
                // Si todavía existe, considerarlo conflicto (p. ej. se pidió rename pero chocó).
                echo json_encode(['success' => false, 'conflict' => true, 'name' => basename($name), 'kind' => $kind, 'destPath' => $originalPath, 'bucket' => $origBucket]);
                exit;
            }
            if (file_exists($src)) {
                if (!rename($src, $dst)) {
                    echo json_encode(['success' => false, 'error' => 'No se pudo restaurar']);
                    exit;
                }
            }
        }

        // Eliminar metadata
        array_splice($trashArr, $idx, 1);
        $currentSettings['fileExplorerTrash'] = $trashArr;
        grid_runtime_settings_save_patch($settingsFile, $settingsBackupFile, $defaultSettings, [
            'fileExplorerTrash' => $trashArr,
        ]);

        // Limpiar carpeta vacía
        $emptyFolder = $trashBase . '/' . $id;
        if (is_dir($emptyFolder)) {
            @rmdir($emptyFolder);
        }

        echo json_encode(['success' => true]);
        exit;
    }

    if ($_GET['action'] === 'explorerTrashRestorePath') {
        $input = json_decode(file_get_contents('php://input'), true);
        $id = isset($input['id']) ? (string)$input['id'] : '';
        $rootName = isset($input['rootName']) ? (string)$input['rootName'] : '';
        $rel = isset($input['path']) ? (string)$input['path'] : '';
        $name = isset($input['name']) ? (string)$input['name'] : '';
        $overwrite = isset($input['overwrite']) ? (bool)$input['overwrite'] : false;
        $merge = isset($input['merge']) ? (bool)$input['merge'] : false;
        $newName = isset($input['newName']) ? (string)$input['newName'] : '';
        $newName = trim(str_replace(["\0", '\\', '/'], ['', '', ''], $newName));

        if ($id === '' || $rootName === '' || $name === '') {
            echo json_encode(['success' => false, 'error' => 'Parámetros inválidos']);
            exit;
        }
        if (strpos($id, 'trash_') !== 0 && strpos($id, 'trash_remote_') !== 0) {
            echo json_encode(['success' => false, 'error' => 'ID inválido']);
            exit;
        }
        $rel = str_replace('\\', '/', $rel);
        $rel = preg_replace('#/+#', '/', $rel);
        $rel = trim($rel, '/');
        if ($rel !== '' && (strpos($rel, '..') !== false || strpos($rel, "\0") !== false)) {
            echo json_encode(['success' => false, 'error' => 'Ruta inválida']);
            exit;
        }

        $trashBase = grid_explorer_local_trash_base();
        $currentSettings = grid_runtime_settings_read($settingsFile, $settingsBackupFile, $defaultSettings);
        $trashArr = isset($currentSettings['fileExplorerTrash']) && is_array($currentSettings['fileExplorerTrash'])
            ? $currentSettings['fileExplorerTrash']
            : [];

        $idx = -1;
        foreach ($trashArr as $i => $t) {
            if (isset($t['id']) && (string)$t['id'] === $id) { $idx = $i; break; }
        }
        if ($idx === -1) {
            echo json_encode(['success' => false, 'error' => 'Elemento no encontrado']);
            exit;
        }
        $t = $trashArr[$idx];
        $origBucket = isset($t['originalBucket']) ? (string)$t['originalBucket'] : 'files';
        if (!in_array($origBucket, ['files', 'cache', 'community', 'department'], true)) {
            $origBucket = 'files';
        }
        $origDepartmentId = isset($t['originalDepartmentId']) ? (string)$t['originalDepartmentId'] : '';
        $originalPath = isset($t['originalPath']) ? (string)$t['originalPath'] : '';
        $originalPath = str_replace(['\\', "\0"], ['/', ''], $originalPath);
        $originalPath = ltrim($originalPath, '/');
        if (strpos($originalPath, '..') !== false) {
            echo json_encode(['success' => false, 'error' => 'Ruta inválida']);
            exit;
        }

        $safeName = basename($newName !== '' ? $newName : $name);
        $baseDir = $trashBase . '/' . $id . '/' . $rootName;
        $baseReal = realpath($baseDir);
        if ($baseReal === false || !is_dir($baseReal)) {
            echo json_encode(['success' => false, 'error' => 'Contenido no existe']);
            exit;
        }
        $src = $baseReal . ($rel !== '' ? (DIRECTORY_SEPARATOR . str_replace('/', DIRECTORY_SEPARATOR, $rel)) : '') . DIRECTORY_SEPARATOR . basename($name);
        $srcReal = realpath($src);
        if ($srcReal === false || strpos($srcReal, $baseReal) !== 0 || !file_exists($srcReal)) {
            echo json_encode(['success' => false, 'error' => 'Contenido no existe']);
            exit;
        }
        $kind = is_dir($srcReal) ? 'dir' : 'file';

        $restoreBase = grid_explorer_restore_base_dir($origBucket, $origDepartmentId);
        $destParent = $restoreBase . ($originalPath !== '' ? '/' . $originalPath : '');
        // Restaurar dentro de la carpeta original (rootName) y subruta rel
        $destParent = $destParent . '/' . $rootName . ($rel !== '' ? ('/' . $rel) : '');
        if (!is_dir($destParent)) @mkdir($destParent, 0755, true);
        $dst = $destParent . '/' . $safeName;

        $deleteRecursive = function($path) use (&$deleteRecursive) {
            if (is_file($path) || is_link($path)) return @unlink($path);
            if (!is_dir($path)) return true;
            $items = @scandir($path);
            if ($items) {
                foreach ($items as $it) {
                    if ($it === '.' || $it === '..') continue;
                    if (!$deleteRecursive($path . DIRECTORY_SEPARATOR . $it)) return false;
                }
            }
            return @rmdir($path);
        };
        $mergeDirs = function($srcDir, $dstDir) use (&$mergeDirs, $deleteRecursive) {
            if (!is_dir($dstDir)) @mkdir($dstDir, 0755, true);
            $items = @scandir($srcDir);
            if (!$items) return true;
            foreach ($items as $it) {
                if ($it === '.' || $it === '..') continue;
                $s = $srcDir . DIRECTORY_SEPARATOR . $it;
                $d = $dstDir . DIRECTORY_SEPARATOR . $it;
                if (is_dir($s)) {
                    if (file_exists($d) && !is_dir($d)) { if (!$deleteRecursive($d)) return false; }
                    if (!$mergeDirs($s, $d)) return false;
                    @rmdir($s);
                    continue;
                }
                if (file_exists($d)) { if (!$deleteRecursive($d)) return false; }
                if (!@rename($s, $d)) return false;
            }
            return true;
        };

        if (file_exists($dst)) {
            if (!$overwrite && !($merge && $kind === 'dir' && is_dir($dst))) {
                echo json_encode(['success' => false, 'conflict' => true, 'name' => $safeName, 'kind' => $kind]);
                exit;
            }
            if ($merge && $kind === 'dir' && is_dir($dst) && is_dir($srcReal)) {
                if (!$mergeDirs($srcReal, $dst)) {
                    echo json_encode(['success' => false, 'error' => 'No se pudo fusionar la carpeta']);
                    exit;
                }
                @rmdir($srcReal);
                echo json_encode(['success' => true]);
                exit;
            }
            if (!$deleteRecursive($dst)) {
                echo json_encode(['success' => false, 'error' => 'No se pudo reemplazar el existente']);
                exit;
            }
        }
        if (!@rename($srcReal, $dst)) {
            echo json_encode(['success' => false, 'error' => 'No se pudo restaurar']);
            exit;
        }

        // Si la carpeta raíz en trash quedó vacía, limpiar metadata y carpeta del id.
        $rootDir = $baseReal;
        $scan = @scandir($rootDir);
        $empty = true;
        if (is_array($scan)) {
            foreach ($scan as $it) { if ($it !== '.' && $it !== '..') { $empty = false; break; } }
        }
        if ($empty) {
            // borrar carpeta del rootName y, si queda vacío, el id
            @rmdir($rootDir);
            $idDir = $trashBase . '/' . $id;
            $scan2 = @scandir($idDir);
            $empty2 = true;
            if (is_array($scan2)) {
                foreach ($scan2 as $it) { if ($it !== '.' && $it !== '..') { $empty2 = false; break; } }
            }
            if ($empty2) @rmdir($idDir);
            // remover metadata del ítem raíz
            array_splice($trashArr, $idx, 1);
            $currentSettings['fileExplorerTrash'] = $trashArr;
            grid_runtime_settings_save_patch($settingsFile, $settingsBackupFile, $defaultSettings, [
                'fileExplorerTrash' => $trashArr,
            ]);
        }

        echo json_encode(['success' => true]);
        exit;
    }

    if ($_GET['action'] === 'explorerDeleteTrashItem') {
        $input = json_decode(file_get_contents('php://input'), true);
        $id = isset($input['id']) ? (string)$input['id'] : '';
        if ($id === '') {
            echo json_encode(['success' => false, 'error' => 'ID inválido']);
            exit;
        }

        $trashBase = grid_explorer_local_trash_base();
        $currentSettings = grid_runtime_settings_read($settingsFile, $settingsBackupFile, $defaultSettings);
        $trashArr = isset($currentSettings['fileExplorerTrash']) && is_array($currentSettings['fileExplorerTrash'])
            ? $currentSettings['fileExplorerTrash']
            : [];

        // Helper borrar recursivo
        $deleteRecursive = function($path) use (&$deleteRecursive) {
            if (is_file($path) || is_link($path)) return @unlink($path);
            if (!is_dir($path)) return true;
            $items = scandir($path);
            if ($items) {
                foreach ($items as $it) {
                    if ($it === '.' || $it === '..') continue;
                    $deleteRecursive($path . DIRECTORY_SEPARATOR . $it);
                }
            }
            return @rmdir($path);
        };

        $target = $trashBase . '/' . $id;
        if (is_dir($target)) {
            $deleteRecursive($target);
        }

        // Quitar metadata
        $newArr = [];
        foreach ($trashArr as $t) {
            if (!isset($t['id']) || (string)$t['id'] !== $id) $newArr[] = $t;
        }
        $currentSettings['fileExplorerTrash'] = $newArr;
        grid_runtime_settings_save_patch($settingsFile, $settingsBackupFile, $defaultSettings, [
            'fileExplorerTrash' => $newArr,
        ]);

        echo json_encode(['success' => true]);
        exit;
    }

    if ($_GET['action'] === 'explorerEmptyTrash') {
        $currentSettings = grid_runtime_settings_read($settingsFile, $settingsBackupFile, $defaultSettings);

        $trashBase = grid_explorer_local_trash_base();

        $deleteRecursive = function($path) use (&$deleteRecursive) {
            if (is_file($path) || is_link($path)) return @unlink($path);
            if (!is_dir($path)) return true;
            $items = @scandir($path);
            if ($items) {
                foreach ($items as $it) {
                    if ($it === '.' || $it === '..') continue;
                    $deleteRecursive($path . DIRECTORY_SEPARATOR . $it);
                }
            }
            return @rmdir($path);
        };

        // Vaciar todo el contenido físico de la papelera (incl. huérfanos respecto a metadata).
        if (is_dir($trashBase)) {
            $items = @scandir($trashBase);
            if ($items) {
                foreach ($items as $it) {
                    if ($it === '.' || $it === '..') continue;
                    $deleteRecursive($trashBase . DIRECTORY_SEPARATOR . $it);
                }
            }
        } else {
            @mkdir($trashBase, 0755, true);
        }

        $currentSettings['fileExplorerTrash'] = [];
        grid_runtime_settings_save_patch($settingsFile, $settingsBackupFile, $defaultSettings, [
            'fileExplorerTrash' => [],
        ]);
        grid_explorer_storage_cache_invalidate();
        echo json_encode(['success' => true]);
        exit;
    }

    if ($_GET['action'] === 'explorerStorageStats') {
        // Cuenta recursiva de tamaño, evitando ciclos por symlinks / junctions (p. ej. mklink a XAMPP)
        $dirUsage = function ($path, array &$seen) use (&$dirUsage) {
            $path = str_replace(['\\', '/'], DIRECTORY_SEPARATOR, (string) $path);
            $key = @realpath($path);
            if ($key === false) {
                $key = rtrim($path, DIRECTORY_SEPARATOR);
            }
            if (isset($seen[$key])) {
                return ['bytes' => 0, 'files' => 0];
            }
            if (!is_dir($path)) {
                return ['bytes' => 0, 'files' => 0];
            }
            $seen[$key] = true;

            $bytes = 0;
            $files = 0;
            $items = @scandir($path);
            if (!$items) {
                return ['bytes' => 0, 'files' => 0];
            }
            foreach ($items as $it) {
                if ($it === '.' || $it === '..') {
                    continue;
                }
                $full = $path . DIRECTORY_SEPARATOR . $it;
                if (is_dir($full)) {
                    $sub = $dirUsage($full, $seen);
                    $bytes += $sub['bytes'];
                    $files += $sub['files'];
                } elseif (is_file($full)) {
                    $sz = @filesize($full);
                    $bytes += ($sz !== false) ? (int) $sz : 0;
                    $files++;
                }
            }
            return ['bytes' => $bytes, 'files' => $files];
        };

        $remoteCache = GRID_ROOT . '/media/explorer/remote-cache';
        $filesDir = grid_explorer_local_files_base();
        $trashDir = grid_explorer_local_trash_base();

        $gridRes = @realpath(GRID_ROOT);
        $rootPath = ($gridRes !== false) ? $gridRes : GRID_ROOT;
        $free = @disk_free_space($rootPath);
        $total = @disk_total_space($rootPath);
        $diskAvailable = ($free !== false && $total !== false && $total > 0);

        $gridLower = strtolower(str_replace('\\', '/', GRID_ROOT));
        $docRoot = isset($_SERVER['DOCUMENT_ROOT']) ? strtolower(str_replace('\\', '/', (string) $_SERVER['DOCUMENT_ROOT'])) : '';
        $env = 'unknown';
        if (PHP_OS_FAMILY === 'Windows') {
            if (strpos($gridLower, 'xampp') !== false || strpos($docRoot, 'xampp') !== false) {
                $env = 'local_xampp';
            } else {
                $env = 'windows_server';
            }
        } else {
            $env = 'unix_hosting';
        }

        $part = isset($_GET['part']) ? (string) $_GET['part'] : '';
        $forceRefresh = isset($_GET['refresh']) && (string) $_GET['refresh'] === '1';
        if ($forceRefresh) {
            grid_explorer_storage_cache_invalidate();
        }

        if ($part === 'meta') {
            echo json_encode([
                'success' => true,
                'part' => 'meta',
                'disk' => $diskAvailable ? [
                    'freeBytes' => (int) $free,
                    'totalBytes' => (int) $total,
                    'usedBytes' => (int) ($total - $free),
                ] : null,
                'diskUnavailableReason' => $diskAvailable ? null : 'disk_space_unavailable',
                'environment' => $env,
                'phpOs' => PHP_OS_FAMILY,
            ], JSON_UNESCAPED_UNICODE);
            exit;
        }

        if ($part === 'remoteCache' || $part === 'trash' || $part === 'files') {
            if (!$forceRefresh) {
                $cPart = grid_explorer_storage_cache_fresh();
                if ($cPart !== null) {
                    $r = $part === 'remoteCache' ? $cPart['remoteCache'] : ($part === 'trash' ? $cPart['trash'] : $cPart['files']);
                    if (is_array($r)) {
                        $out = [
                            'success' => true,
                            'fromCache' => true,
                            'part' => $part,
                            'remoteCache' => null,
                            'trash' => null,
                            'files' => null,
                        ];
                        if ($part === 'remoteCache') {
                            $out['remoteCache'] = $r;
                        } elseif ($part === 'trash') {
                            $out['trash'] = $r;
                        } else {
                            $out['files'] = $r;
                        }
                        echo json_encode($out, JSON_UNESCAPED_UNICODE);
                        exit;
                    }
                }
            }
            $seen = [];
            $p = $part === 'remoteCache' ? $remoteCache : ($part === 'trash' ? $trashDir : $filesDir);
            $r = $dirUsage($p, $seen);
            $out = ['success' => true, 'fromCache' => false, 'part' => $part, 'remoteCache' => null, 'trash' => null, 'files' => null];
            if ($part === 'remoteCache') {
                $out['remoteCache'] = $r;
            } elseif ($part === 'trash') {
                $out['trash'] = $r;
            } else {
                $out['files'] = $r;
            }
            echo json_encode($out, JSON_UNESCAPED_UNICODE);
            exit;
        }

        if ($part !== '') {
            echo json_encode(['success' => false, 'error' => 'invalid part'], JSON_UNESCAPED_UNICODE);
            exit;
        }

        if (!$forceRefresh) {
            $cFull = grid_explorer_storage_cache_fresh();
            if ($cFull !== null) {
                $rc = $cFull['remoteCache'];
                $tr = $cFull['trash'];
                $fl = $cFull['files'];
                if (is_array($rc) && is_array($tr) && is_array($fl)) {
                    $explorerTotalBytes = (int) $rc['bytes'] + (int) $tr['bytes'] + (int) $fl['bytes'];
                    $explorerTotalFiles = (int) $rc['files'] + (int) $tr['files'] + (int) $fl['files'];
                    echo json_encode([
                        'success' => true,
                        'fromCache' => true,
                        'cacheAgeSeconds' => max(0, time() - (int) $cFull['t']),
                        'remoteCache' => $rc,
                        'trash' => $tr,
                        'files' => $fl,
                        'explorerTotal' => ['bytes' => $explorerTotalBytes, 'files' => $explorerTotalFiles],
                        'disk' => $diskAvailable ? [
                            'freeBytes' => (int) $free,
                            'totalBytes' => (int) $total,
                            'usedBytes' => (int) ($total - $free),
                        ] : null,
                        'diskUnavailableReason' => $diskAvailable ? null : 'disk_space_unavailable',
                        'environment' => $env,
                        'phpOs' => PHP_OS_FAMILY,
                        'paths' => [
                            'explorerRoot' => 'media/explorer',
                            'remoteCache' => 'media/explorer/remote-cache',
                            'trash' => rtrim(grid_explorer_local_url_prefix('trash'), '/'),
                            'files' => rtrim(grid_explorer_local_url_prefix('files'), '/'),
                        ],
                    ], JSON_UNESCAPED_UNICODE);
                    exit;
                }
            }
        }

        $sRc = [];
        $sTr = [];
        $sFl = [];
        $rc = $dirUsage($remoteCache, $sRc);
        $tr = $dirUsage($trashDir, $sTr);
        $fl = $dirUsage($filesDir, $sFl);

        grid_explorer_storage_cache_write($rc, $tr, $fl);

        $explorerTotalBytes = $rc['bytes'] + $tr['bytes'] + $fl['bytes'];
        $explorerTotalFiles = $rc['files'] + $tr['files'] + $fl['files'];

        echo json_encode([
            'success' => true,
            'fromCache' => false,
            'cacheAgeSeconds' => 0,
            'remoteCache' => $rc,
            'trash' => $tr,
            'files' => $fl,
            'explorerTotal' => ['bytes' => $explorerTotalBytes, 'files' => $explorerTotalFiles],
            'disk' => $diskAvailable ? [
                'freeBytes' => (int) $free,
                'totalBytes' => (int) $total,
                'usedBytes' => (int) ($total - $free),
            ] : null,
            'diskUnavailableReason' => $diskAvailable ? null : 'disk_space_unavailable',
            'environment' => $env,
            'phpOs' => PHP_OS_FAMILY,
            'paths' => [
                'explorerRoot' => 'media/explorer',
                'remoteCache' => 'media/explorer/remote-cache',
                'trash' => rtrim(grid_explorer_local_url_prefix('trash'), '/'),
                'files' => rtrim(grid_explorer_local_url_prefix('files'), '/'),
            ],
        ], JSON_UNESCAPED_UNICODE);
        exit;
    }

    if (isset($_GET['action']) && $_GET['action'] === 'explorerStorageStatsCacheSave' && $_SERVER['REQUEST_METHOD'] === 'POST') {
        $raw = file_get_contents('php://input');
        $in = is_string($raw) ? json_decode($raw, true) : null;
        if (!is_array($in)) {
            echo json_encode(['success' => false, 'error' => 'invalid json'], JSON_UNESCAPED_UNICODE);
            exit;
        }
        foreach (['remoteCache', 'trash', 'files'] as $k) {
            if (!isset($in[$k]) || !is_array($in[$k])) {
                echo json_encode(['success' => false, 'error' => 'missing ' . $k], JSON_UNESCAPED_UNICODE);
                exit;
            }
            if (!isset($in[$k]['bytes'], $in[$k]['files'])) {
                echo json_encode(['success' => false, 'error' => 'invalid ' . $k], JSON_UNESCAPED_UNICODE);
                exit;
            }
        }
        grid_explorer_storage_cache_write(
            [
                'bytes' => (int) $in['remoteCache']['bytes'],
                'files' => (int) $in['remoteCache']['files'],
            ],
            [
                'bytes' => (int) $in['trash']['bytes'],
                'files' => (int) $in['trash']['files'],
            ],
            [
                'bytes' => (int) $in['files']['bytes'],
                'files' => (int) $in['files']['files'],
            ]
        );
        echo json_encode(['success' => true], JSON_UNESCAPED_UNICODE);
        exit;
    }

    if ($_GET['action'] === 'explorerEmptyRemoteCache') {
        $cacheRoot = GRID_ROOT . '/media/explorer/remote-cache';
        $deleteRecursive = function ($path) use (&$deleteRecursive) {
            if (is_file($path) || is_link($path)) {
                return @unlink($path);
            }
            if (!is_dir($path)) {
                return true;
            }
            $items = @scandir($path);
            if ($items) {
                foreach ($items as $it) {
                    if ($it === '.' || $it === '..') {
                        continue;
                    }
                    $deleteRecursive($path . DIRECTORY_SEPARATOR . $it);
                }
            }
            return @rmdir($path);
        };

        if (is_dir($cacheRoot)) {
            $items = @scandir($cacheRoot);
            if ($items) {
                foreach ($items as $it) {
                    if ($it === '.' || $it === '..') {
                        continue;
                    }
                    $full = $cacheRoot . DIRECTORY_SEPARATOR . $it;
                    if (is_dir($full)) {
                        $deleteRecursive($full);
                    } else {
                        @unlink($full);
                    }
                }
            }
        }
        if (!is_dir($cacheRoot)) {
            @mkdir($cacheRoot, 0755, true);
        }
        grid_explorer_storage_cache_invalidate();
        echo json_encode(['success' => true]);
        exit;
    }

    // ========== EXPLORADOR DE ARCHIVOS (WebDAV/FTP) ==========
    if ($_GET['action'] === 'explorerConnList') {
        $input = json_decode(file_get_contents('php://input'), true);
        $connId = isset($input['connId']) ? (string)$input['connId'] : '';
        $relPath = isset($input['path']) ? (string)$input['path'] : '';
        $relPath = explorerNormalizeRelPath($relPath);

        $currentSettings = grid_runtime_settings_read($settingsFile, $settingsBackupFile, $defaultSettings);
        $conn = explorerGetConnectionById($currentSettings, $connId);
        if (!$conn) {
            echo json_encode(['success' => false, 'error' => 'Conexión remota no encontrada']);
            exit;
        }

        $type = strtolower((string)($conn['type'] ?? ''));
        $items = [];

        // Caché de listados en servidor (acelera FTP/WebDAV). Se invalida en operaciones de escritura.
        $ttlSec = (int)($currentSettings['explorerRemoteListCacheTtlSec'] ?? 120);
        if ($ttlSec < 0) $ttlSec = 0;
        $connKey = preg_replace('/[^a-zA-Z0-9_\-]/', '_', $connId);
        $cacheDir = GRID_ROOT . '/media/explorer/remote-cache/' . $connKey;
        if (!is_dir($cacheDir)) @mkdir($cacheDir, 0755, true);
        $cacheSig = $type . '|' . $connId . '|' . $relPath . '|' . json_encode([
            'host' => $conn['host'] ?? null,
            'port' => $conn['port'] ?? null,
            'root' => $conn['root'] ?? null,
            'baseUrl' => $conn['baseUrl'] ?? ($conn['url'] ?? null),
            'pasv' => $conn['pasv'] ?? null,
        ]);
        $cacheFile = $cacheDir . '/list_' . sha1($cacheSig) . '.json';
        if ($ttlSec > 0 && is_file($cacheFile)) {
            $age = time() - (int)@filemtime($cacheFile);
            if ($age >= 0 && $age <= $ttlSec) {
                $raw = @file_get_contents($cacheFile);
                $cached = $raw ? json_decode($raw, true) : null;
                if (is_array($cached) && isset($cached['items']) && is_array($cached['items'])) {
                    echo json_encode([
                        'success' => true,
                        'items' => $cached['items'],
                        'path' => $relPath,
                        'bucket' => 'remote',
                        'cached' => true
                    ]);
                    exit;
                }
            }
        }

        if ($type === 'webdav') {
            $baseUrl = isset($conn['baseUrl']) ? (string)$conn['baseUrl'] : (string)($conn['url'] ?? '');
            if ($baseUrl === '') {
                echo json_encode(['success' => false, 'error' => 'WebDAV baseUrl faltante']);
                exit;
            }
            $dirUrl = explorerWebdavJoinUrl($baseUrl, $relPath);
            if (substr($dirUrl, -1) !== '/') $dirUrl .= '/';

            $body = '<?xml version="1.0" encoding="utf-8" ?>'
                . '<d:propfind xmlns:d="DAV:">'
                . '<d:prop>'
                . '<d:getcontentlength/>'
                . '<d:getcontenttype/>'
                . '<d:resourcetype/>'
                . '</d:prop>'
                . '</d:propfind>';

            $headers = ['Depth: 1', 'Content-Type: application/xml; charset=utf-8'];
            $resp = explorerDavRequest('PROPFIND', $dirUrl, (string)($conn['username'] ?? ''), (string)($conn['password'] ?? ''), $body, $headers);
            if (!$resp['success']) {
                echo json_encode(['success' => false, 'error' => 'Error WebDAV PROPFIND', 'detail' => $resp['error'], 'code' => $resp['code']]);
                exit;
            }
            $xml = @simplexml_load_string($resp['body']);
            if (!$xml) {
                echo json_encode(['success' => false, 'error' => 'Respuesta WebDAV no válida']);
                exit;
            }

            $responses = $xml->xpath('//*[local-name()="response"]');
            if (is_array($responses)) {
                foreach ($responses as $r) {
                    $hrefNodes = $r->xpath('./*[local-name()="href"]');
                    if (!is_array($hrefNodes) || !isset($hrefNodes[0])) continue;
                    $href = (string)$hrefNodes[0];
                    $path = parse_url($href, PHP_URL_PATH);
                    if ($path === null) $path = $href;
                    $name = basename(rtrim($path, '/'));
                    if ($name === '' || $name === '.' || $name === '..') continue;

                    $isDir = false;
                    $rtNodes = $r->xpath('.//*[local-name()="resourcetype"]');
                    if (is_array($rtNodes) && count($rtNodes) > 0) {
                        $colNodes = $r->xpath('.//*[local-name()="resourcetype"]//*[local-name()="collection"]');
                        $isDir = is_array($colNodes) && count($colNodes) > 0;
                    }

                    $mimeType = '';
                    $mimeNodes = $r->xpath('.//*[local-name()="getcontenttype"]');
                    if (is_array($mimeNodes) && isset($mimeNodes[0])) {
                        $mimeType = (string)$mimeNodes[0];
                    }
                    $size = 0;
                    $lenNodes = $r->xpath('.//*[local-name()="getcontentlength"]');
                    if (is_array($lenNodes) && isset($lenNodes[0])) {
                        $size = (int)$lenNodes[0];
                    }
                    if ($isDir) {
                        $mimeType = '';
                        $size = 0;
                    } else {
                        if ($mimeType === '' || $mimeType === null) {
                            $mimeType = explorerGuessMimeType($name);
                        }
                    }

                    $items[] = [
                        'name' => $name,
                        'type' => $isDir ? 'dir' : 'file',
                        'mimeType' => $mimeType,
                        'size' => (int)$size
                    ];
                }
            }

        } elseif ($type === 'ftp') {
            $host = isset($conn['host']) ? (string)$conn['host'] : '';
            if ($host === '') {
                echo json_encode(['success' => false, 'error' => 'FTP host faltante']);
                exit;
            }
            $port = isset($conn['port']) ? (int)$conn['port'] : 21;
            $user = isset($conn['username']) ? (string)$conn['username'] : '';
            $pass = isset($conn['password']) ? (string)$conn['password'] : '';
            $root = isset($conn['root']) ? (string)$conn['root'] : '';
            $root = explorerNormalizeRelPath($root);
            $remoteDir = $root;
            if ($relPath !== '') $remoteDir = ($remoteDir !== '' ? $remoteDir . '/' : '') . $relPath;

            $ftp = @ftp_connect($host, $port, 15);
            if (!$ftp) {
                echo json_encode(['success' => false, 'error' => 'No se pudo conectar por FTP']);
                exit;
            }
            @ftp_login($ftp, $user, $pass);
            $pasv = isset($conn['pasv']) ? (bool)$conn['pasv'] : true;
            @ftp_pasv($ftp, $pasv);
            // Intentar mejorar compatibilidad: UTF8 + MLSD (si está disponible) + fallback LIST.
            // Algunos servidores no aceptan "LIST <ruta>" y requieren CWD + "LIST .".
            @ftp_raw($ftp, 'OPTS UTF8 ON');
            @ftp_raw($ftp, 'TYPE I');

            $parseRawLine = function(string $line): ?array {
                $line = trim($line);
                if ($line === '') return null;

                // Formato UNIX típico
                // drwxr-xr-x  2 owner group     4096 Jan  1 12:34 Nombre con espacios
                // -rw-r--r--  1 owner group      123 Jan  1  2025 archivo.txt
                if (preg_match('/^([\-ld])([rwx\-]{9})\s+\d+\s+\S+\s+\S+\s+(\d+)\s+([A-Za-z]{3})\s+(\d{1,2})\s+([\d:]{4,5}|\d{4})\s+(.*)$/', $line, $m)) {
                    $kind = $m[1];
                    $size = (int)$m[3];
                    $name = (string)$m[8];
                    // symlink: "name -> target"
                    $arrowPos = strpos($name, ' -> ');
                    if ($arrowPos !== false) $name = substr($name, 0, $arrowPos);
                    $name = trim($name);
                    if ($name === '' || $name === '.' || $name === '..') return null;
                    $isDir = ($kind === 'd');
                    return [
                        'name' => $name,
                        'type' => $isDir ? 'dir' : 'file',
                        'mimeType' => $isDir ? '' : explorerGuessMimeType($name),
                        'size' => $isDir ? 0 : max(0, $size),
                    ];
                }

                // Formato Windows/IIS típico
                // 12-31-25  11:59PM       <DIR>          Folder Name
                // 12-31-25  11:59PM              12345   file name.txt
                if (preg_match('/^(\d{2}-\d{2}-\d{2})\s+(\d{2}:\d{2}[AP]M)\s+(<DIR>|\d+)\s+(.*)$/', $line, $m)) {
                    $dirOrSize = strtoupper((string)$m[3]);
                    $name = trim((string)$m[4]);
                    if ($name === '' || $name === '.' || $name === '..') return null;
                    $isDir = ($dirOrSize === '<DIR>');
                    $size = $isDir ? 0 : (int)$dirOrSize;
                    return [
                        'name' => $name,
                        'type' => $isDir ? 'dir' : 'file',
                        'mimeType' => $isDir ? '' : explorerGuessMimeType($name),
                        'size' => $size,
                    ];
                }

                // Desconocido: último fallback (evitar romper nombres con espacios si hay " -> ").
                $name = $line;
                $arrowPos = strpos($name, ' -> ');
                if ($arrowPos !== false) $name = substr($name, 0, $arrowPos);
                $name = trim($name);
                if ($name === '' || $name === '.' || $name === '..') return null;
                return [
                    'name' => $name,
                    'type' => 'file',
                    'mimeType' => explorerGuessMimeType($name),
                    'size' => 0,
                ];
            };

            $tryListWithRawlist = function($ftpConn, string $dir): array {
                $raw = @ftp_rawlist($ftpConn, $dir);
                if ($raw === false) return [];
                if (!is_array($raw)) return [];
                return $raw;
            };

            // 1) MLSD (si existe y el servidor lo soporta)
            if (function_exists('ftp_mlsd')) {
                $mlsd = @ftp_mlsd($ftp, $remoteDir === '' ? '.' : $remoteDir);
                if (is_array($mlsd) && count($mlsd) > 0) {
                    foreach ($mlsd as $e) {
                        if (!is_array($e)) continue;
                        $name = isset($e['name']) ? (string)$e['name'] : '';
                        if ($name === '' || $name === '.' || $name === '..') continue;
                        $typeFact = strtolower((string)($e['type'] ?? ''));
                        $isDir = ($typeFact === 'dir' || $typeFact === 'cdir' || $typeFact === 'pdir');
                        $size = isset($e['size']) ? (int)$e['size'] : 0;
                        if ($isDir) $size = 0;
                        $items[] = [
                            'name' => $name,
                            'type' => $isDir ? 'dir' : 'file',
                            'mimeType' => $isDir ? '' : explorerGuessMimeType($name),
                            'size' => (int)max(0, $size),
                        ];
                    }
                }
            }

            // 2) Fallback LIST/RAWLIST si MLSD no devolvió nada
            if (count($items) === 0) {
                $raw = $tryListWithRawlist($ftp, $remoteDir);
                if (count($raw) === 0 && $remoteDir !== '') {
                    $raw = $tryListWithRawlist($ftp, rtrim($remoteDir, '/') . '/');
                }

                // 2.b) Algunos servidores requieren cambiar de carpeta
                if (count($raw) === 0) {
                    $cwdOk = true;
                    if ($remoteDir !== '' && $remoteDir !== '.') {
                        $origPwd = @ftp_pwd($ftp);
                        $cwdOk = @ftp_chdir($ftp, $remoteDir);
                    }
                    if ($cwdOk) {
                        $raw = $tryListWithRawlist($ftp, '.');
                        // volver al directorio original para no afectar otras operaciones (best-effort)
                        if (isset($origPwd) && $origPwd !== false && $origPwd !== '') {
                            @ftp_chdir($ftp, (string)$origPwd);
                        }
                    }
                }

                foreach ($raw as $line) {
                    $parsed = $parseRawLine((string)$line);
                    if (!$parsed) continue;
                    $items[] = $parsed;
                }
            }

            @ftp_close($ftp);
        } else {
            echo json_encode(['success' => false, 'error' => 'Tipo de conexión remota no soportado']);
            exit;
        }

        // Guardar caché del listado (best-effort)
        if ($ttlSec > 0) {
            @file_put_contents($cacheFile, json_encode(['items' => $items], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES));
        }
        echo json_encode(['success' => true, 'items' => $items, 'path' => $relPath, 'bucket' => 'remote']);
        exit;
    }

    if ($_GET['action'] === 'explorerConnFetch') {
        $input = json_decode(file_get_contents('php://input'), true);
        $connId = isset($input['connId']) ? (string)$input['connId'] : '';
        $relPath = isset($input['path']) ? (string)$input['path'] : '';
        $name = isset($input['name']) ? (string)$input['name'] : '';
        $relPath = explorerNormalizeRelPath($relPath);
        $name = explorerSafeBasename($name);

        if ($connId === '' || $name === '') {
            echo json_encode(['success' => false, 'error' => 'Datos inválidos']);
            exit;
        }

        $currentSettings = grid_runtime_settings_read($settingsFile, $settingsBackupFile, $defaultSettings);
        $conn = explorerGetConnectionById($currentSettings, $connId);
        if (!$conn) {
            echo json_encode(['success' => false, 'error' => 'Conexión remota no encontrada']);
            exit;
        }

        $cacheDir = GRID_ROOT . '/media/explorer/remote-cache/' . preg_replace('/[^a-zA-Z0-9_\-]/', '_', $connId);
        if (!is_dir($cacheDir)) mkdir($cacheDir, 0755, true);
        $cacheId = uniqid('rc_', true);
        $cachePath = $cacheDir . '/' . $cacheId . '_' . $name;

        $dl = explorerRemoteDownloadToCache($conn, $relPath, $name, $cachePath);
        if (!$dl['ok']) {
            $payload = ['success' => false, 'error' => $dl['error'] ?? 'Error'];
            if (isset($dl['detail'])) {
                $payload['detail'] = $dl['detail'];
            }
            if (isset($dl['code'])) {
                $payload['code'] = $dl['code'];
            }
            echo json_encode($payload);
            exit;
        }

        $finfo = finfo_open(FILEINFO_MIME_TYPE);
        $mime = finfo_file($finfo, $cachePath) ?: explorerGuessMimeType($name);
        finfo_close($finfo);
        $size = filesize($cachePath) ?: 0;

        $urlPath = 'media/explorer/remote-cache/' . preg_replace('/[^a-zA-Z0-9_\-]/', '_', $connId) . '/' . basename($cachePath);
        echo json_encode(['success' => true, 'url' => $urlPath, 'mimeType' => $mime, 'size' => (int)$size]);
        exit;
    }

    if ($_GET['action'] === 'explorerConnCreateDir') {
        $input = json_decode(file_get_contents('php://input'), true);
        $connId = isset($input['connId']) ? (string)$input['connId'] : '';
        $relPath = isset($input['path']) ? (string)$input['path'] : '';
        $name = isset($input['name']) ? (string)$input['name'] : '';
        $relPath = explorerNormalizeRelPath($relPath);
        $name = explorerSafeBasename($name);
        if ($connId === '' || $name === '') {
            echo json_encode(['success' => false, 'error' => 'Datos inválidos']);
            exit;
        }
        $currentSettings = grid_runtime_settings_read($settingsFile, $settingsBackupFile, $defaultSettings);
        $conn = explorerGetConnectionById($currentSettings, $connId);
        if (!$conn) {
            echo json_encode(['success' => false, 'error' => 'Conexión remota no encontrada']);
            exit;
        }
        $type = strtolower((string)($conn['type'] ?? ''));

        if ($type === 'webdav') {
            $baseUrl = isset($conn['baseUrl']) ? (string)$conn['baseUrl'] : (string)($conn['url'] ?? '');
            if ($baseUrl === '') {
                echo json_encode(['success' => false, 'error' => 'WebDAV baseUrl faltante']);
                exit;
            }
            $url = explorerWebdavJoinUrl($baseUrl, $relPath, $name);
            $user = isset($conn['username']) ? (string)$conn['username'] : '';
            $pass = isset($conn['password']) ? (string)$conn['password'] : '';
            $resp = explorerDavRequest('MKCOL', $url, $user, $pass, null, []);
            if (!$resp['success'] || ($resp['code'] ?? 0) >= 400) {
                echo json_encode(['success' => false, 'error' => 'No se pudo crear carpeta (WebDAV)', 'detail' => $resp['error'], 'code' => $resp['code']]);
                exit;
            }
        } elseif ($type === 'ftp') {
            $host = isset($conn['host']) ? (string)$conn['host'] : '';
            if ($host === '') {
                echo json_encode(['success' => false, 'error' => 'FTP host faltante']);
                exit;
            }
            $port = isset($conn['port']) ? (int)$conn['port'] : 21;
            $user = isset($conn['username']) ? (string)$conn['username'] : '';
            $pass = isset($conn['password']) ? (string)$conn['password'] : '';
            $root = isset($conn['root']) ? (string)$conn['root'] : '';
            $root = explorerNormalizeRelPath($root);
            $remoteFullDir = $root;
            if ($relPath !== '') $remoteFullDir = ($remoteFullDir !== '' ? $remoteFullDir . '/' : '') . $relPath;
            $targetDir = ($remoteFullDir !== '' ? $remoteFullDir . '/' : '') . $name;

            $ftp = @ftp_connect($host, $port, 15);
            if (!$ftp) {
                echo json_encode(['success' => false, 'error' => 'No se pudo conectar por FTP']);
                exit;
            }
            @ftp_login($ftp, $user, $pass);
            $pasv = isset($conn['pasv']) ? (bool)$conn['pasv'] : true;
            @ftp_pasv($ftp, $pasv);
            explorerEnsureDirRecursiveFtp($ftp, $targetDir);
            @ftp_close($ftp);
        } else {
            echo json_encode(['success' => false, 'error' => 'Tipo de conexión remota no soportado']);
            exit;
        }

        explorerInvalidateRemoteListCache($connId);
        echo json_encode(['success' => true]);
        exit;
    }

    if ($_GET['action'] === 'explorerConnRename') {
        $input = json_decode(file_get_contents('php://input'), true);
        $connId = isset($input['connId']) ? (string)$input['connId'] : '';
        $relPath = isset($input['path']) ? (string)$input['path'] : '';
        $oldName = isset($input['oldName']) ? (string)$input['oldName'] : '';
        $newName = isset($input['newName']) ? (string)$input['newName'] : '';
        $relPath = explorerNormalizeRelPath($relPath);
        $oldName = explorerSafeBasename($oldName);
        $newName = explorerSafeBasename($newName);

        if ($connId === '' || $oldName === '' || $newName === '') {
            echo json_encode(['success' => false, 'error' => 'Datos inválidos']);
            exit;
        }
        $currentSettings = grid_runtime_settings_read($settingsFile, $settingsBackupFile, $defaultSettings);
        $conn = explorerGetConnectionById($currentSettings, $connId);
        if (!$conn) {
            echo json_encode(['success' => false, 'error' => 'Conexión remota no encontrada']);
            exit;
        }
        $type = strtolower((string)($conn['type'] ?? ''));

        if ($type === 'webdav') {
            $baseUrl = isset($conn['baseUrl']) ? (string)$conn['baseUrl'] : (string)($conn['url'] ?? '');
            if ($baseUrl === '') {
                echo json_encode(['success' => false, 'error' => 'WebDAV baseUrl faltante']);
                exit;
            }
            $user = isset($conn['username']) ? (string)$conn['username'] : '';
            $pass = isset($conn['password']) ? (string)$conn['password'] : '';
            $srcUrl = explorerWebdavJoinUrl($baseUrl, $relPath, $oldName);
            $dstUrl = explorerWebdavJoinUrl($baseUrl, $relPath, $newName);

            $headers = ['Destination: ' . $dstUrl, 'Overwrite: T'];
            $ch = curl_init($srcUrl);
            curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
            curl_setopt($ch, CURLOPT_CUSTOMREQUEST, 'MOVE');
            curl_setopt($ch, CURLOPT_FOLLOWLOCATION, true);
            curl_setopt($ch, CURLOPT_CONNECTTIMEOUT, 15);
            curl_setopt($ch, CURLOPT_TIMEOUT, 60);
            curl_setopt($ch, CURLOPT_HTTPHEADER, $headers);
            if ($user !== '' || $pass !== '') curl_setopt($ch, CURLOPT_USERPWD, $user . ':' . $pass);
            $respBody = curl_exec($ch);
            $code = curl_getinfo($ch, CURLINFO_HTTP_CODE);
            $err = curl_error($ch);
            curl_close($ch);
            if ($respBody === false || $code >= 400) {
                echo json_encode(['success' => false, 'error' => 'No se pudo renombrar (WebDAV)', 'detail' => $err, 'code' => $code]);
                exit;
            }
        } elseif ($type === 'ftp') {
            $host = isset($conn['host']) ? (string)$conn['host'] : '';
            if ($host === '') {
                echo json_encode(['success' => false, 'error' => 'FTP host faltante']);
                exit;
            }
            $port = isset($conn['port']) ? (int)$conn['port'] : 21;
            $user = isset($conn['username']) ? (string)$conn['username'] : '';
            $pass = isset($conn['password']) ? (string)$conn['password'] : '';
            $root = isset($conn['root']) ? (string)$conn['root'] : '';
            $root = explorerNormalizeRelPath($root);

            $remoteDir = $root;
            if ($relPath !== '') $remoteDir = ($remoteDir !== '' ? $remoteDir . '/' : '') . $relPath;
            $src = ($remoteDir !== '' ? $remoteDir . '/' : '') . $oldName;
            $dst = ($remoteDir !== '' ? $remoteDir . '/' : '') . $newName;

            $ftp = @ftp_connect($host, $port, 15);
            if (!$ftp) {
                echo json_encode(['success' => false, 'error' => 'No se pudo conectar por FTP']);
                exit;
            }
            @ftp_login($ftp, $user, $pass);
            $pasv = isset($conn['pasv']) ? (bool)$conn['pasv'] : true;
            @ftp_pasv($ftp, $pasv);
            $ok = @ftp_rename($ftp, $src, $dst);
            @ftp_close($ftp);
            if (!$ok) {
                echo json_encode(['success' => false, 'error' => 'No se pudo renombrar (FTP)']);
                exit;
            }
        } else {
            echo json_encode(['success' => false, 'error' => 'Tipo de conexión remota no soportado']);
            exit;
        }

        explorerInvalidateRemoteListCache($connId);
        echo json_encode(['success' => true]);
        exit;
    }

    if ($_GET['action'] === 'explorerConnMove') {
        $input = json_decode(file_get_contents('php://input'), true);
        $connId = isset($input['connId']) ? (string)$input['connId'] : '';
        $fromPath = explorerNormalizeRelPath(isset($input['fromPath']) ? (string)$input['fromPath'] : '');
        $toPath = explorerNormalizeRelPath(isset($input['toPath']) ? (string)$input['toPath'] : '');
        $name = explorerSafeBasename(isset($input['name']) ? (string)$input['name'] : '');
        $newNameRaw = isset($input['newName']) ? (string)$input['newName'] : '';
        $newName = $newNameRaw !== '' ? explorerSafeBasename($newNameRaw) : $name;
        $overwrite = !empty($input['overwrite']);

        if ($connId === '' || $name === '' || $newName === '') {
            echo json_encode(['success' => false, 'error' => 'Datos inválidos']);
            exit;
        }
        $currentSettings = grid_runtime_settings_read($settingsFile, $settingsBackupFile, $defaultSettings);
        $conn = explorerGetConnectionById($currentSettings, $connId);
        if (!$conn) {
            echo json_encode(['success' => false, 'error' => 'Conexión remota no encontrada']);
            exit;
        }
        $type = strtolower((string)($conn['type'] ?? ''));

        if ($type === 'webdav') {
            $baseUrl = isset($conn['baseUrl']) ? (string)$conn['baseUrl'] : (string)($conn['url'] ?? '');
            if ($baseUrl === '') {
                echo json_encode(['success' => false, 'error' => 'WebDAV baseUrl faltante']);
                exit;
            }
            $user = isset($conn['username']) ? (string)$conn['username'] : '';
            $pass = isset($conn['password']) ? (string)$conn['password'] : '';
            $srcUrl = explorerWebdavJoinUrl($baseUrl, $fromPath, $name);
            $dstUrl = explorerWebdavJoinUrl($baseUrl, $toPath, $newName);
            $headers = ['Destination: ' . $dstUrl, 'Overwrite: T'];
            $ch = curl_init($srcUrl);
            curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
            curl_setopt($ch, CURLOPT_CUSTOMREQUEST, 'MOVE');
            curl_setopt($ch, CURLOPT_FOLLOWLOCATION, true);
            curl_setopt($ch, CURLOPT_CONNECTTIMEOUT, 15);
            curl_setopt($ch, CURLOPT_TIMEOUT, 120);
            curl_setopt($ch, CURLOPT_HTTPHEADER, $headers);
            if ($user !== '' || $pass !== '') curl_setopt($ch, CURLOPT_USERPWD, $user . ':' . $pass);
            curl_exec($ch);
            $code = curl_getinfo($ch, CURLINFO_HTTP_CODE);
            $err = curl_error($ch);
            curl_close($ch);
            if ($code >= 400) {
                echo json_encode(['success' => false, 'error' => 'No se pudo mover (WebDAV)', 'detail' => $err, 'code' => $code]);
                exit;
            }
        } elseif ($type === 'ftp') {
            $host = isset($conn['host']) ? (string)$conn['host'] : '';
            if ($host === '') {
                echo json_encode(['success' => false, 'error' => 'FTP host faltante']);
                exit;
            }
            $port = isset($conn['port']) ? (int)$conn['port'] : 21;
            $user = isset($conn['username']) ? (string)$conn['username'] : '';
            $pass = isset($conn['password']) ? (string)$conn['password'] : '';
            $root = explorerNormalizeRelPath(isset($conn['root']) ? (string)$conn['root'] : '');
            $dirFrom = $root;
            if ($fromPath !== '') $dirFrom = ($dirFrom !== '' ? $dirFrom . '/' : '') . $fromPath;
            $dirTo = $root;
            if ($toPath !== '') $dirTo = ($dirTo !== '' ? $dirTo . '/' : '') . $toPath;
            $src = ($dirFrom !== '' ? $dirFrom . '/' : '') . $name;
            $dst = ($dirTo !== '' ? $dirTo . '/' : '') . $newName;

            $ftp = @ftp_connect($host, $port, 15);
            if (!$ftp) {
                echo json_encode(['success' => false, 'error' => 'No se pudo conectar por FTP']);
                exit;
            }
            @ftp_login($ftp, $user, $pass);
            $pasv = isset($conn['pasv']) ? (bool)$conn['pasv'] : true;
            @ftp_pasv($ftp, $pasv);
            explorerEnsureDirRecursiveFtp($ftp, $dirTo);
            if ($overwrite) {
                // ftp_rename no sobrescribe; si el destino existe, intentar eliminarlo antes.
                $dstSize = @ftp_size($ftp, $dst);
                if ($dstSize >= 0) {
                    @ftp_delete($ftp, $dst);
                } else {
                    // Puede ser carpeta o el servidor no soporta SIZE. Intentar borrar como dir (vacía).
                    @ftp_rmdir($ftp, $dst);
                }
            }
            $ok = @ftp_rename($ftp, $src, $dst);
            @ftp_close($ftp);
            if (!$ok) {
                echo json_encode(['success' => false, 'error' => 'No se pudo mover (FTP)']);
                exit;
            }
        } else {
            echo json_encode(['success' => false, 'error' => 'Tipo de conexión remota no soportado']);
            exit;
        }

        explorerInvalidateRemoteListCache($connId);
        echo json_encode(['success' => true]);
        exit;
    }

    if ($_GET['action'] === 'explorerConnCopy') {
        $input = json_decode(file_get_contents('php://input'), true);
        $connId = isset($input['connId']) ? (string)$input['connId'] : '';
        $fromPath = explorerNormalizeRelPath(isset($input['fromPath']) ? (string)$input['fromPath'] : '');
        $toPath = explorerNormalizeRelPath(isset($input['toPath']) ? (string)$input['toPath'] : '');
        $name = explorerSafeBasename(isset($input['name']) ? (string)$input['name'] : '');
        $newNameRaw = isset($input['newName']) ? (string)$input['newName'] : '';
        $newName = $newNameRaw !== '' ? explorerSafeBasename($newNameRaw) : $name;

        if ($connId === '' || $name === '' || $newName === '') {
            echo json_encode(['success' => false, 'error' => 'Datos inválidos']);
            exit;
        }
        $currentSettings = grid_runtime_settings_read($settingsFile, $settingsBackupFile, $defaultSettings);
        $conn = explorerGetConnectionById($currentSettings, $connId);
        if (!$conn) {
            echo json_encode(['success' => false, 'error' => 'Conexión remota no encontrada']);
            exit;
        }
        $type = strtolower((string)($conn['type'] ?? ''));

        if ($type === 'webdav') {
            $baseUrl = isset($conn['baseUrl']) ? (string)$conn['baseUrl'] : (string)($conn['url'] ?? '');
            if ($baseUrl === '') {
                echo json_encode(['success' => false, 'error' => 'WebDAV baseUrl faltante']);
                exit;
            }
            $user = isset($conn['username']) ? (string)$conn['username'] : '';
            $pass = isset($conn['password']) ? (string)$conn['password'] : '';
            $srcUrl = explorerWebdavJoinUrl($baseUrl, $fromPath, $name);
            $dstUrl = explorerWebdavJoinUrl($baseUrl, $toPath, $newName);
            $headers = ['Destination: ' . $dstUrl, 'Overwrite: T'];
            $ch = curl_init($srcUrl);
            curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
            curl_setopt($ch, CURLOPT_CUSTOMREQUEST, 'COPY');
            curl_setopt($ch, CURLOPT_FOLLOWLOCATION, true);
            curl_setopt($ch, CURLOPT_CONNECTTIMEOUT, 15);
            curl_setopt($ch, CURLOPT_TIMEOUT, 120);
            curl_setopt($ch, CURLOPT_HTTPHEADER, $headers);
            if ($user !== '' || $pass !== '') curl_setopt($ch, CURLOPT_USERPWD, $user . ':' . $pass);
            curl_exec($ch);
            $code = curl_getinfo($ch, CURLINFO_HTTP_CODE);
            $err = curl_error($ch);
            curl_close($ch);
            if ($code >= 400) {
                echo json_encode(['success' => false, 'error' => 'No se pudo copiar (WebDAV)', 'detail' => $err, 'code' => $code]);
                exit;
            }
        } elseif ($type === 'ftp') {
            $host = isset($conn['host']) ? (string)$conn['host'] : '';
            if ($host === '') {
                echo json_encode(['success' => false, 'error' => 'FTP host faltante']);
                exit;
            }
            $port = isset($conn['port']) ? (int)$conn['port'] : 21;
            $user = isset($conn['username']) ? (string)$conn['username'] : '';
            $pass = isset($conn['password']) ? (string)$conn['password'] : '';
            $root = explorerNormalizeRelPath(isset($conn['root']) ? (string)$conn['root'] : '');
            $dirFrom = $root;
            if ($fromPath !== '') $dirFrom = ($dirFrom !== '' ? $dirFrom . '/' : '') . $fromPath;
            $dirTo = $root;
            if ($toPath !== '') $dirTo = ($dirTo !== '' ? $dirTo . '/' : '') . $toPath;
            $remoteSrc = ($dirFrom !== '' ? $dirFrom . '/' : '') . $name;
            $remoteDst = ($dirTo !== '' ? $dirTo . '/' : '') . $newName;

            $ftp = @ftp_connect($host, $port, 15);
            if (!$ftp) {
                echo json_encode(['success' => false, 'error' => 'No se pudo conectar por FTP']);
                exit;
            }
            @ftp_login($ftp, $user, $pass);
            $pasv = isset($conn['pasv']) ? (bool)$conn['pasv'] : true;
            @ftp_pasv($ftp, $pasv);
            explorerEnsureDirRecursiveFtp($ftp, $dirTo);
            $tmp = tempnam(sys_get_temp_dir(), 'fe_ftp_cp_');
            if ($tmp === false) {
                @ftp_close($ftp);
                echo json_encode(['success' => false, 'error' => 'No se pudo crear temporal']);
                exit;
            }
            $okGet = @ftp_get($ftp, $tmp, $remoteSrc, FTP_BINARY);
            if (!$okGet) {
                @unlink($tmp);
                @ftp_close($ftp);
                echo json_encode(['success' => false, 'error' => 'No se pudo leer origen (FTP)']);
                exit;
            }
            $okPut = @ftp_put($ftp, $remoteDst, $tmp, FTP_BINARY);
            @unlink($tmp);
            @ftp_close($ftp);
            if (!$okPut) {
                echo json_encode(['success' => false, 'error' => 'No se pudo escribir destino (FTP)']);
                exit;
            }
        } else {
            echo json_encode(['success' => false, 'error' => 'Tipo de conexión remota no soportado']);
            exit;
        }

        explorerInvalidateRemoteListCache($connId);
        echo json_encode(['success' => true]);
        exit;
    }

    /**
     * Copia un archivo remoto (FTP/WebDAV) a la biblioteca local del explorador.
     */
    if ($_GET['action'] === 'explorerImportFromRemote') {
        $input = json_decode(file_get_contents('php://input'), true);
        $connId = isset($input['connId']) ? (string)$input['connId'] : '';
        $fromPath = explorerNormalizeRelPath(isset($input['fromPath']) ? (string)$input['fromPath'] : '');
        $name = explorerSafeBasename(isset($input['name']) ? (string)$input['name'] : '');
        $toPathRaw = isset($input['toPath']) ? (string)$input['toPath'] : '';
        $toPathRaw = str_replace(['\\', "\0"], ['/', ''], $toPathRaw);
        $toPathRaw = ltrim($toPathRaw, '/');
        if (strpos($toPathRaw, '..') !== false) {
            $toPathRaw = '';
        }
        $autoRename = !empty($input['autoRename']);
        $overwrite = !empty($input['overwrite']);
        $toBucket = isset($input['toBucket']) && $input['toBucket'] === 'cache' ? 'cache' : 'files';
        $destNameOverride = isset($input['destName']) ? explorerSafeBasename((string)$input['destName']) : '';

        if ($connId === '' || $name === '') {
            echo json_encode(['success' => false, 'error' => 'Datos inválidos']);
            exit;
        }

        $currentSettings = grid_runtime_settings_read($settingsFile, $settingsBackupFile, $defaultSettings);
        $conn = explorerGetConnectionById($currentSettings, $connId);
        if (!$conn) {
            echo json_encode(['success' => false, 'error' => 'Conexión remota no encontrada']);
            exit;
        }

        $filesBase = $toBucket === 'cache'
            ? (GRID_ROOT . '/media/explorer/remote-cache')
            : grid_explorer_local_files_base();
        $dstParent = $filesBase . ($toPathRaw !== '' ? '/' . $toPathRaw : '');
        if (!is_dir($dstParent)) {
            if (!mkdir($dstParent, 0755, true)) {
                echo json_encode(['success' => false, 'error' => 'No se pudo crear la carpeta destino']);
                exit;
            }
        }

        $tmp = tempnam(sys_get_temp_dir(), 'fe_imp_');
        if ($tmp === false) {
            echo json_encode(['success' => false, 'error' => 'No se pudo crear temporal']);
            exit;
        }

        $dl = explorerRemoteDownloadToCache($conn, $fromPath, $name, $tmp);
        if (!$dl['ok']) {
            @unlink($tmp);
            echo json_encode(['success' => false, 'error' => $dl['error'] ?? 'No se pudo descargar']);
            exit;
        }

        $destName = $destNameOverride !== '' ? $destNameOverride : $name;
        $dest = $dstParent . '/' . $destName;
        if (file_exists($dest)) {
            if ($overwrite) {
                if (is_dir($dest)) {
                    @unlink($tmp);
                    echo json_encode(['success' => false, 'error' => 'Destino es una carpeta']);
                    exit;
                }
                if (!@unlink($dest)) {
                    @unlink($tmp);
                    echo json_encode(['success' => false, 'error' => 'No se pudo reemplazar el archivo existente']);
                    exit;
                }
            } elseif ($autoRename) {
                $uniqueBase = function ($parentDir, $filename) {
                    if (!file_exists($parentDir . '/' . $filename)) {
                        return $filename;
                    }
                    $pi = pathinfo($filename);
                    $stem = isset($pi['filename']) ? $pi['filename'] : $filename;
                    $ext = isset($pi['extension']) && $pi['extension'] !== '' ? '.' . $pi['extension'] : '';
                    $n = 1;
                    do {
                        $candidate = $stem . ' (' . $n . ')' . $ext;
                        $n++;
                    } while (file_exists($parentDir . '/' . $candidate) && $n < 5000);
                    return $candidate;
                };
                $destName = $uniqueBase($dstParent, $destName);
                $dest = $dstParent . '/' . $destName;
            } else {
                @unlink($tmp);
                echo json_encode(['success' => false, 'error' => 'Ya existe en destino']);
                exit;
            }
        }

        if (!@rename($tmp, $dest)) {
            if (!@copy($tmp, $dest)) {
                @unlink($tmp);
                echo json_encode(['success' => false, 'error' => 'No se pudo guardar en local']);
                exit;
            }
            @unlink($tmp);
        }

        echo json_encode(['success' => true, 'storedAs' => $destName]);
        exit;
    }

    /**
     * Sube un archivo de la biblioteca local a una ruta remota (FTP/WebDAV).
     */
    if ($_GET['action'] === 'explorerExportToRemote') {
        $input = json_decode(file_get_contents('php://input'), true);
        $connId = isset($input['connId']) ? (string)$input['connId'] : '';
        $toPath = explorerNormalizeRelPath(isset($input['toPath']) ? (string)$input['toPath'] : '');
        $fromPathRaw = isset($input['fromPath']) ? (string)$input['fromPath'] : '';
        $fromPathRaw = str_replace(['\\', "\0"], ['/', ''], $fromPathRaw);
        $fromPathRaw = ltrim($fromPathRaw, '/');
        if (strpos($fromPathRaw, '..') !== false) {
            echo json_encode(['success' => false, 'error' => 'Ruta local inválida']);
            exit;
        }
        $name = explorerSafeBasename(isset($input['name']) ? (string)$input['name'] : '');
        $autoRename = !empty($input['autoRename']);
        $remoteNameOverrideRaw = isset($input['remoteName']) ? (string)$input['remoteName'] : '';

        if ($connId === '' || $name === '') {
            echo json_encode(['success' => false, 'error' => 'Datos inválidos']);
            exit;
        }

        $filesBase = grid_explorer_local_files_base();
        $srcDir = $filesBase . ($fromPathRaw !== '' ? '/' . $fromPathRaw : '');
        $src = $srcDir . '/' . $name;
        if (!is_file($src)) {
            echo json_encode(['success' => false, 'error' => 'Archivo local no encontrado']);
            exit;
        }

        $currentSettings = grid_runtime_settings_read($settingsFile, $settingsBackupFile, $defaultSettings);
        $conn = explorerGetConnectionById($currentSettings, $connId);
        if (!$conn) {
            echo json_encode(['success' => false, 'error' => 'Conexión remota no encontrada']);
            exit;
        }

        $type = strtolower((string)($conn['type'] ?? ''));
        $remoteName = $name;
        if ($remoteNameOverrideRaw !== '') {
            $safeOverride = explorerSafeBasename($remoteNameOverrideRaw);
            if ($safeOverride !== '') $remoteName = $safeOverride;
        }

        if ($type === 'webdav') {
            $baseUrl = isset($conn['baseUrl']) ? (string)$conn['baseUrl'] : (string)($conn['url'] ?? '');
            if ($baseUrl === '') {
                echo json_encode(['success' => false, 'error' => 'WebDAV baseUrl faltante']);
                exit;
            }
            $user = isset($conn['username']) ? (string)$conn['username'] : '';
            $pass = isset($conn['password']) ? (string)$conn['password'] : '';
            $webdavResourceExists = function ($url) use ($user, $pass) {
                $ch = curl_init($url);
                curl_setopt($ch, CURLOPT_NOBODY, true);
                curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
                curl_setopt($ch, CURLOPT_FOLLOWLOCATION, true);
                curl_setopt($ch, CURLOPT_CONNECTTIMEOUT, 15);
                curl_setopt($ch, CURLOPT_TIMEOUT, 60);
                if ($user !== '' || $pass !== '') {
                    curl_setopt($ch, CURLOPT_USERPWD, $user . ':' . $pass);
                }
                curl_exec($ch);
                $code = (int)curl_getinfo($ch, CURLINFO_HTTP_CODE);
                curl_close($ch);
                return $code >= 200 && $code < 300;
            };
            if ($autoRename) {
                $pi = pathinfo($remoteName);
                $stem = isset($pi['filename']) ? $pi['filename'] : $remoteName;
                $ext = isset($pi['extension']) && $pi['extension'] !== '' ? '.' . $pi['extension'] : '';
                $n = 0;
                while ($webdavResourceExists(explorerWebdavJoinUrl($baseUrl, $toPath, $remoteName))) {
                    $n++;
                    $remoteName = $stem . ' (' . $n . ')' . $ext;
                    if ($n > 5000) {
                        echo json_encode(['success' => false, 'error' => 'No se pudo generar nombre único (WebDAV)']);
                        exit;
                    }
                }
            }
            $dstUrl = explorerWebdavJoinUrl($baseUrl, $toPath, $remoteName);
            $fp = fopen($src, 'rb');
            if (!$fp) {
                echo json_encode(['success' => false, 'error' => 'No se pudo leer archivo local']);
                exit;
            }
            $ch = curl_init($dstUrl);
            curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
            curl_setopt($ch, CURLOPT_CUSTOMREQUEST, 'PUT');
            curl_setopt($ch, CURLOPT_UPLOAD, true);
            curl_setopt($ch, CURLOPT_INFILE, $fp);
            curl_setopt($ch, CURLOPT_INFILESIZE, filesize($src));
            curl_setopt($ch, CURLOPT_CONNECTTIMEOUT, 15);
            curl_setopt($ch, CURLOPT_TIMEOUT, 600);
            if ($user !== '' || $pass !== '') {
                curl_setopt($ch, CURLOPT_USERPWD, $user . ':' . $pass);
            }
            curl_exec($ch);
            $code = curl_getinfo($ch, CURLINFO_HTTP_CODE);
            $err = curl_error($ch);
            curl_close($ch);
            fclose($fp);
            if ($code >= 400) {
                echo json_encode(['success' => false, 'error' => 'No se pudo subir (WebDAV)', 'detail' => $err, 'code' => $code]);
                exit;
            }
        } elseif ($type === 'ftp') {
            $host = isset($conn['host']) ? (string)$conn['host'] : '';
            if ($host === '') {
                echo json_encode(['success' => false, 'error' => 'FTP host faltante']);
                exit;
            }
            $port = isset($conn['port']) ? (int)$conn['port'] : 21;
            $user = isset($conn['username']) ? (string)$conn['username'] : '';
            $pass = isset($conn['password']) ? (string)$conn['password'] : '';
            $root = explorerNormalizeRelPath(isset($conn['root']) ? (string)$conn['root'] : '');
            $remoteDir = $root;
            if ($toPath !== '') {
                $remoteDir = ($remoteDir !== '' ? $remoteDir . '/' : '') . $toPath;
            }

            $ftp = @ftp_connect($host, $port, 15);
            if (!$ftp) {
                echo json_encode(['success' => false, 'error' => 'No se pudo conectar por FTP']);
                exit;
            }
            @ftp_login($ftp, $user, $pass);
            $pasv = isset($conn['pasv']) ? (bool)$conn['pasv'] : true;
            @ftp_pasv($ftp, $pasv);
            explorerEnsureDirRecursiveFtp($ftp, $remoteDir);

            if ($autoRename) {
                $remoteFile = ($remoteDir !== '' ? $remoteDir . '/' : '') . $remoteName;
                $size = @ftp_size($ftp, $remoteFile);
                if ($size >= 0) {
                    $pi = pathinfo($remoteName);
                    $stem = isset($pi['filename']) ? $pi['filename'] : $remoteName;
                    $ext = isset($pi['extension']) && $pi['extension'] !== '' ? '.' . $pi['extension'] : '';
                    $n = 1;
                    do {
                        $candidate = $stem . ' (' . $n . ')' . $ext;
                        $tryFile = ($remoteDir !== '' ? $remoteDir . '/' : '') . $candidate;
                        $sz = @ftp_size($ftp, $tryFile);
                        if ($sz < 0) {
                            $remoteName = $candidate;
                            break;
                        }
                        $n++;
                    } while ($n < 5000);
                }
            }

            $remoteFile = ($remoteDir !== '' ? $remoteDir . '/' : '') . $remoteName;
            $ok = @ftp_put($ftp, $remoteFile, $src, FTP_BINARY);
            @ftp_close($ftp);
            if (!$ok) {
                echo json_encode(['success' => false, 'error' => 'No se pudo subir (FTP)']);
                exit;
            }
        } else {
            echo json_encode(['success' => false, 'error' => 'Tipo de conexión remota no soportado']);
            exit;
        }

        echo json_encode(['success' => true, 'storedAs' => $remoteName]);
        exit;
    }

    if ($_GET['action'] === 'explorerConnUpload') {
        // Similar a explorerUpload pero contra WebDAV/FTP
        $relPath = isset($_POST['path']) ? (string)$_POST['path'] : '';
        $connId = isset($_POST['connId']) ? (string)$_POST['connId'] : '';
        $relPath = explorerNormalizeRelPath($relPath);
        $currentSettings = grid_runtime_settings_read($settingsFile, $settingsBackupFile, $defaultSettings);
        $conn = explorerGetConnectionById($currentSettings, $connId);
        if (!$conn) {
            echo json_encode(['success' => false, 'error' => 'Conexión remota no encontrada']);
            exit;
        }
        $type = strtolower((string)($conn['type'] ?? ''));

        if (!isset($_FILES['files'])) {
            echo json_encode(['success' => false, 'error' => 'No se subieron archivos']);
            exit;
        }

        $files = $_FILES['files'];
        $count = is_array($files['name']) ? count($files['name']) : 0;
        $results = [];

        $user = isset($conn['username']) ? (string)$conn['username'] : '';
        $pass = isset($conn['password']) ? (string)$conn['password'] : '';

        if ($type === 'webdav') {
            $baseUrl = isset($conn['baseUrl']) ? (string)$conn['baseUrl'] : (string)($conn['url'] ?? '');
            if ($baseUrl === '') {
                echo json_encode(['success' => false, 'error' => 'WebDAV baseUrl faltante']);
                exit;
            }
            for ($i = 0; $i < $count; $i++) {
                if ($files['error'][$i] !== UPLOAD_ERR_OK) {
                    $results[] = ['name' => (string)$files['name'][$i], 'success' => false, 'error' => 'Upload error'];
                    continue;
                }
                $origName = (string)$files['name'][$i];
                $safeName = explorerSafeBasename($origName);
                if ($safeName === '') {
                    $results[] = ['name' => $origName, 'success' => false, 'error' => 'Nombre inválido'];
                    continue;
                }
                $tmp = $files['tmp_name'][$i];
                $remoteUrl = explorerWebdavJoinUrl($baseUrl, $relPath, $safeName);

                $mime = explorerGuessMimeType($safeName);
                $fp = fopen($tmp, 'rb');
                $ch = curl_init($remoteUrl);
                curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
                curl_setopt($ch, CURLOPT_CUSTOMREQUEST, 'PUT');
                curl_setopt($ch, CURLOPT_UPLOAD, true);
                curl_setopt($ch, CURLOPT_INFILE, $fp);
                curl_setopt($ch, CURLOPT_INFILESIZE, filesize($tmp));
                curl_setopt($ch, CURLOPT_CONNECTTIMEOUT, 15);
                curl_setopt($ch, CURLOPT_TIMEOUT, 120);
                curl_setopt($ch, CURLOPT_HTTPHEADER, ['Content-Type: ' . $mime]);
                if ($user !== '' || $pass !== '') curl_setopt($ch, CURLOPT_USERPWD, $user . ':' . $pass);
                $respBody = curl_exec($ch);
                $code = curl_getinfo($ch, CURLINFO_HTTP_CODE);
                $err = curl_error($ch);
                curl_close($ch);
                fclose($fp);

                $ok = !($respBody === false) && $code < 400;
                $results[] = ['name' => $safeName, 'success' => $ok, 'detail' => $ok ? null : $err];
            }
        } elseif ($type === 'ftp') {
            $host = isset($conn['host']) ? (string)$conn['host'] : '';
            if ($host === '') {
                echo json_encode(['success' => false, 'error' => 'FTP host faltante']);
                exit;
            }
            $port = isset($conn['port']) ? (int)$conn['port'] : 21;
            $root = isset($conn['root']) ? (string)$conn['root'] : '';
            $root = explorerNormalizeRelPath($root);
            $remoteDir = $root;
            if ($relPath !== '') $remoteDir = ($remoteDir !== '' ? $remoteDir . '/' : '') . $relPath;

            $ftp = @ftp_connect($host, $port, 15);
            if (!$ftp) {
                echo json_encode(['success' => false, 'error' => 'No se pudo conectar por FTP']);
                exit;
            }
            @ftp_login($ftp, $user, $pass);
            $pasv = isset($conn['pasv']) ? (bool)$conn['pasv'] : true;
            @ftp_pasv($ftp, $pasv);
            explorerEnsureDirRecursiveFtp($ftp, $remoteDir);

            for ($i = 0; $i < $count; $i++) {
                if ($files['error'][$i] !== UPLOAD_ERR_OK) {
                    $results[] = ['name' => (string)$files['name'][$i], 'success' => false, 'error' => 'Upload error'];
                    continue;
                }
                $origName = (string)$files['name'][$i];
                $safeName = explorerSafeBasename($origName);
                if ($safeName === '') {
                    $results[] = ['name' => $origName, 'success' => false, 'error' => 'Nombre inválido'];
                    continue;
                }
                $tmp = $files['tmp_name'][$i];
                $remoteFile = ($remoteDir !== '' ? $remoteDir . '/' : '') . $safeName;
                $ok = @ftp_put($ftp, $remoteFile, $tmp, FTP_BINARY);
                $results[] = ['name' => $safeName, 'success' => (bool)$ok];
            }
            @ftp_close($ftp);
        } else {
            echo json_encode(['success' => false, 'error' => 'Tipo de conexión remota no soportado']);
            exit;
        }

        explorerInvalidateRemoteListCache($connId);
        echo json_encode(['success' => true, 'results' => $results]);
        exit;
    }

    if ($_GET['action'] === 'explorerConnDelete') {
        $input = json_decode(file_get_contents('php://input'), true);
        $connId = isset($input['connId']) ? (string)$input['connId'] : '';
        $relPath = isset($input['path']) ? (string)$input['path'] : '';
        $name = isset($input['name']) ? (string)$input['name'] : '';
        $relPath = explorerNormalizeRelPath($relPath);
        $name = explorerSafeBasename($name);
        $kind = isset($input['kind']) ? (string)$input['kind'] : 'file';

        if ($connId === '' || $name === '') {
            echo json_encode(['success' => false, 'error' => 'Datos inválidos']);
            exit;
        }
        $currentSettings = grid_runtime_settings_read($settingsFile, $settingsBackupFile, $defaultSettings);
        $conn = explorerGetConnectionById($currentSettings, $connId);
        if (!$conn) {
            echo json_encode(['success' => false, 'error' => 'Conexión remota no encontrada']);
            exit;
        }
        $type = strtolower((string)($conn['type'] ?? ''));

        if ($type === 'webdav') {
            $baseUrl = isset($conn['baseUrl']) ? (string)$conn['baseUrl'] : (string)($conn['url'] ?? '');
            if ($baseUrl === '') {
                echo json_encode(['success' => false, 'error' => 'WebDAV baseUrl faltante']);
                exit;
            }
            $url = explorerWebdavJoinUrl($baseUrl, $relPath, $name);
            if ($kind === 'dir') $url = rtrim($url, '/') . '/';
            $user = isset($conn['username']) ? (string)$conn['username'] : '';
            $pass = isset($conn['password']) ? (string)$conn['password'] : '';
            $resp = explorerDavRequest('DELETE', $url, $user, $pass, null, []);
            if (!$resp['success'] || ($resp['code'] ?? 0) >= 400) {
                echo json_encode(['success' => false, 'error' => 'No se pudo borrar (WebDAV)', 'detail' => $resp['error'], 'code' => $resp['code']]);
                exit;
            }
        } elseif ($type === 'ftp') {
            $host = isset($conn['host']) ? (string)$conn['host'] : '';
            if ($host === '') {
                echo json_encode(['success' => false, 'error' => 'FTP host faltante']);
                exit;
            }
            $port = isset($conn['port']) ? (int)$conn['port'] : 21;
            $user = isset($conn['username']) ? (string)$conn['username'] : '';
            $pass = isset($conn['password']) ? (string)$conn['password'] : '';
            $root = isset($conn['root']) ? (string)$conn['root'] : '';
            $root = explorerNormalizeRelPath($root);
            $remoteDir = $root;
            if ($relPath !== '') $remoteDir = ($remoteDir !== '' ? $remoteDir . '/' : '') . $relPath;
            $remoteTarget = ($remoteDir !== '' ? $remoteDir . '/' : '') . $name;

            $ftp = @ftp_connect($host, $port, 15);
            if (!$ftp) {
                echo json_encode(['success' => false, 'error' => 'No se pudo conectar por FTP']);
                exit;
            }
            @ftp_login($ftp, $user, $pass);
            $pasv = isset($conn['pasv']) ? (bool)$conn['pasv'] : true;
            @ftp_pasv($ftp, $pasv);
            @ftp_raw($ftp, 'OPTS UTF8 ON');
            @ftp_raw($ftp, 'TYPE I');

            if ($kind === 'dir') {
                $ok = explorerFtpDeleteRecursive($ftp, $remoteTarget);
            } else {
                $ok = @ftp_delete($ftp, $remoteTarget);
            }
            @ftp_close($ftp);
            if (!$ok) {
                echo json_encode(['success' => false, 'error' => 'No se pudo borrar (FTP)']);
                exit;
            }
        } else {
            echo json_encode(['success' => false, 'error' => 'Tipo de conexión remota no soportado']);
            exit;
        }

        explorerInvalidateRemoteListCache($connId);
        echo json_encode(['success' => true]);
        exit;
    }

    if ($_GET['action'] === 'explorerConnDeleteToTrash') {
        // Para mantener la experiencia de “papelera” (solo archivos, no carpetas).
        $input = json_decode(file_get_contents('php://input'), true);
        $connId = isset($input['connId']) ? (string)$input['connId'] : '';
        $relPath = isset($input['path']) ? (string)$input['path'] : '';
        $name = isset($input['name']) ? (string)$input['name'] : '';
        $relPath = explorerNormalizeRelPath($relPath);
        $name = explorerSafeBasename($name);

        if ($connId === '' || $name === '') {
            echo json_encode(['success' => false, 'error' => 'Datos inválidos']);
            exit;
        }

        $currentSettings = grid_runtime_settings_read($settingsFile, $settingsBackupFile, $defaultSettings);
        $conn = explorerGetConnectionById($currentSettings, $connId);
        if (!$conn) {
            echo json_encode(['success' => false, 'error' => 'Conexión remota no encontrada']);
            exit;
        }
        $type = strtolower((string)($conn['type'] ?? ''));

        // Descargar a trash local
        $trashBase = grid_explorer_local_trash_base();
        $id = uniqid('trash_remote_', true);
        $destBase = $trashBase . '/' . $id;
        mkdir($destBase, 0755, true);
        $dest = $destBase . '/' . $name;

        $mime = explorerGuessMimeType($name);
        $size = 0;

        if ($type === 'webdav') {
            $baseUrl = isset($conn['baseUrl']) ? (string)$conn['baseUrl'] : (string)($conn['url'] ?? '');
            if ($baseUrl === '') {
                echo json_encode(['success' => false, 'error' => 'WebDAV baseUrl faltante']);
                exit;
            }
            $url = explorerWebdavJoinUrl($baseUrl, $relPath, $name);
            $user = isset($conn['username']) ? (string)$conn['username'] : '';
            $pass = isset($conn['password']) ? (string)$conn['password'] : '';
            $ch = curl_init($url);
            curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
            curl_setopt($ch, CURLOPT_FOLLOWLOCATION, true);
            curl_setopt($ch, CURLOPT_CONNECTTIMEOUT, 15);
            curl_setopt($ch, CURLOPT_TIMEOUT, 120);
            if ($user !== '' || $pass !== '') curl_setopt($ch, CURLOPT_USERPWD, $user . ':' . $pass);
            $data = curl_exec($ch);
            $err = curl_error($ch);
            $code = curl_getinfo($ch, CURLINFO_HTTP_CODE);
            curl_close($ch);
            if ($data === false || $code >= 400) {
                echo json_encode(['success' => false, 'error' => 'No se pudo descargar para borrar (WebDAV)', 'detail' => $err, 'code' => $code]);
                exit;
            }
            file_put_contents($dest, $data);
        } elseif ($type === 'ftp') {
            $host = isset($conn['host']) ? (string)$conn['host'] : '';
            if ($host === '') {
                echo json_encode(['success' => false, 'error' => 'FTP host faltante']);
                exit;
            }
            $port = isset($conn['port']) ? (int)$conn['port'] : 21;
            $user = isset($conn['username']) ? (string)$conn['username'] : '';
            $pass = isset($conn['password']) ? (string)$conn['password'] : '';
            $root = isset($conn['root']) ? (string)$conn['root'] : '';
            $root = explorerNormalizeRelPath($root);
            $remoteDir = $root;
            if ($relPath !== '') $remoteDir = ($remoteDir !== '' ? $remoteDir . '/' : '') . $relPath;
            $remoteFile = ($remoteDir !== '' ? $remoteDir . '/' : '') . $name;

            $ftp = @ftp_connect($host, $port, 15);
            if (!$ftp) {
                echo json_encode(['success' => false, 'error' => 'No se pudo conectar por FTP']);
                exit;
            }
            @ftp_login($ftp, $user, $pass);
            $pasv = isset($conn['pasv']) ? (bool)$conn['pasv'] : true;
            @ftp_pasv($ftp, $pasv);
            $ok = @ftp_get($ftp, $dest, $remoteFile, FTP_BINARY);
            @ftp_close($ftp);
            if (!$ok) {
                echo json_encode(['success' => false, 'error' => 'No se pudo descargar para borrar (FTP)']);
                exit;
            }
        } else {
            echo json_encode(['success' => false, 'error' => 'Tipo de conexión remota no soportado']);
            exit;
        }

        $finfo = finfo_open(FILEINFO_MIME_TYPE);
        $mime = finfo_file($finfo, $dest) ?: $mime;
        finfo_close($finfo);
        $size = filesize($dest) ?: 0;

        // Borrar remoto tras descargar
        $deleteOk = false;
        if ($type === 'webdav') {
            $baseUrl = isset($conn['baseUrl']) ? (string)$conn['baseUrl'] : (string)($conn['url'] ?? '');
            $url = explorerWebdavJoinUrl($baseUrl, $relPath, $name);
            $user = isset($conn['username']) ? (string)$conn['username'] : '';
            $pass = isset($conn['password']) ? (string)$conn['password'] : '';
            $resp = explorerDavRequest('DELETE', $url, $user, $pass, null, []);
            $deleteOk = $resp['success'] && ($resp['code'] ?? 0) < 400;
        } elseif ($type === 'ftp') {
            $host = isset($conn['host']) ? (string)$conn['host'] : '';
            $port = isset($conn['port']) ? (int)$conn['port'] : 21;
            $user = isset($conn['username']) ? (string)$conn['username'] : '';
            $pass = isset($conn['password']) ? (string)$conn['password'] : '';
            $root = isset($conn['root']) ? (string)$conn['root'] : '';
            $root = explorerNormalizeRelPath($root);
            $remoteDir = $root;
            if ($relPath !== '') $remoteDir = ($remoteDir !== '' ? $remoteDir . '/' : '') . $relPath;
            $remoteFile = ($remoteDir !== '' ? $remoteDir . '/' : '') . $name;
            $ftp = @ftp_connect($host, $port, 15);
            @ftp_login($ftp, $user, $pass);
            $pasv = isset($conn['pasv']) ? (bool)$conn['pasv'] : true;
            @ftp_pasv($ftp, $pasv);
            $deleteOk = @ftp_delete($ftp, $remoteFile);
            @ftp_close($ftp);
        }

        if (!$deleteOk) {
            echo json_encode(['success' => false, 'error' => 'Se descargó pero no se pudo borrar en remoto']);
            exit;
        }

        if (!isset($currentSettings['fileExplorerTrash']) || !is_array($currentSettings['fileExplorerTrash'])) {
            $currentSettings['fileExplorerTrash'] = [];
        }
        $currentSettings['fileExplorerTrash'][] = [
            'id' => $id,
            'name' => $name,
            'kind' => 'file',
            'originalPath' => $relPath,
            'mimeType' => $mime,
            'size' => (int)$size,
            'deletedAt' => time(),
            'sourceType' => 'remote',
            'connectionId' => (string)$connId,
            'originalRemotePath' => $relPath
        ];

        grid_runtime_settings_save_patch($settingsFile, $settingsBackupFile, $defaultSettings, [
            'fileExplorerTrash' => $currentSettings['fileExplorerTrash'],
        ]);
        explorerInvalidateRemoteListCache($connId);
        echo json_encode(['success' => true, 'id' => $id]);
        exit;
    }

    // ========== LOGS DE ACTIVIDAD ==========
    if ($_GET['action'] === 'logEvent') {
        $payload = json_decode(file_get_contents('php://input'), true);
        if (!$payload) $payload = [];

        $logDir = GRID_ROOT . '/logs';
        if (!is_dir($logDir)) mkdir($logDir, 0755, true);
        $logFile = $logDir . '/activity.log';
        if (!file_exists($logFile)) file_put_contents($logFile, '');

        $type = isset($payload['type']) ? (string)$payload['type'] : 'event';
        $data = isset($payload['data']) ? $payload['data'] : [];
        $ts = isset($payload['ts']) ? (int)$payload['ts'] : time();
        $iso = gmdate('c', $ts);

        $line = $iso . ' | ' . $type . ' | ' . json_encode($data, JSON_UNESCAPED_UNICODE);
        file_put_contents($logFile, $line . PHP_EOL, FILE_APPEND | LOCK_EX);

        echo json_encode(['success' => true]);
        exit;
    }

    if ($_GET['action'] === 'getActivityLog') {
        $limit = 250;
        $payload = json_decode(file_get_contents('php://input'), true);
        if (is_array($payload) && isset($payload['limit'])) {
            $limit = max(20, min(2000, (int)$payload['limit']));
        }
        $logFile = GRID_ROOT . '/logs/activity.log';
        if (!file_exists($logFile)) file_put_contents($logFile, '');

        $all = file($logFile, FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES);
        $tail = array_slice($all, max(0, count($all) - $limit));
        echo json_encode(['success' => true, 'lines' => $tail]);
        exit;
    }

    if ($_GET['action'] === 'clearActivityLog') {
        $logFile = GRID_ROOT . '/logs/activity.log';
        if (!file_exists($logFile)) file_put_contents($logFile, '');
        file_put_contents($logFile, '');
        echo json_encode(['success' => true]);
        exit;
    }

    if ($_GET['action'] === 'rotateActivityLog') {
        $logDir = GRID_ROOT . '/logs';
        if (!is_dir($logDir)) mkdir($logDir, 0755, true);
        $logFile = $logDir . '/activity.log';
        if (!file_exists($logFile)) file_put_contents($logFile, '');
        $ts = date('Ymd_His');
        $rotated = $logDir . '/activity_' . $ts . '.log';
        @rename($logFile, $rotated);
        file_put_contents($logFile, '');
        echo json_encode(['success' => true, 'rotated' => basename($rotated)]);
        exit;
    }

    if ($_GET['action'] === 'prepareInstallWizard' && $_SERVER['REQUEST_METHOD'] === 'POST') {
        require_once GRID_ROOT . '/core/auth/bootstrap_auth.php';
        require_once GRID_ROOT . '/core/security/logger.php';
        $su = AntaraSession::user();
        if (!$su || empty($su['id'])) {
            echo json_encode(['success' => false, 'error' => 'Solo un superadministrador puede preparar la reinstalación. Inicia sesión e inténtalo de nuevo.'], JSON_UNESCAPED_UNICODE);
            exit;
        }
        $uid = (string)$su['id'];
        $promoted = AntaraStore::promoteToSuperIfNoSuper($uid);
        if ($promoted !== null && ($promoted['role'] ?? '') === AntaraStore::ROLE_SUPER) {
            AntaraLogger::log('auth.promote_super_orphan', ['user' => $uid]);
        }
        $fresh = AntaraStore::findUserById($uid);
        if ($fresh && (($fresh['role'] ?? '') !== ($su['role'] ?? ''))) {
            AntaraSession::login($fresh);
        }
        $u = AntaraSession::user();
        if (!$u || (($u['role'] ?? '') !== AntaraStore::ROLE_SUPER)) {
            echo json_encode(['success' => false, 'error' => 'Solo un superadministrador puede preparar la reinstalación. Inicia sesión e inténtalo de nuevo.'], JSON_UNESCAPED_UNICODE);
            exit;
        }
        $raw = file_get_contents('php://input');
        $body = is_string($raw) ? json_decode($raw, true) : null;
        $keep = false;
        if (is_array($body) && array_key_exists('keepExplorerFiles', $body)) {
            $keep = !empty($body['keepExplorerFiles']);
        }
        require_once GRID_ROOT . '/includes/install_checks.php';
        $r = antara_install_prepare_reopen_wizard($keep);
        if (empty($r['ok'])) {
            echo json_encode(['success' => false, 'error' => (string) ($r['error'] ?? 'Error')], JSON_UNESCAPED_UNICODE);
            exit;
        }
        require_once GRID_ROOT . '/includes/install_public.php';
        echo json_encode(['success' => true, 'installUrl' => antara_install_base_url() . '?fresh=1'], JSON_UNESCAPED_UNICODE);
        exit;
    }

    // ========== BACKUP ZIP (config + media + logs) ==========
    if ($_GET['action'] === 'exportAllZip') {
        $zipDir = GRID_ROOT . '/temp';
        if (!is_dir($zipDir)) mkdir($zipDir, 0755, true);

        $zipName = 'grid_backup_' . date('Ymd_His') . '.zip';
        $zipPath = $zipDir . '/' . $zipName;

        $zip = new ZipArchive();
        if ($zip->open($zipPath, ZipArchive::CREATE | ZipArchive::OVERWRITE) !== true) {
            echo json_encode(['success' => false, 'error' => 'No se pudo crear ZIP']);
            exit;
        }

        $settingsPath = GRID_ROOT . '/settings.json';
        if (file_exists($settingsPath)) {
            $zip->addFile($settingsPath, 'settings.json');
        }

        $addDir = function($dirPath, $zipPrefix) use ($zip) {
            if (!is_dir($dirPath)) return;
            $it = new RecursiveIteratorIterator(new RecursiveDirectoryIterator($dirPath, FilesystemIterator::SKIP_DOTS));
        };

        $addToZipRec = function($dirPath, $zipPrefix) use ($zip) {
            $items = scandir($dirPath);
            if ($items === false) return;
            foreach ($items as $it) {
                if ($it === '.' || $it === '..') continue;
                $full = $dirPath . '/' . $it;
                $rel = $zipPrefix . '/' . $it;
                if (is_dir($full)) {
                    $zip->addEmptyDir($rel);
                    $addToZipRec($full, $rel);
                } else {
                    $zip->addFile($full, $rel);
                }
            }
        };

        $addToZipRec(GRID_ROOT . '/media', 'media');
        $addToZipRec(GRID_ROOT . '/logs', 'logs');
        $zip->close();

        header('Content-Type: application/zip');
        header('Content-Disposition: attachment; filename="' . $zipName . '"');
        header('Content-Length: ' . filesize($zipPath));
        readfile($zipPath);
        @unlink($zipPath);
        exit;
    }

    if ($_GET['action'] === 'importAllZip') {
        if (!isset($_FILES['zipFile'])) {
            echo json_encode(['success' => false, 'error' => 'No ZIP enviado']);
            exit;
        }
        $tmpZip = $_FILES['zipFile']['tmp_name'];
        if (!is_uploaded_file($tmpZip)) {
            echo json_encode(['success' => false, 'error' => 'ZIP inválido']);
            exit;
        }

        $extractDir = GRID_ROOT . '/temp/import_' . uniqid();
        mkdir($extractDir, 0755, true);

        $zip = new ZipArchive();
        if ($zip->open($tmpZip) !== true) {
            echo json_encode(['success' => false, 'error' => 'No se pudo abrir ZIP']);
            exit;
        }

        // Extraer con validación de rutas (anti ZipSlip básico).
        for ($i = 0; $i < $zip->numFiles; $i++) {
            $name = $zip->getNameIndex($i);
            if ($name === false) continue;
            $name = str_replace(['\\', "\0"], ['/', ''], $name);
            if (strpos($name, '..') !== false) continue;
            if (strpos($name, ':') !== false) continue;
            $target = $extractDir . '/' . $name;

            if (substr($name, -1) === '/') {
                if (!is_dir($target)) mkdir($target, 0755, true);
                continue;
            }
            $dir = dirname($target);
            if (!is_dir($dir)) mkdir($dir, 0755, true);

            $stream = $zip->getStream($name);
            if (!$stream) continue;
            $bytes = stream_copy_to_stream($stream, fopen($target, 'w'));
            if (is_resource($stream)) fclose($stream);
        }
        $zip->close();

        // Reemplazar settings.json
        $importSettings = $extractDir . '/settings.json';
        if (file_exists($importSettings)) {
            copy($importSettings, $settingsFile);
        }

        $recursiveDelete = function($path) use (&$recursiveDelete) {
            if (is_file($path) || is_link($path)) {
                @unlink($path);
                return;
            }
            if (!is_dir($path)) return;
            $items = scandir($path);
            if ($items) {
                foreach ($items as $it) {
                    if ($it === '.' || $it === '..') continue;
                    $recursiveDelete($path . '/' . $it);
                }
            }
            @rmdir($path);
        };

        $copyRecursive = function($src, $dst) use (&$copyRecursive) {
            if (is_file($src)) {
                copy($src, $dst);
                return;
            }
            if (!is_dir($dst)) mkdir($dst, 0755, true);
            $items = scandir($src);
            if (!$items) return;
            foreach ($items as $it) {
                if ($it === '.' || $it === '..') continue;
                $from = $src . '/' . $it;
                $to = $dst . '/' . $it;
                if (is_dir($from)) {
                    $copyRecursive($from, $to);
                } else {
                    copy($from, $to);
                }
            }
        };

        foreach (['media', 'logs'] as $dirName) {
            $src = $extractDir . '/' . $dirName;
            if (!is_dir($src)) continue;
            $dst = GRID_ROOT . '/' . $dirName;
            if (is_dir($dst)) $recursiveDelete($dst);
            mkdir($dst, 0755, true);
            $copyRecursive($src, $dst);
        }

        // Cleanup
        $recursiveDelete($extractDir);

        echo json_encode(['success' => true]);
        exit;
    }

    require_once __DIR__ . '/api_arquitect.php';
    if (arquitect_api_dispatch($_GET['action'] ?? '')) {
        exit;
    }
}
