<?php
/**
 * DokuWiki Plugin siedeployer (Helper Component)
 *
 * Logica de negocio para aprovisionamiento de nuevos sistemas SIE.
 * Gestiona despliegues, 16 pasos independientes, logging y utilidades de filesystem.
 *
 * @license GPL 2 http://www.gnu.org/licenses/gpl-2.0.html
 * @author  SIE Systems <soporte@systemsie.site>
 */

class helper_plugin_siedeployer extends DokuWiki_Plugin
{
    /** @var helper_plugin_sqlite */
    protected $sqlite = null;

    /** @var bool */
    protected $dbInitialized = false;

    /** @var array Nombres de los 19 pasos */
    protected static $stepNames = [
        1  => 'Escanear y Duplicar Sistema',
        2  => 'Titulo y URL',
        3  => 'Correos Electronicos',
        4  => 'Token IA',
        5  => 'Limpiar Base de Datos',
        6  => 'Logos Smileys',
        7  => 'Limpiar Carpeta Data',
        8  => 'Imagenes Media Wiki',
        9  => 'Configuracion ACL',
        10 => 'Configuracion Usuarios',
        11 => 'Favicon Template 20cones',
        12 => 'Imagenes Template 20cones',
        13 => 'Favicon Template 21cones',
        14 => 'Imagenes Template 21cones',
        15 => 'Documentacion ZIP',
        16 => 'Crear Subdominio y Certificado SSL',
        17 => 'Copia de Seguridad',
        18 => 'Zona de Peligro',
        19 => 'Editor Documental',
    ];

    /** @var string Nombre de usuario cPanel del servidor */
    public static $cpanelUser = 'systeng3';

    /** @var string Dominio raiz para subdominios */
    public static $rootDomain = 'systemsie.site';

    /** @var array Dominios disponibles para migracion */
    public static $availableDomains = [
        'systemsie.site',
        'sistemavirtualdegestion.com',
        'susprofesionales.com',
        'sieasesorias.com',
        'gestioncontrolada.com',  // BUG10-FIX: dominio faltante agregado
    ];

    /**
     * Retorna el flag --user=cpanelUser solo si el proceso corre como root.
     * Desde PHP-FPM ya somos el usuario cPanel, no necesita --user.
     */
    private function _cpUserFlag()
    {
        $isRoot = (function_exists('posix_getuid') && posix_getuid() === 0);
        return $isRoot ? '--user=' . escapeshellarg(self::$cpanelUser) . ' ' : '';
    }

    /**
     * Verifica si exec() esta realmente disponible (no solo definida, sino no deshabilitada).
     */
    private function _execAvailable()
    {
        if (!function_exists('exec')) return false;
        $disabled = array_map('trim', explode(',', ini_get('disable_functions')));
        return !in_array('exec', $disabled);
    }

    /**
     * Ejecuta una llamada a la API de cPanel/WHM.
     * Intenta exec() (CLI) primero; si no esta disponible, usa cURL HTTP API.
     *
     * @param string $api     'cpapi2', 'uapi' o 'whmapi1'
     * @param string $module  Modulo cPanel (ej: 'SubDomain', 'DomainInfo')
     * @param string $func    Funcion (ej: 'listsubdomains', 'addsubdomain')
     * @param array  $params  Parametros key=>value
     * @return array ['success'=>bool, 'data'=>array|null, 'raw'=>string, 'method'=>'exec'|'curl', 'error'=>string]
     */
    private function _cpanelApiCall($api, $module, $func, $params = [])
    {
        // ── Intento 1: exec() CLI ──
        if ($this->_execAvailable()) {
            $result = $this->_cpanelApiCallExec($api, $module, $func, $params);

            // Si exec fallo (cualquier codigo != 0) → intentar cURL como fallback
            // Codigos comunes de fallo CLI:
            //   1   = permiso denegado (whmapi1 requiere root, PHP-FPM no lo es)
            //   127 = comando no encontrado (binario no esta en PATH)
            if (!$result['success'] && isset($result['returnVar']) && $result['returnVar'] !== 0) {
                $resultCurl = $this->_cpanelApiCallCurl($api, $module, $func, $params);
                // Si cURL funciono, usarlo; si no, devolver error combinado
                if ($resultCurl['success']) return $resultCurl;
                // cURL tambien fallo: devolver error mas informativo
                $result['error'] .= ' | Fallback cURL: ' . ($resultCurl['error'] ?: 'fallo');
            }

            return $result;
        }

        // ── Intento 2: cURL HTTP API ──
        return $this->_cpanelApiCallCurl($api, $module, $func, $params);
    }

    /**
     * Llamada cPanel via exec() CLI (metodo original).
     */
    private function _cpanelApiCallExec($api, $module, $func, $params = [])
    {
        $userFlag = $this->_cpUserFlag();

        // Rutas completas — PHP-FPM no tiene /usr/local/cpanel/bin en PATH
        $binPaths = [
            'cpapi2'  => '/usr/local/cpanel/bin/cpapi2',
            'uapi'    => '/usr/local/cpanel/bin/uapi',
            'whmapi1' => '/usr/local/cpanel/bin/whmapi1',
        ];

        // Construir comando segun API
        if ($api === 'cpapi2') {
            $paramStr = '';
            foreach ($params as $k => $v) {
                $paramStr .= ' ' . $k . '=' . escapeshellarg($v);
            }
            $bin = is_executable($binPaths['cpapi2']) ? $binPaths['cpapi2'] : 'cpapi2';
            $cmd = "{$bin} --output=json {$userFlag}{$module} {$func}{$paramStr} 2>&1";
        } elseif ($api === 'uapi') {
            $paramStr = '';
            foreach ($params as $k => $v) {
                $paramStr .= ' ' . $k . '=' . escapeshellarg($v);
            }
            $bin = is_executable($binPaths['uapi']) ? $binPaths['uapi'] : 'uapi';
            $cmd = "{$bin} --output=json {$userFlag}{$module} {$func}{$paramStr} 2>&1";
        } elseif ($api === 'whmapi1') {
            $paramStr = '';
            foreach ($params as $k => $v) {
                $paramStr .= ' ' . $k . '=' . escapeshellarg($v);
            }
            $bin = is_executable($binPaths['whmapi1']) ? $binPaths['whmapi1'] : 'whmapi1';
            $cmd = "{$bin} --output=json {$func}{$paramStr} 2>&1";
        } else {
            return ['success' => false, 'data' => null, 'raw' => '', 'method' => 'exec', 'error' => "API desconocida: $api"];
        }

        $output = [];
        $returnVar = -1;
        @exec($cmd, $output, $returnVar);
        $raw = implode("\n", $output);
        $data = json_decode($raw, true);

        return [
            'success'    => ($returnVar === 0 && $data !== null),
            'data'       => $data,
            'raw'        => $raw,
            'method'     => 'exec',
            'returnVar'  => $returnVar,
            'error'      => ($returnVar !== 0) ? "exec() retorno codigo $returnVar" : '',
        ];
    }

    /**
     * Llamada cPanel via cURL HTTP API (fallback cuando exec() no esta disponible).
     *
     * Endpoints:
     *   cpapi2:  https://127.0.0.1:2083/json-api/cpanel?cpanel_jsonapi_user=X&cpanel_jsonapi_apiversion=2&cpanel_jsonapi_module=M&cpanel_jsonapi_func=F&params
     *   uapi:    https://127.0.0.1:2083/execute/Module/function?params
     *   whmapi1: https://127.0.0.1:2087/json-api/function?params
     */
    private function _cpanelApiCallCurl($api, $module, $func, $params = [])
    {
        $cpUser   = self::$cpanelUser;
        $cpToken  = $this->getConf('cpanel_api_token');
        $whmToken = $this->getConf('whm_api_token');
        $whmUser  = $this->getConf('whm_api_user') ?: 'root';
        $cpPort   = $this->getConf('cpanel_port') ?: '2083';
        $whmPort  = $this->getConf('whm_port') ?: '2087';

        // Validar token
        if ($api === 'whmapi1') {
            if (empty($whmToken)) {
                return ['success' => false, 'data' => null, 'raw' => '', 'method' => 'curl',
                        'error' => 'whm_api_token no configurado. Configure en Admin > Configuracion > siedeployer'];
            }
            // WHM tokens se crean bajo el usuario WHM (root), NO bajo el usuario cPanel
            $authHeader = "Authorization: whm {$whmUser}:{$whmToken}";
        } else {
            if (empty($cpToken)) {
                return ['success' => false, 'data' => null, 'raw' => '', 'method' => 'curl',
                        'error' => 'cpanel_api_token no configurado. Configure en Admin > Configuracion > siedeployer'];
            }
            $authHeader = "Authorization: cpanel {$cpUser}:{$cpToken}";
        }

        // Construir URL
        if ($api === 'cpapi2') {
            $query = http_build_query(array_merge([
                'cpanel_jsonapi_user'       => $cpUser,
                'cpanel_jsonapi_apiversion' => '2',
                'cpanel_jsonapi_module'     => $module,
                'cpanel_jsonapi_func'       => $func,
            ], $params));
            $url = "https://127.0.0.1:{$cpPort}/json-api/cpanel?{$query}";
        } elseif ($api === 'uapi') {
            $query = !empty($params) ? '?' . http_build_query($params) : '';
            $url = "https://127.0.0.1:{$cpPort}/execute/{$module}/{$func}{$query}";
        } elseif ($api === 'whmapi1') {
            $query = !empty($params) ? '?' . http_build_query($params) : '';
            $url = "https://127.0.0.1:{$whmPort}/json-api/{$func}{$query}";
        } else {
            return ['success' => false, 'data' => null, 'raw' => '', 'method' => 'curl', 'error' => "API desconocida: $api"];
        }

        // Ejecutar cURL
        if (!function_exists('curl_init')) {
            return ['success' => false, 'data' => null, 'raw' => '', 'method' => 'curl',
                    'error' => 'Extension curl no disponible en PHP'];
        }

        $ch = curl_init($url);
        curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
        curl_setopt($ch, CURLOPT_HTTPHEADER, [$authHeader]);
        curl_setopt($ch, CURLOPT_SSL_VERIFYPEER, false);
        curl_setopt($ch, CURLOPT_SSL_VERIFYHOST, false);
        curl_setopt($ch, CURLOPT_TIMEOUT, 60);
        curl_setopt($ch, CURLOPT_CONNECTTIMEOUT, 15);

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

        if ($raw === false || !empty($curlError)) {
            return ['success' => false, 'data' => null, 'raw' => '', 'method' => 'curl',
                    'error' => "cURL error: $curlError"];
        }

        $data = json_decode($raw, true);

        return [
            'success'   => ($httpCode >= 200 && $httpCode < 300 && $data !== null),
            'data'      => $data,
            'raw'       => $raw,
            'method'    => 'curl',
            'httpCode'  => $httpCode,
            'error'     => ($httpCode >= 400) ? "HTTP $httpCode" : '',
        ];
    }

    // =========================================================================
    //  INICIALIZACION DB
    // =========================================================================

    public function getDB()
    {
        if ($this->dbInitialized) {
            return $this->sqlite;
        }

        $this->dbInitialized = true;

        /** @var helper_plugin_sqlite $sqlite */
        $sqlite = plugin_load('helper', 'sqlite');
        if (!$sqlite) {
            msg('El plugin SQLite es requerido para siedeployer', -1);
            return null;
        }

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

        $this->sqlite = $sqlite;
        return $this->sqlite;
    }

    /**
     * Verifica si la columna root_domain existe en la tabla deployments.
     * Necesario para compatibilidad cuando la migración update0003.sql aún no se ha aplicado.
     */
    protected function _hasRootDomainColumn()
    {
        static $hasColumn = null;
        if ($hasColumn !== null) return $hasColumn;

        $db = $this->getDB();
        if (!$db) { $hasColumn = false; return false; }

        $res = $db->query("PRAGMA table_info(deployments)");
        $rows = $db->res2arr($res);
        $hasColumn = false;
        foreach ($rows as $row) {
            if ($row['name'] === 'root_domain') {
                $hasColumn = true;
                break;
            }
        }
        return $hasColumn;
    }

    // =========================================================================
    //  CRUD DESPLIEGUES
    // =========================================================================

    public function createDeployment($name, $sourcePath, $targetPath, $subdomain, $user, $mode = 'new', $rootDomain = '')
    {
        $db = $this->getDB();
        if (!$db) return null;

        // Verificar si ya existe un despliegue con esa ruta destino
        $res = $db->query('SELECT id, name FROM deployments WHERE target_path = ?', $targetPath);
        $existing = $db->res2row($res);
        if ($existing) {
            return ['error' => 'Ya existe un despliegue con esa ruta destino: "' . $existing['name'] . '" (ID ' . $existing['id'] . '). Elimine el existente o use "Gestionar".', 'existing_id' => $existing['id']];
        }

        // Validar modo
        if (!in_array($mode, ['new', 'modify'])) {
            $mode = 'new';
        }

        // Validar dominio raiz
        if (empty($rootDomain) || !in_array($rootDomain, self::$availableDomains)) {
            $rootDomain = self::$rootDomain;
        }

        // Insertar con root_domain solo si la columna existe (migración update0003.sql aplicada)
        if ($this->_hasRootDomainColumn()) {
            $db->query(
                'INSERT INTO deployments (name, source_path, target_path, subdomain, created_by, mode, root_domain) VALUES (?, ?, ?, ?, ?, ?, ?)',
                $name, $sourcePath, $targetPath, $subdomain, $user, $mode, $rootDomain
            );
        } else {
            $db->query(
                'INSERT INTO deployments (name, source_path, target_path, subdomain, created_by, mode) VALUES (?, ?, ?, ?, ?, ?)',
                $name, $sourcePath, $targetPath, $subdomain, $user, $mode
            );
        }

        $res = $db->query('SELECT last_insert_rowid() as id');
        $row = $db->res2row($res);
        $deploymentId = $row ? $row['id'] : null;

        if ($deploymentId) {
            // Crear los registros de pasos
            foreach (self::$stepNames as $num => $stepName) {
                $db->query(
                    'INSERT INTO deployment_steps (deployment_id, step_number, step_name) VALUES (?, ?, ?)',
                    $deploymentId, $num, $stepName
                );
            }

            // En modo Modificar: auto-completar pasos que no aplican
            if ($mode === 'modify') {
                // Paso 1: No se duplica (ya existe el sistema)
                $this->markStepCompleted($deploymentId, 1, 'No aplica en modo Modificar', 0);
                $this->addLog($deploymentId, 1, 'Paso omitido: en modo Modificar no se realiza duplicacion', 'info');

                // Paso 5: No se limpia BD en modo Modificar
                $this->markStepCompleted($deploymentId, 5, 'No aplica en modo Modificar', 0);
                $this->addLog($deploymentId, 5, 'Paso omitido: en modo Modificar no se limpia la base de datos', 'info');

                // Paso 7: No se limpia data/ en modo Modificar
                $this->markStepCompleted($deploymentId, 7, 'No aplica en modo Modificar', 0);
                $this->addLog($deploymentId, 7, 'Paso omitido: en modo Modificar no se limpia la carpeta data', 'info');

                // Paso 16: No auto-completar — permite reasignar subdominio a otro dominio
                // El usuario puede ejecutarlo manualmente si necesita cambiar el dominio
            }
        }

        return $deploymentId;
    }

    public function getDeployment($id)
    {
        $db = $this->getDB();
        if (!$db) return null;

        $res = $db->query('SELECT * FROM deployments WHERE id = ?', $id);
        return $db->res2row($res);
    }

    public function listDeployments()
    {
        $db = $this->getDB();
        if (!$db) return [];

        $stepsTotal = count(self::$stepNames);
        $res = $db->query(
            'SELECT d.*, ' .
            '(SELECT COUNT(*) FROM deployment_steps WHERE deployment_id = d.id AND status = \'completed\') as steps_done, ' .
            $stepsTotal . ' as steps_total ' .
            'FROM deployments d ORDER BY d.created_at DESC'
        );
        return $db->res2arr($res) ?: [];
    }

    public function deleteDeployment($id)
    {
        $db = $this->getDB();
        if (!$db) return false;

        $db->query('DELETE FROM deployment_log WHERE deployment_id = ?', $id);
        $db->query('DELETE FROM deployment_steps WHERE deployment_id = ?', $id);
        $db->query('DELETE FROM deployments WHERE id = ?', $id);
        return true;
    }

    public function resetDeploymentSteps($deploymentId)
    {
        $db = $this->getDB();
        if (!$db) return;

        // Reiniciar todos los pasos a pendiente
        $db->query(
            'UPDATE deployment_steps SET status = ?, executed_by = NULL, executed_at = NULL, ' .
            'result_message = NULL, duration_seconds = NULL, input_data = NULL ' .
            'WHERE deployment_id = ?',
            'pending', $deploymentId
        );

        // Limpiar todos los logs
        $db->query('DELETE FROM deployment_log WHERE deployment_id = ?', $deploymentId);

        // Reiniciar estado del despliegue
        $db->query(
            'UPDATE deployments SET status = ?, completed_at = NULL WHERE id = ?',
            'in_progress', $deploymentId
        );

        // Si el modo es Modificar, re-completar los pasos que no aplican
        $dep = $this->getDeployment($deploymentId);
        if ($dep && ($dep['mode'] ?? 'new') === 'modify') {
            $this->markStepCompleted($deploymentId, 1, 'No aplica en modo Modificar', 0);
            $this->addLog($deploymentId, 1, 'Paso omitido: en modo Modificar no se realiza duplicacion', 'info');
            $this->markStepCompleted($deploymentId, 5, 'No aplica en modo Modificar', 0);
            $this->addLog($deploymentId, 5, 'Paso omitido: en modo Modificar no se limpia la base de datos', 'info');
            $this->markStepCompleted($deploymentId, 7, 'No aplica en modo Modificar', 0);
            $this->addLog($deploymentId, 7, 'Paso omitido: en modo Modificar no se limpia la carpeta data', 'info');
            // Paso 16: NO auto-completar — permite reasignar subdominio a otro dominio
        }
    }

    public function getTargetPath($deploymentId)
    {
        $dep = $this->getDeployment($deploymentId);
        return $dep ? $dep['target_path'] : null;
    }

    public function getSourcePath($deploymentId)
    {
        $dep = $this->getDeployment($deploymentId);
        return $dep ? $dep['source_path'] : null;
    }

    // =========================================================================
    //  ESTADO DE PASOS
    // =========================================================================

    public function getStepsStatus($deploymentId)
    {
        $db = $this->getDB();
        if (!$db) return [];

        // Verificar y agregar pasos faltantes (por si se anadieron nuevos pasos al plugin)
        $this->ensureAllStepsExist($deploymentId);

        $res = $db->query(
            'SELECT * FROM deployment_steps WHERE deployment_id = ? ORDER BY step_number',
            $deploymentId
        );
        return $db->res2arr($res) ?: [];
    }

    /**
     * Verifica que todos los pasos definidos en $stepNames existan para un despliegue.
     * Si faltan pasos (ej: se agrego paso 16 y el despliegue fue creado con 15), los crea.
     */
    protected function ensureAllStepsExist($deploymentId)
    {
        $db = $this->getDB();
        if (!$db) return;

        $res = $db->query(
            'SELECT step_number FROM deployment_steps WHERE deployment_id = ?',
            $deploymentId
        );
        $existing = [];
        $rows = $db->res2arr($res) ?: [];
        foreach ($rows as $row) {
            $existing[] = intval($row['step_number']);
        }

        $dep = $this->getDeployment($deploymentId);
        $mode = $dep ? ($dep['mode'] ?? 'new') : 'new';

        foreach (self::$stepNames as $num => $stepName) {
            if (!in_array($num, $existing)) {
                $db->query(
                    'INSERT INTO deployment_steps (deployment_id, step_number, step_name) VALUES (?, ?, ?)',
                    $deploymentId, $num, $stepName
                );

                // Si es modo Modificar y es un paso que no aplica, auto-completar
                // Nota: step 16 NO se auto-completa para permitir cambio de dominio
                if ($mode === 'modify' && in_array($num, [1, 5, 7])) {
                    $this->markStepCompleted($deploymentId, $num, 'No aplica en modo Modificar', 0);
                    $this->addLog($deploymentId, $num, 'Paso agregado y omitido automaticamente (modo Modificar)', 'info');
                }
            }
        }
    }

    public function getStepStatus($deploymentId, $stepNumber)
    {
        $db = $this->getDB();
        if (!$db) return null;

        $res = $db->query(
            'SELECT * FROM deployment_steps WHERE deployment_id = ? AND step_number = ?',
            $deploymentId, $stepNumber
        );
        return $db->res2row($res);
    }

    public function markStepRunning($deploymentId, $stepNumber, $user)
    {
        $db = $this->getDB();
        if (!$db) return;

        $db->query(
            'UPDATE deployment_steps SET status = ?, executed_by = ?, executed_at = datetime(\'now\') ' .
            'WHERE deployment_id = ? AND step_number = ?',
            'running', $user, $deploymentId, $stepNumber
        );
    }

    public function markStepCompleted($deploymentId, $stepNumber, $message, $duration)
    {
        $db = $this->getDB();
        if (!$db) return;

        $db->query(
            'UPDATE deployment_steps SET status = ?, result_message = ?, duration_seconds = ? ' .
            'WHERE deployment_id = ? AND step_number = ?',
            'completed', $message, $duration, $deploymentId, $stepNumber
        );

        // Verificar si todos los pasos estan completos
        $res = $db->query(
            'SELECT COUNT(*) as pending FROM deployment_steps WHERE deployment_id = ? AND status != ?',
            $deploymentId, 'completed'
        );
        $row = $db->res2row($res);
        if ($row && intval($row['pending']) === 0) {
            $db->query(
                'UPDATE deployments SET status = ?, completed_at = datetime(\'now\') WHERE id = ?',
                'completed', $deploymentId
            );
        }
    }

    public function markStepFailed($deploymentId, $stepNumber, $message)
    {
        $db = $this->getDB();
        if (!$db) return;

        $db->query(
            'UPDATE deployment_steps SET status = ?, result_message = ? ' .
            'WHERE deployment_id = ? AND step_number = ?',
            'failed', $message, $deploymentId, $stepNumber
        );
    }

    public function saveStepInput($deploymentId, $stepNumber, $inputData)
    {
        $db = $this->getDB();
        if (!$db) return;

        $json = json_encode($inputData, JSON_UNESCAPED_UNICODE);
        $db->query(
            'UPDATE deployment_steps SET input_data = ? WHERE deployment_id = ? AND step_number = ?',
            $json, $deploymentId, $stepNumber
        );
    }

    // =========================================================================
    //  LOGGING
    // =========================================================================

    public function addLog($deploymentId, $stepNumber, $message, $level = 'info')
    {
        $db = $this->getDB();
        if (!$db) return;

        $db->query(
            'INSERT INTO deployment_log (deployment_id, step_number, log_level, message) VALUES (?, ?, ?, ?)',
            $deploymentId, $stepNumber, $level, $message
        );
    }

    public function getStepLogs($deploymentId, $stepNumber)
    {
        $db = $this->getDB();
        if (!$db) return [];

        $res = $db->query(
            'SELECT * FROM deployment_log WHERE deployment_id = ? AND step_number = ? ORDER BY created_at ASC',
            $deploymentId, $stepNumber
        );
        return $db->res2arr($res) ?: [];
    }

    public function clearStepLogs($deploymentId, $stepNumber)
    {
        $db = $this->getDB();
        if (!$db) return;

        $db->query(
            'DELETE FROM deployment_log WHERE deployment_id = ? AND step_number = ?',
            $deploymentId, $stepNumber
        );
    }

    // =========================================================================
    //  VALIDACION
    // =========================================================================

    public function validatePath($path, $basePath = '')
    {
        // Ruta no puede estar vacia
        if (empty($path)) {
            return false;
        }

        // No permitir path traversal
        if (strpos($path, '..') !== false) {
            return false;
        }

        // Debe ser ruta absoluta
        if ($path[0] !== '/') {
            return false;
        }

        // Si hay basePath, verificar que esta dentro
        if ($basePath && strpos($path, $basePath) !== 0) {
            return false;
        }

        return true;
    }

    public function getBasePath()
    {
        $configured = $this->getConf('server_base_path');
        if ($configured && is_dir($configured)) {
            return rtrim($configured, '/') . '/';
        }

        // Auto-detectar desde __FILE__ (siempre ruta absoluta resuelta)
        // helper.php = lib/plugins/siedeployer/helper.php
        // Subir 4 niveles = raiz DokuWiki, 1 mas = directorio padre (donde estan los sistemas)
        $dokuRoot = dirname(dirname(dirname(dirname(__FILE__))));
        $parent = dirname($dokuRoot);
        if (is_dir($parent)) {
            return $parent . '/';
        }

        return '/public_html/';
    }

    public function getDetectedPath()
    {
        // Para mostrar en la UI que ruta se esta usando
        return $this->getBasePath();
    }

    // =========================================================================
    //  GENERACION DE TOKEN IA (API)
    // =========================================================================

    public function generateToken($projectName)
    {
        if (!function_exists('curl_init')) {
            return ['error' => 'Extension curl no disponible', 'token' => null];
        }

        $apiUrl = $this->getConf('token_api_url');
        $apiKey = $this->getConf('token_api_key');

        if (empty($apiUrl) || empty($apiKey)) {
            return ['error' => 'API URL o API Key no configurados en siedeployer', 'token' => null];
        }

        $ch = curl_init($apiUrl);
        curl_setopt($ch, CURLOPT_POST, true);
        curl_setopt($ch, CURLOPT_POSTFIELDS, ['proyecto' => $projectName, 'api_key' => $apiKey]);
        curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
        curl_setopt($ch, CURLOPT_TIMEOUT, 30);
        curl_setopt($ch, CURLOPT_CONNECTTIMEOUT, 10);

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

        if ($curlError) {
            return ['error' => 'CURL: ' . $curlError, 'token' => null];
        }

        if ($httpCode !== 200) {
            return ['error' => 'HTTP ' . $httpCode . ': ' . substr($response, 0, 200), 'token' => null];
        }

        $data = json_decode($response, true);
        if (!$data) {
            return ['error' => 'Respuesta invalida: ' . substr($response, 0, 200), 'token' => null];
        }

        $token = $data['token'] ?? ($data['access_token'] ?? null);
        if (!$token) {
            return ['error' => 'Sin token en respuesta', 'token' => null];
        }

        return ['error' => null, 'token' => $token];
    }

    // =========================================================================
    //  PASO 1: ESCANEAR Y DUPLICAR SISTEMA
    // =========================================================================

    public function scanServerSystems()
    {
        $basePath = $this->getBasePath();
        $systems = [];

        if (!is_dir($basePath)) {
            // Retornar info de debug
            return ['_error' => 'Ruta no existe: ' . $basePath, '_doku_inc' => defined('DOKU_INC') ? DOKU_INC : 'no definido'];
        }

        $dirs = @scandir($basePath);
        if (!$dirs) return $systems;

        foreach ($dirs as $dir) {
            if ($dir === '.' || $dir === '..') continue;

            $fullPath = rtrim($basePath, '/') . '/' . $dir;

            if (!is_dir($fullPath)) continue;

            // Detectar DokuWiki: conf/local.php o conf/dokuwiki.php + data/
            // (doku.php puede no existir si el subdominio ya fue limpiado)
            $hasConf = file_exists($fullPath . '/conf/local.php')
                    || file_exists($fullPath . '/conf/dokuwiki.php');
            $hasData = is_dir($fullPath . '/data');
            if ($hasConf && $hasData) {
                $title = '';
                $localPhp = $fullPath . '/conf/local.php';
                if (file_exists($localPhp)) {
                    $content = @file_get_contents($localPhp);
                    if ($content && preg_match('/\$conf\s*\[\s*[\'"]title[\'"]\s*\]\s*=\s*\'([^\']*)\'/s', $content, $m)) {
                        $title = $m[1];
                    }
                }

                $systems[] = [
                    'path'  => $fullPath,
                    'name'  => $dir,
                    'title' => $title,
                ];
            }
        }

        usort($systems, function($a, $b) {
            return strcasecmp($a['name'], $b['name']);
        });

        return $systems;
    }

    public function executeStep1($deploymentId)
    {
        $dep = $this->getDeployment($deploymentId);
        if (!$dep) return 'Despliegue no encontrado';

        $source = rtrim($dep['source_path'], '/');
        $target = rtrim($dep['target_path'], '/');

        if (!is_dir($source)) {
            return 'Ruta origen no existe: ' . $source;
        }

        if (is_dir($target)) {
            return 'Ruta destino ya existe: ' . $target;
        }

        // Intentar con shell primero
        $this->addLog($deploymentId, 1, 'Intentando copia con shell (cp -a)...', 'info');

        $shellSuccess = false;
        if ($this->_execAvailable()) {
            $escapedSource = escapeshellarg($source);
            $escapedTarget = escapeshellarg($target);
            $output = [];
            $returnVar = -1;

            @exec("cp -a $escapedSource $escapedTarget 2>&1", $output, $returnVar);

            if ($returnVar === 0 && is_dir($target)) {
                $shellSuccess = true;
                $this->addLog($deploymentId, 1, 'Copia con shell completada exitosamente', 'success');
            } else {
                $this->addLog($deploymentId, 1, 'Shell fallo (code=' . $returnVar . '): ' . implode(' ', $output), 'warning');
            }
        } else {
            $this->addLog($deploymentId, 1, 'exec() no disponible, usando PHP recursivo', 'warning');
        }

        // Fallback a PHP recursivo
        if (!$shellSuccess) {
            $this->addLog($deploymentId, 1, 'Iniciando copia recursiva con PHP...', 'info');

            if (!@mkdir($target, 0755, true)) {
                return 'No se pudo crear directorio destino: ' . $target;
            }

            $result = $this->copyDirectoryRecursive($source, $target, $deploymentId);
            if ($result !== true) {
                return 'Error en copia recursiva: ' . $result;
            }

            $this->addLog($deploymentId, 1, 'Copia recursiva PHP completada', 'success');
        }

        return true;
    }

    protected function copyDirectoryRecursive($source, $target, $deploymentId)
    {
        if (!is_dir($target)) {
            @mkdir($target, 0755, true);
        }

        $items = @scandir($source);
        if (!$items) return 'No se pudo leer directorio: ' . $source;

        $count = 0;
        foreach ($items as $item) {
            if ($item === '.' || $item === '..') continue;

            $srcPath = $source . '/' . $item;
            $dstPath = $target . '/' . $item;

            if (is_dir($srcPath)) {
                $result = $this->copyDirectoryRecursive($srcPath, $dstPath, $deploymentId);
                if ($result !== true) return $result;
            } else {
                if (!@copy($srcPath, $dstPath)) {
                    $this->addLog($deploymentId, 1, 'Error copiando: ' . $item, 'warning');
                }
            }

            $count++;
            if ($count % 500 === 0) {
                $this->addLog($deploymentId, 1, $count . ' archivos procesados...', 'info');
            }
        }

        return true;
    }

    // =========================================================================
    //  PASO 2: TITULO Y URL
    // =========================================================================

    public function executeStep2($deploymentId, $title, $baseurl, $template = '20cones')
    {
        $targetPath = $this->getTargetPath($deploymentId);
        if (!$targetPath) return 'Despliegue no encontrado';

        // BUG-FIX: Sanitizar inputs para evitar inyeccion PHP en local.php
        $title = trim($title);
        $baseurl = trim($baseurl);
        if (preg_match("/[\\x00-\\x1f]/", $title)) {
            return 'El titulo contiene caracteres de control no permitidos';
        }
        if (preg_match("/[\\x00-\\x1f]/", $baseurl) || preg_match("/['\\\\]/", $baseurl)) {
            return 'La URL base contiene caracteres no permitidos';
        }

        $localPhp = $targetPath . '/conf/local.php';
        if (!file_exists($localPhp)) {
            return 'No se encontro conf/local.php en: ' . $targetPath;
        }

        $content = file_get_contents($localPhp);
        if ($content === false) return 'No se pudo leer conf/local.php';

        // Backup
        @copy($localPhp, $localPhp . '.bak.' . date('YmdHis'));

        // Reemplazar titulo — escapar comillas simples y backslashes
        $titleEscaped = str_replace(['\\', "'"], ['\\\\', "\\'"], $title);
        // BUG3-FIX: Si la clave no existe en local.php, preg_replace no hace nada.
        // Agregamos fallback insert igual al patron de executeStep4.
        if (preg_match('/\$conf\s*\[\s*[\'"]title[\'"]\s*\]/', $content)) {
            $content = preg_replace(
                '/(\$conf\s*\[\s*[\'"]title[\'"]\s*\]\s*=\s*\')[^\']*(\')/s',
                '${1}' . $titleEscaped . '${2}',
                $content
            );
        } else {
            $content = rtrim($content) . "\n\$conf['title'] = '" . $titleEscaped . "';\n";
        }

        // Reemplazar baseurl
        $baseurlClean = rtrim($baseurl, '/') . '/';
        // BUG3-FIX: Mismo fallback para baseurl.
        if (preg_match('/\$conf\s*\[\s*[\'"]baseurl[\'"]\s*\]/', $content)) {
            $content = preg_replace(
                '/(\$conf\s*\[\s*[\'"]baseurl[\'"]\s*\]\s*=\s*\')[^\']*(\')/s',
                '${1}' . $baseurlClean . '${2}',
                $content
            );
        } else {
            $content = rtrim($content) . "\n\$conf['baseurl'] = '" . $baseurlClean . "';\n";
        }

        // Reemplazar template (20cones o 21cones)
        $allowedTemplates = ['20cones', '21cones'];
        if (!in_array($template, $allowedTemplates, true)) {
            $template = '20cones';
        }
        if (preg_match('/\$conf\s*\[\s*[\'"]template[\'"]\s*\]/', $content)) {
            $content = preg_replace(
                '/(\$conf\s*\[\s*[\'"]template[\'"]\s*\]\s*=\s*\')[^\']*(\')/s',
                '${1}' . $template . '${2}',
                $content
            );
        } else {
            $content = rtrim($content) . "\n\$conf['template'] = '" . $template . "';\n";
        }

        if (file_put_contents($localPhp, $content) === false) {
            return 'No se pudo escribir conf/local.php';
        }

        return true;
    }

    // =========================================================================
    //  PASO 3: CORREOS ELECTRONICOS
    // =========================================================================

    public function executeStep3($deploymentId, $email)
    {
        $targetPath = $this->getTargetPath($deploymentId);
        if (!$targetPath) return 'Despliegue no encontrado';

        // BUG-FIX: Validar email para evitar inyeccion de codigo PHP en local.php
        $email = trim($email);
        if (!filter_var($email, FILTER_VALIDATE_EMAIL)) {
            return 'Correo electronico invalido: ' . htmlspecialchars($email);
        }
        // Seguridad adicional: no permitir comillas simples ni caracteres de control
        if (preg_match("/['\\\\\\x00-\\x1f]/", $email)) {
            return 'El correo contiene caracteres no permitidos';
        }

        $localPhp = $targetPath . '/conf/local.php';
        if (!file_exists($localPhp)) return 'No se encontro conf/local.php';

        $content = file_get_contents($localPhp);
        if ($content === false) return 'No se pudo leer conf/local.php';

        @copy($localPhp, $localPhp . '.bak.' . date('YmdHis'));

        $keys = ['notify', 'registernotify', 'mailfrom', 'mailreturnpath'];

        foreach ($keys as $key) {
            if (preg_match('/\$conf\s*\[\s*[\'"]' . preg_quote($key, '/') . '[\'"]\s*\]/', $content)) {
                $content = preg_replace(
                    '/(\$conf\s*\[\s*[\'"]' . preg_quote($key, '/') . '[\'"]\s*\]\s*=\s*\')[^\']*(\')/s',
                    '${1}' . $email . '${2}',
                    $content
                );
            } else {
                $content = rtrim($content) . "\n\$conf['" . $key . "'] = '" . $email . "';\n";
            }
        }

        if (file_put_contents($localPhp, $content) === false) {
            return 'No se pudo escribir conf/local.php';
        }

        return true;
    }

    // =========================================================================
    //  PASO 4: TOKEN IA
    // =========================================================================

    public function executeStep4($deploymentId, $token)
    {
        $targetPath = $this->getTargetPath($deploymentId);
        if (!$targetPath) return 'Despliegue no encontrado';

        // BUG-FIX: Sanitizar token para evitar inyeccion de codigo PHP en local.php
        $token = trim($token);
        if (preg_match("/['\\\\\\x00-\\x1f]/", $token)) {
            return 'El token contiene caracteres no permitidos';
        }

        $localPhp = $targetPath . '/conf/local.php';
        if (!file_exists($localPhp)) return 'No se encontro conf/local.php';

        $content = file_get_contents($localPhp);
        if ($content === false) return 'No se pudo leer conf/local.php';

        @copy($localPhp, $localPhp . '.bak.' . date('YmdHis'));

        // Patron del generar_tokens_masivo.php
        if (preg_match('/\$conf\s*\[\s*[\'"]token_ia[\'"]\s*\]/', $content)) {
            $content = preg_replace(
                '/(\$conf\s*\[\s*[\'"]token_ia[\'"]\s*\]\s*=\s*\')[^\']*(\')/s',
                '${1}' . $token . '${2}',
                $content
            );
        } else {
            // Si no existe, agregar al final
            $content = rtrim($content) . "\n\$conf['token_ia'] = '" . $token . "';\n";
        }

        if (file_put_contents($localPhp, $content) === false) {
            return 'No se pudo escribir conf/local.php';
        }

        return true;
    }

    // =========================================================================
    //  PASO 5: LIMPIAR BASE DE DATOS
    // =========================================================================

    public function executeStep5($deploymentId)
    {
        $targetPath = $this->getTargetPath($deploymentId);
        if (!$targetPath) return 'Despliegue no encontrado';

        $dbFile = $targetPath . '/data/meta/b3p.sqlite3';
        if (!file_exists($dbFile)) {
            return 'No se encontro base de datos: ' . $dbFile;
        }

        try {
            $pdo = new PDO('sqlite:' . $dbFile);
            $pdo->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION);

            // Obtener lista de tablas existentes
            $tables = [];
            $stmt = $pdo->query("SELECT name FROM sqlite_master WHERE type='table'");
            while ($row = $stmt->fetch(PDO::FETCH_ASSOC)) {
                $tables[] = $row['name'];
            }

            // FASE 1: Tablas hoja
            $this->addLog($deploymentId, 5, 'Fase 1: Limpiando tablas hoja...', 'info');
            $fase1 = ['task_comment', 'task_participant', 'task_filename', 'bulk_task_log',
                       'thread_comment', 'thread_participant', 'thread_label',
                       'authentication_token', 'subscription'];
            foreach ($fase1 as $t) {
                if (in_array($t, $tables)) {
                    $pdo->exec("DELETE FROM $t");
                    $this->addLog($deploymentId, 5, "  Limpiada: $t", 'info');
                }
            }

            // FASE 2: Tablas padre
            $this->addLog($deploymentId, 5, 'Fase 2: Limpiando tablas padre...', 'info');
            $fase2 = ['task', 'thread', 'progreso_proyecto'];
            foreach ($fase2 as $t) {
                if (in_array($t, $tables)) {
                    $pdo->exec("DELETE FROM $t");
                    $this->addLog($deploymentId, 5, "  Limpiada: $t", 'info');
                }
            }

            // FASE 3: Admin threads
            $this->addLog($deploymentId, 5, 'Fase 3: Limpiando admin threads...', 'info');
            $fase3 = ['admin_thread_preferencia', 'admin_thread_progress', 'admin_thread'];
            foreach ($fase3 as $t) {
                if (in_array($t, $tables)) {
                    $pdo->exec("DELETE FROM $t");
                    $this->addLog($deploymentId, 5, "  Limpiada: $t", 'info');
                }
            }

            // FASE 4: Admin control
            $this->addLog($deploymentId, 5, 'Fase 4: Limpiando admin control...', 'info');
            $fase4 = ['admin_control_progress', 'admin_control_export_log',
                       'admin_control_items', 'admin_control_aplicadores', 'admin_control_groups'];
            foreach ($fase4 as $t) {
                if (in_array($t, $tables)) {
                    $pdo->exec("DELETE FROM $t");
                    $this->addLog($deploymentId, 5, "  Limpiada: $t", 'info');
                }
            }

            // FASE 5: Calendario tributario
            $this->addLog($deploymentId, 5, 'Fase 5: Limpiando calendario tributario...', 'info');
            $fase5 = ['tax_calendar_files', 'tax_calendar_master', 'tax_calendar_status',
                       'tax_calendar_upload_log', 'tax_task_log'];
            foreach ($fase5 as $t) {
                if (in_array($t, $tables)) {
                    $pdo->exec("DELETE FROM $t");
                    $this->addLog($deploymentId, 5, "  Limpiada: $t", 'info');
                }
            }

            // FASE 6: Obligaciones de clientes
            $this->addLog($deploymentId, 5, 'Fase 6: Limpiando obligaciones...', 'info');
            $fase6 = ['client_obligations', 'client_custom_obligations', 'client_deadline_exceptions'];
            foreach ($fase6 as $t) {
                if (in_array($t, $tables)) {
                    $pdo->exec("DELETE FROM $t");
                    $this->addLog($deploymentId, 5, "  Limpiada: $t", 'info');
                }
            }

            // FASE 7: Reset secuencias
            $this->addLog($deploymentId, 5, 'Fase 7: Reseteando secuencias...', 'info');
            if (in_array('label', $tables)) {
                $pdo->exec('UPDATE label SET count = 0');
            }
            if (in_array('task_program', $tables)) {
                $pdo->exec('UPDATE task_program SET count = 0');
            }
            if (in_array('thread_sequence', $tables)) {
                $pdo->exec('UPDATE thread_sequence SET last_id = 0 WHERE id = 1');
            }
            if (in_array('admin_thread_sequence', $tables)) {
                $pdo->exec('UPDATE admin_thread_sequence SET last_id = 0 WHERE id = 1');
            }
            // BUG2-FIX: sqlite_sequence solo existe si alguna tabla usa AUTOINCREMENT.
            // Sin esta verificacion, lanza PDOException y falla todo el Paso 5.
            if (in_array('sqlite_sequence', $tables)) {
                $pdo->exec('DELETE FROM sqlite_sequence');
            }
            $this->addLog($deploymentId, 5, 'Secuencias reseteadas', 'info');

            $pdo = null; // cerrar conexion

            // FASE 8: Eliminar bases de datos auxiliares (se recrean al primer uso)
            $this->addLog($deploymentId, 5, 'Fase 8: Eliminando bases de datos auxiliares...', 'info');
            $metaDir = $targetPath . '/data/meta';
            $auxDbs = ['do.sqlite3', 'docmaster.sqlite3', 'struct.sqlite3'];
            foreach ($auxDbs as $dbName) {
                $dbPath = $metaDir . '/' . $dbName;
                if (file_exists($dbPath)) {
                    if (@unlink($dbPath)) {
                        $this->addLog($deploymentId, 5, "  Eliminada: data/meta/$dbName", 'success');
                    } else {
                        $this->addLog($deploymentId, 5, "  Error eliminando: data/meta/$dbName", 'warning');
                    }
                } else {
                    $this->addLog($deploymentId, 5, "  No existe: data/meta/$dbName (omitida)", 'info');
                }
            }

            return true;

        } catch (Exception $e) {
            return 'Error limpiando BD: ' . $e->getMessage();
        }
    }

    // =========================================================================
    //  PASO 6: LOGOS SMILEYS
    // =========================================================================

    public function executeStep6($deploymentId, $logoPng, $logoGif)
    {
        $targetPath = $this->getTargetPath($deploymentId);
        if (!$targetPath) return 'Despliegue no encontrado';

        $smileysDir = $targetPath . '/lib/images/smileys/';
        if (!is_dir($smileysDir)) {
            @mkdir($smileysDir, 0755, true);
        }

        $errors = [];

        if ($logoPng && $logoPng['error'] === UPLOAD_ERR_OK) {
            if (!move_uploaded_file($logoPng['tmp_name'], $smileysDir . 'logo.png')) {
                $errors[] = 'Error copiando logo.png';
            }
        }

        if ($logoGif && $logoGif['error'] === UPLOAD_ERR_OK) {
            if (!move_uploaded_file($logoGif['tmp_name'], $smileysDir . 'logo.gif')) {
                $errors[] = 'Error copiando logo.gif';
            }
        }

        if (!empty($errors)) {
            return implode('; ', $errors);
        }

        return true;
    }

    // =========================================================================
    //  PASO 7: LIMPIAR CARPETA DATA
    // =========================================================================

    public function executeStep7($deploymentId)
    {
        $targetPath = $this->getTargetPath($deploymentId);
        if (!$targetPath) return 'Despliegue no encontrado';

        $dataDir = $targetPath . '/data';
        if (!is_dir($dataDir)) {
            return 'No se encontro carpeta data/ en: ' . $targetPath;
        }

        // Carpetas a limpiar completamente
        $cleanDirs = ['pages', 'media', 'attic', 'media_attic', 'media_meta',
                       'cache', 'index', 'locks', 'log', 'tmp'];

        foreach ($cleanDirs as $dir) {
            $fullDir = $dataDir . '/' . $dir;
            if (is_dir($fullDir)) {
                $this->deleteDirectoryContents($fullDir);
                $this->addLog($deploymentId, 7, 'Limpiado: data/' . $dir, 'info');
            }
        }

        // Meta: limpiar todo excepto b3p.sqlite3 (ya limpiada por Paso 5)
        $metaDir = $dataDir . '/meta';
        if (is_dir($metaDir)) {
            $this->cleanDirectoryKeepFiles($metaDir, ['b3p.sqlite3']);
            $this->addLog($deploymentId, 7, 'Limpiado: data/meta (conservando b3p.sqlite3)', 'info');
        }

        return true;
    }

    // =========================================================================
    //  PASO 8: IMAGENES MEDIA WIKI + FAVICONS (incluye pasos 11 y 13)
    // =========================================================================

    public function executeStep8($deploymentId, $dokuwikiPng, $faviconIco)
    {
        $targetPath = $this->getTargetPath($deploymentId);
        if (!$targetPath) return 'Despliegue no encontrado';

        $wikiDir = $targetPath . '/data/media/wiki/';
        if (!is_dir($wikiDir)) {
            @mkdir($wikiDir, 0755, true);
        }

        $errors = [];

        if ($dokuwikiPng && $dokuwikiPng['error'] === UPLOAD_ERR_OK) {
            if (!move_uploaded_file($dokuwikiPng['tmp_name'], $wikiDir . 'dokuwiki-128.png')) {
                $errors[] = 'Error copiando dokuwiki-128.png a media/wiki/';
            } else {
                $this->addLog($deploymentId, 8, 'Copiado: dokuwiki-128.png a data/media/wiki/', 'success');
            }
        }

        if ($faviconIco && $faviconIco['error'] === UPLOAD_ERR_OK) {
            // Guardar favicon en ubicacion temporal para copiar a multiples destinos
            $tmpFavicon = $faviconIco['tmp_name'];
            $faviconSaved = $wikiDir . 'favicon.ico';

            if (!move_uploaded_file($tmpFavicon, $faviconSaved)) {
                $errors[] = 'Error copiando favicon.ico a media/wiki/';
            } else {
                $this->addLog($deploymentId, 8, 'Copiado: favicon.ico a data/media/wiki/', 'success');

                // Copiar favicon a 4 ubicaciones adicionales
                $faviconDests = [
                    $targetPath . '/lib/tpl/20cones/favicon.ico'  => 'lib/tpl/20cones/',
                    $targetPath . '/lib/tpl/21cones/favicon.ico'  => 'lib/tpl/21cones/',
                    $targetPath . '/favicon.ico'                   => 'raiz del sistema',
                ];

                foreach ($faviconDests as $dest => $label) {
                    $destDir = dirname($dest);
                    if (!is_dir($destDir)) {
                        @mkdir($destDir, 0755, true);
                    }
                    if (@copy($faviconSaved, $dest)) {
                        $this->addLog($deploymentId, 8, 'Copiado: favicon.ico a ' . $label, 'success');
                    } else {
                        $errors[] = 'Error copiando favicon.ico a ' . $label;
                        $this->addLog($deploymentId, 8, 'Error copiando favicon.ico a ' . $label, 'warning');
                    }
                }
            }
        }

        return empty($errors) ? true : implode('; ', $errors);
    }

    // =========================================================================
    //  PASO 9: CONFIGURACION ACL
    // =========================================================================

    public function executeStep9($deploymentId, $aclContent)
    {
        $targetPath = $this->getTargetPath($deploymentId);
        if (!$targetPath) return 'Despliegue no encontrado';

        $aclFile = $targetPath . '/conf/acl.auth.php';

        // Backup si existe
        if (file_exists($aclFile)) {
            @copy($aclFile, $aclFile . '.bak.' . date('YmdHis'));
        }

        if (file_put_contents($aclFile, $aclContent) === false) {
            return 'No se pudo escribir acl.auth.php';
        }

        return true;
    }

    public function getDefaultACL()
    {
        return "# acl.auth.php\n" .
               "# <?php exit()?>\n" .
               "*\t\t@ALL\t\t0\n" .
               "*\t\t@sie\t\t16\n" .
               "*\t\t@lectura\t\t8\n" .
               "*\t\t@editartareas\t\t2\n" .
               "@lidertareas\t\t:task\t\tcoordinator\n";
    }

    // =========================================================================
    //  PASO 10: CONFIGURACION USUARIOS
    // =========================================================================

    public function executeStep10($deploymentId, $usersContent)
    {
        $targetPath = $this->getTargetPath($deploymentId);
        if (!$targetPath) return 'Despliegue no encontrado';

        $usersFile = $targetPath . '/conf/users.auth.php';

        if (file_exists($usersFile)) {
            @copy($usersFile, $usersFile . '.bak.' . date('YmdHis'));
        }

        if (file_put_contents($usersFile, $usersContent) === false) {
            return 'No se pudo escribir users.auth.php';
        }

        return true;
    }

    public function getDefaultUsers()
    {
        return "# users.auth.php\n" .
               "# <?php exit()?>\n" .
               "# Don't modify the lines above\n" .
               "#\n" .
               "# Userfile\n" .
               "#\n" .
               "# Auto-generated by install script\n" .
               "# Date: " . gmdate('D, d M Y H:i:s') . " +0000\n" .
               "#\n" .
               "# Format:\n" .
               "# login:passwordhash:Real Name:email:groups,comma,separated\n" .
               'sie2:$1$6pQkBUuo$cin5JXNHqiTGjckGEKlic0:Administrador:proyectos@sieasesorias.com:admin,manager,sie,editarproyecto' . "\n";
    }

    // =========================================================================
    //  PASOS 11-14: FAVICONS E IMAGENES DE TEMPLATES
    // =========================================================================

    public function executeStep11($deploymentId, $faviconIco)
    {
        return $this->copyUploadedFile($deploymentId, 11, $faviconIco, '/lib/tpl/20cones/', 'favicon.ico');
    }

    public function executeStep12($deploymentId, $files)
    {
        return $this->copyMultipleUploads($deploymentId, 12, $files, '/lib/tpl/20cones/images/',
            ['icon.png', 'logo_sie_asesorias.png', 'page-gradient.png',
             'header-gradient.png', 'favicon.ico', 'apple-touch-icon.png', 'dokuwiki-128.png']
        );
    }

    public function executeStep13($deploymentId, $faviconIco)
    {
        return $this->copyUploadedFile($deploymentId, 13, $faviconIco, '/lib/tpl/21cones/', 'favicon.ico');
    }

    public function executeStep14($deploymentId, $files)
    {
        return $this->copyMultipleUploads($deploymentId, 14, $files, '/lib/tpl/21cones/images/',
            ['icon.png', 'logo_sie_asesorias.png', 'page-gradient.png',
             'favicon.ico', 'apple-touch-icon.png', 'dokuwiki-128.png']
        );
    }

    protected function copyUploadedFile($deploymentId, $step, $file, $relDir, $filename)
    {
        $targetPath = $this->getTargetPath($deploymentId);
        if (!$targetPath) return 'Despliegue no encontrado';

        $destDir = $targetPath . $relDir;
        if (!is_dir($destDir)) {
            @mkdir($destDir, 0755, true);
        }

        if (!$file || $file['error'] !== UPLOAD_ERR_OK) {
            return 'No se recibio archivo: ' . $filename;
        }

        // Si es un ZIP, extraer el archivo esperado de adentro
        $uploadedName = basename($file['name']);
        $ext = strtolower(pathinfo($uploadedName, PATHINFO_EXTENSION));

        if ($ext === 'zip') {
            $zipResult = $this->_extractZipImages($deploymentId, $step, $file['tmp_name'], $destDir, [$filename]);
            if ($zipResult['copied'] > 0) {
                return true;
            }
            return 'El ZIP no contiene ' . $filename;
        }

        if (!move_uploaded_file($file['tmp_name'], $destDir . $filename)) {
            return 'Error copiando ' . $filename;
        }

        $this->addLog($deploymentId, $step, 'Copiado: ' . $filename, 'success');
        return true;
    }

    protected function copyMultipleUploads($deploymentId, $step, $files, $relDir, $expectedFiles)
    {
        $targetPath = $this->getTargetPath($deploymentId);
        if (!$targetPath) return 'Despliegue no encontrado';

        $destDir = $targetPath . $relDir;
        if (!is_dir($destDir)) {
            @mkdir($destDir, 0755, true);
        }

        $errors = [];
        $copied = 0;

        foreach ($files as $key => $file) {
            if ($file['error'] !== UPLOAD_ERR_OK) continue;

            $basename = basename($file['name']);
            $ext = strtolower(pathinfo($basename, PATHINFO_EXTENSION));

            // Si es un ZIP, descomprimir y extraer imagenes esperadas
            if ($ext === 'zip') {
                $zipResult = $this->_extractZipImages($deploymentId, $step, $file['tmp_name'], $destDir, $expectedFiles);
                $copied += $zipResult['copied'];
                $errors = array_merge($errors, $zipResult['errors']);
                continue;
            }

            // Archivo individual (imagen)
            if (!in_array($basename, $expectedFiles)) {
                $this->addLog($deploymentId, $step, 'Archivo ignorado (no esperado): ' . $basename, 'warning');
                continue;
            }

            if (move_uploaded_file($file['tmp_name'], $destDir . $basename)) {
                $this->addLog($deploymentId, $step, 'Copiado: ' . $basename, 'success');
                $copied++;
            } else {
                $errors[] = 'Error copiando: ' . $basename;
            }
        }

        if (!empty($errors)) {
            return implode('; ', $errors);
        }

        if ($copied === 0) {
            return 'No se recibieron archivos para copiar';
        }

        return true;
    }

    /**
     * Extrae imagenes esperadas de un archivo ZIP al directorio destino.
     * Busca archivos por nombre en cualquier nivel del ZIP (ignora carpetas internas).
     */
    protected function _extractZipImages($deploymentId, $step, $zipPath, $destDir, $expectedFiles)
    {
        $result = ['copied' => 0, 'errors' => []];

        if (!class_exists('ZipArchive')) {
            $result['errors'][] = 'Extension ZipArchive no disponible en PHP';
            $this->addLog($deploymentId, $step, 'Error: ZipArchive no disponible', 'error');
            return $result;
        }

        $zip = new \ZipArchive();
        if ($zip->open($zipPath) !== true) {
            $result['errors'][] = 'No se pudo abrir el archivo ZIP';
            $this->addLog($deploymentId, $step, 'Error: No se pudo abrir ZIP', 'error');
            return $result;
        }

        $this->addLog($deploymentId, $step, 'ZIP abierto: ' . $zip->numFiles . ' archivos encontrados', 'info');

        $extracted = 0;
        for ($i = 0; $i < $zip->numFiles; $i++) {
            $entryName = $zip->getNameIndex($i);
            // Obtener solo el nombre del archivo (sin carpetas internas del ZIP)
            $entryBasename = basename($entryName);

            // Saltar directorios
            if (empty($entryBasename) || substr($entryName, -1) === '/') continue;

            // Solo extraer archivos esperados
            if (!in_array($entryBasename, $expectedFiles)) {
                continue;
            }

            $content = $zip->getFromIndex($i);
            if ($content === false) {
                $result['errors'][] = 'Error leyendo del ZIP: ' . $entryBasename;
                continue;
            }

            if (file_put_contents($destDir . $entryBasename, $content) !== false) {
                $this->addLog($deploymentId, $step, 'Extraido de ZIP: ' . $entryBasename, 'success');
                $extracted++;
            } else {
                $result['errors'][] = 'Error escribiendo: ' . $entryBasename;
            }
        }

        $zip->close();

        $result['copied'] = $extracted;
        if ($extracted > 0) {
            $this->addLog($deploymentId, $step, 'ZIP procesado: ' . $extracted . ' imagenes extraidas', 'success');
        } else {
            $this->addLog($deploymentId, $step, 'ZIP no contenia imagenes esperadas', 'warning');
        }

        return $result;
    }

    // =========================================================================
    //  PASO 15: DOCUMENTACION ZIP
    // =========================================================================

    public function executeStep15($deploymentId, $zipFiles, $mode = 'individual')
    {
        $targetPath = $this->getTargetPath($deploymentId);
        if (!$targetPath) return 'Despliegue no encontrado';

        $folders = ['administrativa', 'comercial', 'compartidos', 'operativo', 'rrhh'];

        if ($mode === 'unified') {
            return $this->executeStep15Unified($deploymentId, $zipFiles, $targetPath, $folders);
        }

        return $this->executeStep15Individual($deploymentId, $zipFiles, $targetPath, $folders);
    }

    protected function executeStep15Individual($deploymentId, $zipFiles, $targetPath, $folders)
    {
        $errors = [];
        $extracted = 0;

        foreach ($folders as $folder) {
            $key = 'zip_' . $folder;
            if (!isset($zipFiles[$key]) || $zipFiles[$key]['error'] !== UPLOAD_ERR_OK) {
                continue;
            }

            $zipPath = $zipFiles[$key]['tmp_name'];
            $destDir = $targetPath . '/data/media/' . $folder . '/';

            if (!is_dir($destDir)) {
                @mkdir($destDir, 0755, true);
            }

            $zip = new ZipArchive();
            if ($zip->open($zipPath) === true) {
                $zip->extractTo($destDir);
                $zip->close();
                $this->addLog($deploymentId, 15, "ZIP extraido en: $folder (" . $zipFiles[$key]['name'] . ")", 'success');
                $extracted++;
            } else {
                $errors[] = "Error abriendo ZIP para: $folder";
                $this->addLog($deploymentId, 15, "Error abriendo ZIP para: $folder", 'error');
            }
        }

        if (!empty($errors)) {
            return implode('; ', $errors);
        }

        if ($extracted === 0) {
            return 'No se recibieron archivos ZIP';
        }

        return true;
    }

    protected function executeStep15Unified($deploymentId, $zipFiles, $targetPath, $folders)
    {
        if (!isset($zipFiles['zip_all']) || $zipFiles['zip_all']['error'] !== UPLOAD_ERR_OK) {
            return 'No se recibio el archivo ZIP unificado';
        }

        $zipPath = $zipFiles['zip_all']['tmp_name'];
        $zipName = $zipFiles['zip_all']['name'];

        $zip = new ZipArchive();
        if ($zip->open($zipPath) !== true) {
            return 'Error abriendo ZIP: ' . $zipName;
        }

        // Extraer a directorio temporal
        $tmpDir = sys_get_temp_dir() . '/siedeployer_step15_' . uniqid();
        @mkdir($tmpDir, 0755, true);
        $numFiles = $zip->numFiles;
        $zip->extractTo($tmpDir);
        $zip->close();

        $this->addLog($deploymentId, 15, "ZIP extraido temporalmente: $zipName ($numFiles archivos)", 'info');

        // Buscar las carpetas esperadas: pueden estar en la raiz o dentro de una carpeta contenedora
        $foundFolders = 0;
        $errors = [];

        // Primero: intentar encontrar las carpetas directamente en la raiz del ZIP
        $baseDir = $tmpDir;
        $hasDirectFolders = false;
        foreach ($folders as $folder) {
            if (is_dir($tmpDir . '/' . $folder)) {
                $hasDirectFolders = true;
                break;
            }
        }

        // Si no estan en la raiz, buscar dentro de una carpeta contenedora (un nivel)
        if (!$hasDirectFolders) {
            $items = @scandir($tmpDir);
            if ($items) {
                foreach ($items as $item) {
                    if ($item === '.' || $item === '..') continue;
                    if (is_dir($tmpDir . '/' . $item)) {
                        // Verificar si esta carpeta contiene alguna de las esperadas
                        foreach ($folders as $folder) {
                            if (is_dir($tmpDir . '/' . $item . '/' . $folder)) {
                                $baseDir = $tmpDir . '/' . $item;
                                $this->addLog($deploymentId, 15, "Carpetas encontradas dentro de: $item/", 'info');
                                break 2;
                            }
                        }
                    }
                }
            }
        }

        // Copiar cada carpeta encontrada a su destino
        foreach ($folders as $folder) {
            $sourceDir = $baseDir . '/' . $folder;
            if (!is_dir($sourceDir)) {
                $this->addLog($deploymentId, 15, "Carpeta no encontrada en ZIP: $folder/ (omitida)", 'warning');
                continue;
            }

            $destDir = $targetPath . '/data/media/' . $folder . '/';
            if (!is_dir($destDir)) {
                @mkdir($destDir, 0755, true);
            }

            // Copiar contenido recursivamente
            $result = $this->copyDirectoryRecursiveSimple($sourceDir, $destDir);
            if ($result === true) {
                $this->addLog($deploymentId, 15, "Copiado: $folder/ a data/media/$folder/", 'success');
                $foundFolders++;
            } else {
                $errors[] = "Error copiando $folder: $result";
                $this->addLog($deploymentId, 15, "Error copiando $folder: $result", 'error');
            }
        }

        // Limpiar directorio temporal
        $this->deleteDirectoryRecursive($tmpDir);

        if (!empty($errors)) {
            return implode('; ', $errors);
        }

        if ($foundFolders === 0) {
            return 'No se encontraron carpetas de documentacion dentro del ZIP. Esperadas: ' . implode(', ', $folders);
        }

        $this->addLog($deploymentId, 15, "$foundFolders de " . count($folders) . " carpetas procesadas", 'success');
        return true;
    }

    protected function copyDirectoryRecursiveSimple($source, $target)
    {
        if (!is_dir($target)) {
            @mkdir($target, 0755, true);
        }

        $items = @scandir($source);
        if (!$items) return 'No se pudo leer: ' . $source;

        foreach ($items as $item) {
            if ($item === '.' || $item === '..') continue;

            $srcPath = $source . '/' . $item;
            $dstPath = $target . '/' . $item;

            if (is_dir($srcPath)) {
                $result = $this->copyDirectoryRecursiveSimple($srcPath, $dstPath);
                if ($result !== true) return $result;
            } else {
                if (!@copy($srcPath, $dstPath)) {
                    return 'Error copiando: ' . $item;
                }
            }
        }

        return true;
    }

    // =========================================================================
    //  PASO 16: CREAR SUBDOMINIO Y CERTIFICADO SSL
    // =========================================================================

    /**
     * Crea un subdominio en cPanel y solicita certificado SSL via AutoSSL.
     * Usa cpapi2 CLI para crear el subdominio y whmapi1 para AutoSSL.
     *
     * @param int    $deploymentId  ID del despliegue
     * @param string $customDomain  Dominio raiz personalizado (ej: 'sistemavirtualdegestion.com'). Vacio = usar $rootDomain
     */
    public function executeStep16($deploymentId, $customDomain = '')
    {
        $dep = $this->getDeployment($deploymentId);
        if (!$dep) return 'Despliegue no encontrado';

        $subdomain = trim($dep['subdomain']);
        if (empty($subdomain)) {
            return 'No se ha definido un subdominio para este despliegue';
        }

        // Validar formato de subdominio (solo letras, numeros, guiones y guiones bajos)
        if (!preg_match('/^[a-z0-9][a-z0-9_-]*$/', $subdomain)) {
            return 'El subdominio contiene caracteres invalidos: ' . $subdomain . '. Solo se permiten letras minusculas, numeros, guiones y guiones bajos.';
        }

        $cpUser = self::$cpanelUser;

        // Dominio raiz: usar el personalizado si se proporciono y es valido
        $rootDomain = self::$rootDomain;
        if (!empty($customDomain)) {
            $customDomain = strtolower(trim($customDomain));
            // Validar que sea un dominio permitido
            if (in_array($customDomain, self::$availableDomains)) {
                $rootDomain = $customDomain;
            } else {
                return 'Dominio no permitido: ' . $customDomain . '. Dominios disponibles: ' . implode(', ', self::$availableDomains);
            }
        }

        // ---------------------------------------------------------------
        // FASE 1: Crear subdominio via API cPanel (exec o cURL)
        // ---------------------------------------------------------------
        $this->addLog($deploymentId, 16, 'Creando subdominio: ' . $subdomain . '.' . $rootDomain . '...', 'info');

        $addParams = [
            'domain'      => $subdomain,
            'rootdomain'  => $rootDomain,
            'dir'         => 'public_html/' . $subdomain,
            'disallowdot' => '1',
        ];

        $apiResult = $this->_cpanelApiCall('cpapi2', 'SubDomain', 'addsubdomain', $addParams);
        $this->addLog($deploymentId, 16, 'cpapi2 respuesta (metodo=' . $apiResult['method'] . '): ' . substr($apiResult['raw'] ?? '', 0, 500), 'info');

        $subdomainCreated = false;

        if ($apiResult['success']) {
            $cpError = $apiResult['data']['cpanelresult']['error'] ?? '';
            if (!empty($cpError)) {
                if (stripos($cpError, 'already exist') !== false || stripos($cpError, 'ya existe') !== false) {
                    $this->addLog($deploymentId, 16, 'El subdominio ya existe en cPanel (OK, continuando)', 'warning');
                    $subdomainCreated = true;
                } else {
                    $this->addLog($deploymentId, 16, 'Error cpapi2: ' . $cpError, 'error');
                    return 'Error creando subdominio: ' . $cpError;
                }
            } else {
                $cpResult = $apiResult['data']['cpanelresult']['data'][0] ?? [];
                if (isset($cpResult['result']) && $cpResult['result'] == 1) {
                    $this->addLog($deploymentId, 16, 'Subdominio creado exitosamente: ' . $subdomain . '.' . $rootDomain, 'success');
                } else {
                    $this->addLog($deploymentId, 16, 'Subdominio creado (respuesta sin error)', 'success');
                }
                $subdomainCreated = true;
            }
        } else {
            // Fallback: uapi
            $this->addLog($deploymentId, 16, 'cpapi2 fallo (' . ($apiResult['error'] ?? '') . '), intentando con uapi...', 'warning');

            unset($addParams['disallowdot']);
            $apiResult2 = $this->_cpanelApiCall('uapi', 'SubDomain', 'addsubdomain', $addParams);
            $this->addLog($deploymentId, 16, 'uapi respuesta (metodo=' . $apiResult2['method'] . '): ' . substr($apiResult2['raw'] ?? '', 0, 500), 'info');

            if ($apiResult2['success']) {
                $this->addLog($deploymentId, 16, 'Subdominio creado via uapi', 'success');
                $subdomainCreated = true;
            } else {
                $raw = $apiResult2['raw'] ?? '';
                if (stripos($raw, 'already exist') !== false) {
                    $this->addLog($deploymentId, 16, 'El subdominio ya existe (OK)', 'warning');
                    $subdomainCreated = true;
                } else {
                    return 'Error creando subdominio. ' . ($apiResult2['error'] ?: $apiResult['error']);
                }
            }
        }

        if (!$subdomainCreated) {
            return 'No se pudo crear el subdominio ' . $subdomain . '.' . $rootDomain;
        }

        // ---------------------------------------------------------------
        // FASE 2: Verificar que el directorio existe con permisos correctos
        // ---------------------------------------------------------------
        $homeDir = '/home/' . $cpUser;
        $docRoot = $homeDir . '/public_html/' . $subdomain;

        if (is_dir($docRoot)) {
            $this->addLog($deploymentId, 16, 'Directorio del subdominio existe: ' . $docRoot, 'info');
        } else {
            $this->addLog($deploymentId, 16, 'Directorio no encontrado, intentando crear: ' . $docRoot, 'warning');
            if ($this->_execAvailable()) {
                @exec('mkdir -p ' . escapeshellarg($docRoot) . ' 2>&1');
                @exec('chown ' . escapeshellarg($cpUser . ':' . $cpUser) . ' ' . escapeshellarg($docRoot) . ' 2>&1');
                @exec('chmod 755 ' . escapeshellarg($docRoot) . ' 2>&1');
            } else {
                @mkdir($docRoot, 0755, true);
            }

            if (is_dir($docRoot)) {
                $this->addLog($deploymentId, 16, 'Directorio creado y permisos asignados', 'success');
            } else {
                $this->addLog($deploymentId, 16, 'No se pudo crear directorio (puede requerir manual)', 'warning');
            }
        }

        // ---------------------------------------------------------------
        // FASE 3: Solicitar certificado SSL via AutoSSL
        // ---------------------------------------------------------------
        $this->addLog($deploymentId, 16, 'Solicitando certificado SSL via AutoSSL...', 'info');

        $sslResult = $this->_cpanelApiCall('whmapi1', '', 'start_autossl_check_for_one_user', ['username' => $cpUser]);
        $this->addLog($deploymentId, 16, 'AutoSSL respuesta (metodo=' . $sslResult['method'] . '): ' . substr($sslResult['raw'] ?? '', 0, 500), 'info');

        if ($sslResult['success']) {
            $this->addLog($deploymentId, 16, 'AutoSSL iniciado correctamente. El certificado se generara en los proximos minutos.', 'success');
        } else {
            // Metodo alternativo: binario directo (solo con exec)
            if ($this->_execAvailable()) {
                $this->addLog($deploymentId, 16, 'whmapi1 fallo, intentando con autossl_check binario...', 'warning');
                $outputSSL2 = [];
                $returnSSL2 = -1;
                @exec('/usr/local/cpanel/bin/autossl_check --user=' . escapeshellarg($cpUser) . ' 2>&1', $outputSSL2, $returnSSL2);

                if ($returnSSL2 === 0) {
                    $this->addLog($deploymentId, 16, 'AutoSSL iniciado via binario directo.', 'success');
                } else {
                    $this->addLog($deploymentId, 16, 'No se pudo iniciar AutoSSL automaticamente. ' . ($sslResult['error'] ?? '') . ' Puede ejecutarlo manualmente desde WHM > AutoSSL.', 'warning');
                }
            } else {
                $this->addLog($deploymentId, 16, 'No se pudo iniciar AutoSSL: ' . ($sslResult['error'] ?? '') . '. Ejecutelo manualmente desde WHM > AutoSSL.', 'warning');
            }
        }

        // Persistir el dominio usado en la BD para que deleteSubdomain/fullCleanup lo conozcan
        if ($this->_hasRootDomainColumn()) {
            $db = $this->getDB();
            if ($db) {
                $db->query('UPDATE deployments SET root_domain = ? WHERE id = ?', $rootDomain, $deploymentId);
            }
        }

        $this->addLog($deploymentId, 16, 'Subdominio configurado: https://' . $subdomain . '.' . $rootDomain . '/', 'success');
        $this->addLog($deploymentId, 16, 'NOTA: El certificado SSL puede tardar 5-15 minutos en generarse via AutoSSL.', 'info');

        return true;
    }

    // =========================================================================
    //  PASO 17: COPIA DE SEGURIDAD
    // =========================================================================

    /** Limite maximo de tamano para backup (12 GB en bytes) */
    const BACKUP_MAX_SIZE = 12884901888;

    /** Carpeta de respaldos relativa a public_html */
    const BACKUP_DIR = 'respaldo';

    /**
     * Calcula el tamano total de los archivos que se incluiran en el backup.
     */
    public function estimateBackupSize($targetPath)
    {
        $paths = $this->_getBackupPaths($targetPath);
        $totalSize = 0;

        foreach ($paths as $p) {
            $full = $targetPath . '/' . $p;
            if (is_file($full)) {
                $totalSize += filesize($full);
            } elseif (is_dir($full)) {
                $totalSize += $this->_dirSize($full);
            }
        }

        return $totalSize;
    }

    /**
     * Crea un ZIP de backup del sistema destino.
     *
     * @return array ['success' => bool, 'message' => string, 'filename' => string|null, 'size' => int]
     */
    public function executeStep17($deploymentId)
    {
        $dep = $this->getDeployment($deploymentId);
        if (!$dep) return 'Despliegue no encontrado';

        $targetPath = $dep['target_path'];
        if (!is_dir($targetPath)) return 'Carpeta del sistema no encontrada: ' . $targetPath;

        if (!class_exists('ZipArchive')) return 'Extension ZipArchive no disponible en PHP';

        // Carpeta de respaldos
        $basePath = $this->getBasePath();
        $backupDir = rtrim($basePath, '/') . '/' . self::BACKUP_DIR;
        if (!is_dir($backupDir)) {
            if (!@mkdir($backupDir, 0755, true)) {
                return 'No se pudo crear carpeta de respaldos: ' . $backupDir;
            }
            $this->addLog($deploymentId, 17, 'Carpeta de respaldos creada: ' . $backupDir, 'info');
        }

        // Estimar tamano antes de crear
        $this->addLog($deploymentId, 17, 'Estimando tamano del backup...', 'info');
        $estimatedSize = $this->estimateBackupSize($targetPath);
        $sizeMB = round($estimatedSize / 1048576, 1);
        $sizeGB = round($estimatedSize / 1073741824, 2);
        $this->addLog($deploymentId, 17, 'Tamano estimado: ' . $sizeMB . ' MB (' . $sizeGB . ' GB)', 'info');

        if ($estimatedSize > self::BACKUP_MAX_SIZE) {
            $this->addLog($deploymentId, 17, 'ERROR: El tamano estimado (' . $sizeGB . ' GB) supera el limite de 12 GB. Backup cancelado.', 'error');
            return 'El tamano estimado (' . $sizeGB . ' GB) supera el limite de 12 GB. Considere hacer backup manual de carpetas individuales.';
        }

        // Advertir si es mayor a 2 GB
        if ($estimatedSize > 2147483648) {
            $this->addLog($deploymentId, 17, 'ADVERTENCIA: Backup grande (' . $sizeGB . ' GB). El proceso puede tomar varios minutos.', 'warning');
        }

        // Nombre del ZIP: nombre_carpeta_YYYY-MM-DD_HHmmss.zip
        $folderName = basename($targetPath);
        $timestamp = date('Y-m-d_His');
        $zipFilename = $folderName . '_' . $timestamp . '.zip';
        $zipPath = $backupDir . '/' . $zipFilename;

        $this->addLog($deploymentId, 17, 'Creando backup: ' . $zipFilename . '...', 'info');

        $zip = new \ZipArchive();
        if ($zip->open($zipPath, \ZipArchive::CREATE | \ZipArchive::OVERWRITE) !== true) {
            return 'No se pudo crear archivo ZIP: ' . $zipPath;
        }

        $paths = $this->_getBackupPaths($targetPath);
        $addedFiles = 0;
        $addedSize = 0;

        foreach ($paths as $relativePath) {
            $fullPath = $targetPath . '/' . $relativePath;

            if (is_file($fullPath)) {
                $zip->addFile($fullPath, $relativePath);
                $addedFiles++;
                $addedSize += filesize($fullPath);
            } elseif (is_dir($fullPath)) {
                $result = $this->_addDirToZip($zip, $fullPath, $relativePath, $addedFiles, $addedSize);
                if (is_string($result)) {
                    $zip->close();
                    @unlink($zipPath);
                    return $result;
                }
            }

            // Verificar tamano durante la creacion
            if ($addedSize > self::BACKUP_MAX_SIZE) {
                $zip->close();
                @unlink($zipPath);
                $this->addLog($deploymentId, 17, 'Backup cancelado: tamano real excedio 12 GB durante la creacion', 'error');
                return 'El backup excedio 12 GB durante la creacion. Proceso cancelado.';
            }
        }

        $zip->close();

        $finalSize = filesize($zipPath);
        $finalMB = round($finalSize / 1048576, 1);
        $this->addLog($deploymentId, 17, 'Backup creado: ' . $zipFilename . ' (' . $addedFiles . ' archivos, ' . $finalMB . ' MB)', 'success');

        return true;
    }

    /**
     * Lista todos los backups en la carpeta de respaldos.
     */
    public function listBackups()
    {
        $basePath = $this->getBasePath();
        $backupDir = rtrim($basePath, '/') . '/' . self::BACKUP_DIR;

        if (!is_dir($backupDir)) return [];

        $backups = [];
        $files = @scandir($backupDir);
        if (!$files) return [];

        foreach ($files as $file) {
            if ($file === '.' || $file === '..') continue;
            if (strtolower(pathinfo($file, PATHINFO_EXTENSION)) !== 'zip') continue;

            $fullPath = $backupDir . '/' . $file;
            $size = filesize($fullPath);

            $backups[] = [
                'filename' => $file,
                'size'     => $size,
                'size_mb'  => round($size / 1048576, 1),
                'date'     => date('Y-m-d H:i:s', filemtime($fullPath)),
            ];
        }

        // Ordenar por fecha descendente
        usort($backups, function ($a, $b) {
            return strcmp($b['date'], $a['date']);
        });

        return $backups;
    }

    /**
     * Elimina un archivo de backup.
     */
    public function deleteBackup($filename)
    {
        // Validar nombre seguro (sin path traversal)
        $filename = basename($filename);
        if (empty($filename) || strtolower(pathinfo($filename, PATHINFO_EXTENSION)) !== 'zip') {
            return 'Nombre de archivo invalido';
        }

        $basePath = $this->getBasePath();
        $backupDir = rtrim($basePath, '/') . '/' . self::BACKUP_DIR;
        $fullPath = $backupDir . '/' . $filename;

        if (!file_exists($fullPath)) return 'Archivo no encontrado';

        if (!@unlink($fullPath)) return 'No se pudo eliminar el archivo';

        return true;
    }

    /**
     * Retorna la ruta del directorio de backups.
     */
    public function getBackupDir()
    {
        return rtrim($this->getBasePath(), '/') . '/' . self::BACKUP_DIR;
    }

    /**
     * Rutas relativas a incluir en el backup.
     */
    protected function _getBackupPaths($targetPath)
    {
        return [
            'conf',
            'data/media',
            'data/meta',
            'data/pages',
            'lib/tpl/20cones/favicon.ico',
            'lib/tpl/20cones/images',
            'lib/tpl/21cones/favicon.ico',
            'lib/tpl/21cones/images',
            'lib/images/smileys/logo.png',
            'lib/images/smileys/logo.gif',
        ];
    }

    /**
     * Calcula recursivamente el tamano de un directorio.
     */
    protected function _dirSize($dir)
    {
        $size = 0;
        $iterator = new \RecursiveIteratorIterator(
            new \RecursiveDirectoryIterator($dir, \RecursiveDirectoryIterator::SKIP_DOTS),
            \RecursiveIteratorIterator::LEAVES_ONLY
        );
        foreach ($iterator as $file) {
            if ($file->isFile()) {
                $size += $file->getSize();
            }
        }
        return $size;
    }

    /**
     * Agrega recursivamente un directorio al ZIP.
     */
    protected function _addDirToZip($zip, $dirPath, $zipDir, &$addedFiles, &$addedSize)
    {
        if (!is_dir($dirPath)) return null;

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

        foreach ($iterator as $file) {
            if (!$file->isFile()) continue;

            $filePath = $file->getRealPath();
            $relativePath = $zipDir . '/' . substr($filePath, strlen(realpath($dirPath)) + 1);
            // Normalizar separadores
            $relativePath = str_replace('\\', '/', $relativePath);

            $zip->addFile($filePath, $relativePath);
            $addedFiles++;
            $addedSize += $file->getSize();

            // Corte de emergencia
            if ($addedSize > self::BACKUP_MAX_SIZE) {
                return 'Tamano excedio 12 GB durante la creacion';
            }
        }

        return null;
    }

    // =========================================================================
    //  BACKUP PLUGINS (carpeta lib/plugins/)
    // =========================================================================

    /**
     * Rutas relativas a incluir en el backup de plugins.
     */
    protected function _getBackupPathsPlugins()
    {
        return ['lib/plugins'];
    }

    /**
     * Estima el tamano de la carpeta lib/plugins/ de un subdominio.
     */
    public function estimateBackupSizePlugins($targetPath)
    {
        $pluginsPath = rtrim($targetPath, '/') . '/lib/plugins';
        if (!is_dir($pluginsPath)) {
            return ['exists' => false, 'size' => 0];
        }
        return ['exists' => true, 'size' => $this->_dirSize($pluginsPath)];
    }

    /**
     * Crea un backup ZIP de la carpeta lib/plugins/ del subdominio.
     *
     * @param int $deploymentId ID del despliegue
     * @return true|string true si exito, string con mensaje de error
     */
    public function executeBackupPlugins($deploymentId)
    {
        $dep = $this->getDeployment($deploymentId);
        if (!$dep) return 'Despliegue no encontrado';

        $targetPath = $dep['target_path'];
        if (!is_dir($targetPath)) return 'Carpeta del sistema no encontrada: ' . $targetPath;

        $pluginsPath = rtrim($targetPath, '/') . '/lib/plugins';
        if (!is_dir($pluginsPath)) return 'La carpeta lib/plugins/ no existe en: ' . $targetPath;

        if (!class_exists('ZipArchive')) return 'Extension ZipArchive no disponible en PHP';

        // Carpeta de respaldos
        $basePath = $this->getBasePath();
        $backupDir = rtrim($basePath, '/') . '/' . self::BACKUP_DIR;
        if (!is_dir($backupDir)) {
            if (!@mkdir($backupDir, 0755, true)) {
                return 'No se pudo crear carpeta de respaldos: ' . $backupDir;
            }
        }

        // Estimar tamano
        $this->addLog($deploymentId, 17, 'Estimando tamano de lib/plugins/...', 'info');
        $estimatedSize = $this->_dirSize($pluginsPath);
        $sizeMB = round($estimatedSize / 1048576, 1);
        $sizeGB = round($estimatedSize / 1073741824, 2);
        $this->addLog($deploymentId, 17, 'Tamano estimado lib/plugins/: ' . $sizeMB . ' MB (' . $sizeGB . ' GB)', 'info');

        if ($estimatedSize > self::BACKUP_MAX_SIZE) {
            $this->addLog($deploymentId, 17, 'ERROR: lib/plugins/ excede 12 GB. Backup cancelado.', 'error');
            return 'El tamano de lib/plugins/ (' . $sizeGB . ' GB) supera el limite de 12 GB.';
        }

        // Nombre del ZIP
        $folderName = basename($targetPath);
        $timestamp = date('Y-m-d_His');
        $zipFilename = $folderName . '_PLUGINS_' . $timestamp . '.zip';
        $zipPath = $backupDir . '/' . $zipFilename;

        $this->addLog($deploymentId, 17, 'Creando backup plugins: ' . $zipFilename . '...', 'info');

        $zip = new \ZipArchive();
        if ($zip->open($zipPath, \ZipArchive::CREATE | \ZipArchive::OVERWRITE) !== true) {
            return 'No se pudo crear archivo ZIP: ' . $zipPath;
        }

        $addedFiles = 0;
        $addedSize = 0;

        $result = $this->_addDirToZip($zip, $pluginsPath, 'lib/plugins', $addedFiles, $addedSize);
        if (is_string($result)) {
            $zip->close();
            @unlink($zipPath);
            return $result;
        }

        $zip->close();

        $finalSize = filesize($zipPath);
        $finalMB = round($finalSize / 1048576, 1);
        $this->addLog($deploymentId, 17, 'Backup plugins creado: ' . $zipFilename . ' (' . $addedFiles . ' archivos, ' . $finalMB . ' MB)', 'success');

        return true;
    }

    // =========================================================================
    //  BACKUP CONFIGURACION (conf/, logos, templates, meta)
    // =========================================================================

    /**
     * Rutas relativas a incluir en el backup de configuracion.
     */
    protected function _getBackupPathsConfig()
    {
        return [
            'conf',
            'lib/images/smileys/logo.png',
            'lib/images/smileys/logo.gif',
            'lib/tpl/20cones/favicon.ico',
            'lib/tpl/20cones/images',
            'lib/tpl/21cones/favicon.ico',
            'lib/tpl/21cones/images',
            'data/meta',
        ];
    }

    /**
     * Estima el tamano del backup de configuracion.
     */
    public function estimateBackupSizeConfig($targetPath)
    {
        $paths = $this->_getBackupPathsConfig();
        $totalSize = 0;
        $exists = false;

        foreach ($paths as $p) {
            $full = rtrim($targetPath, '/') . '/' . $p;
            if (is_file($full)) {
                $totalSize += filesize($full);
                $exists = true;
            } elseif (is_dir($full)) {
                $totalSize += $this->_dirSize($full);
                $exists = true;
            }
        }

        return ['exists' => $exists, 'size' => $totalSize];
    }

    /**
     * Crea un backup ZIP de la configuracion del subdominio.
     *
     * @param int $deploymentId ID del despliegue
     * @return true|string true si exito, string con mensaje de error
     */
    public function executeBackupConfig($deploymentId)
    {
        $dep = $this->getDeployment($deploymentId);
        if (!$dep) return 'Despliegue no encontrado';

        $targetPath = $dep['target_path'];
        if (!is_dir($targetPath)) return 'Carpeta del sistema no encontrada: ' . $targetPath;

        if (!class_exists('ZipArchive')) return 'Extension ZipArchive no disponible en PHP';

        // Carpeta de respaldos
        $basePath = $this->getBasePath();
        $backupDir = rtrim($basePath, '/') . '/' . self::BACKUP_DIR;
        if (!is_dir($backupDir)) {
            if (!@mkdir($backupDir, 0755, true)) {
                return 'No se pudo crear carpeta de respaldos: ' . $backupDir;
            }
        }

        // Estimar tamano
        $this->addLog($deploymentId, 17, 'Estimando tamano de backup configuracion...', 'info');
        $result = $this->estimateBackupSizeConfig($targetPath);
        $estimatedSize = $result['size'];
        $sizeMB = round($estimatedSize / 1048576, 1);
        $sizeGB = round($estimatedSize / 1073741824, 2);
        $this->addLog($deploymentId, 17, 'Tamano estimado configuracion: ' . $sizeMB . ' MB (' . $sizeGB . ' GB)', 'info');

        if ($estimatedSize > self::BACKUP_MAX_SIZE) {
            $this->addLog($deploymentId, 17, 'ERROR: Configuracion excede 12 GB. Backup cancelado.', 'error');
            return 'El tamano de la configuracion (' . $sizeGB . ' GB) supera el limite de 12 GB.';
        }

        // Nombre del ZIP
        $folderName = basename($targetPath);
        $timestamp = date('Y-m-d_His');
        $zipFilename = $folderName . '_CONFIG_' . $timestamp . '.zip';
        $zipPath = $backupDir . '/' . $zipFilename;

        $this->addLog($deploymentId, 17, 'Creando backup configuracion: ' . $zipFilename . '...', 'info');

        $zip = new \ZipArchive();
        if ($zip->open($zipPath, \ZipArchive::CREATE | \ZipArchive::OVERWRITE) !== true) {
            return 'No se pudo crear archivo ZIP: ' . $zipPath;
        }

        $paths = $this->_getBackupPathsConfig();
        $addedFiles = 0;
        $addedSize = 0;

        foreach ($paths as $relativePath) {
            $fullPath = rtrim($targetPath, '/') . '/' . $relativePath;

            if (is_file($fullPath)) {
                $zip->addFile($fullPath, $relativePath);
                $addedFiles++;
                $addedSize += filesize($fullPath);
            } elseif (is_dir($fullPath)) {
                $r = $this->_addDirToZip($zip, $fullPath, $relativePath, $addedFiles, $addedSize);
                if (is_string($r)) {
                    $zip->close();
                    @unlink($zipPath);
                    return $r;
                }
            }

            if ($addedSize > self::BACKUP_MAX_SIZE) {
                $zip->close();
                @unlink($zipPath);
                $this->addLog($deploymentId, 17, 'Backup cancelado: tamano excedio 12 GB durante la creacion', 'error');
                return 'El backup excedio 12 GB durante la creacion. Proceso cancelado.';
            }
        }

        $zip->close();

        $finalSize = filesize($zipPath);
        $finalMB = round($finalSize / 1048576, 1);
        $this->addLog($deploymentId, 17, 'Backup configuracion creado: ' . $zipFilename . ' (' . $addedFiles . ' archivos, ' . $finalMB . ' MB)', 'success');

        return true;
    }

    // =========================================================================
    //  BACKUP PAGINA WEB (carpeta web/)
    // =========================================================================

    /**
     * Estima el tamano de la carpeta web/ de un subdominio.
     */
    public function estimateBackupSizeWeb($targetPath)
    {
        $webPath = rtrim($targetPath, '/') . '/web';
        if (!is_dir($webPath)) {
            return ['exists' => false, 'size' => 0];
        }
        return ['exists' => true, 'size' => $this->_dirSize($webPath)];
    }

    /**
     * Crea un backup ZIP de la carpeta web/ del subdominio.
     *
     * @param int $deploymentId ID del despliegue
     * @return true|string true si exito, string con mensaje de error
     */
    public function executeBackupWeb($deploymentId)
    {
        $dep = $this->getDeployment($deploymentId);
        if (!$dep) return 'Despliegue no encontrado';

        $targetPath = $dep['target_path'];
        if (!is_dir($targetPath)) return 'Carpeta del sistema no encontrada: ' . $targetPath;

        $webPath = rtrim($targetPath, '/') . '/web';
        if (!is_dir($webPath)) return 'La carpeta web/ no existe en: ' . $targetPath;

        if (!class_exists('ZipArchive')) return 'Extension ZipArchive no disponible en PHP';

        // Carpeta de respaldos
        $basePath = $this->getBasePath();
        $backupDir = rtrim($basePath, '/') . '/' . self::BACKUP_DIR;
        if (!is_dir($backupDir)) {
            if (!@mkdir($backupDir, 0755, true)) {
                return 'No se pudo crear carpeta de respaldos: ' . $backupDir;
            }
        }

        // Estimar tamano
        $this->addLog($deploymentId, 17, 'Estimando tamano de web/...', 'info');
        $estimatedSize = $this->_dirSize($webPath);
        $sizeMB = round($estimatedSize / 1048576, 1);
        $sizeGB = round($estimatedSize / 1073741824, 2);
        $this->addLog($deploymentId, 17, 'Tamano estimado web/: ' . $sizeMB . ' MB (' . $sizeGB . ' GB)', 'info');

        if ($estimatedSize > self::BACKUP_MAX_SIZE) {
            $this->addLog($deploymentId, 17, 'ERROR: web/ excede 12 GB. Backup cancelado.', 'error');
            return 'El tamano de web/ (' . $sizeGB . ' GB) supera el limite de 12 GB.';
        }

        // Nombre del ZIP
        $folderName = basename($targetPath);
        $timestamp = date('Y-m-d_His');
        $zipFilename = $folderName . '_WEB_' . $timestamp . '.zip';
        $zipPath = $backupDir . '/' . $zipFilename;

        $this->addLog($deploymentId, 17, 'Creando backup web: ' . $zipFilename . '...', 'info');

        $zip = new \ZipArchive();
        if ($zip->open($zipPath, \ZipArchive::CREATE | \ZipArchive::OVERWRITE) !== true) {
            return 'No se pudo crear archivo ZIP: ' . $zipPath;
        }

        $addedFiles = 0;
        $addedSize = 0;

        $result = $this->_addDirToZip($zip, $webPath, 'web', $addedFiles, $addedSize);
        if (is_string($result)) {
            $zip->close();
            @unlink($zipPath);
            return $result;
        }

        $zip->close();

        $finalSize = filesize($zipPath);
        $finalMB = round($finalSize / 1048576, 1);
        $this->addLog($deploymentId, 17, 'Backup web creado: ' . $zipFilename . ' (' . $addedFiles . ' archivos, ' . $finalMB . ' MB)', 'success');

        return true;
    }

    // =========================================================================
    //  ELIMINAR SUBDOMINIO (inverso de paso 16)
    // =========================================================================

    /**
     * Elimina el subdominio y certificado SSL de cPanel sin borrar la carpeta.
     */
    public function deleteSubdomain($deploymentId)
    {
        $dep = $this->getDeployment($deploymentId);
        if (!$dep) return 'Despliegue no encontrado';

        $subdomain = trim($dep['subdomain']);
        if (empty($subdomain)) {
            return 'No se ha definido un subdominio para este despliegue';
        }

        $cpUser = self::$cpanelUser;
        // BUG-FIX: usar el dominio guardado en BD, no el hardcodeado
        $rootDomain = !empty($dep['root_domain']) ? $dep['root_domain'] : self::$rootDomain;
        $fullDomain = $subdomain . '.' . $rootDomain;

        // ---------------------------------------------------------------
        // FASE 1: Eliminar subdominio via API cPanel (exec o cURL)
        // ---------------------------------------------------------------
        $this->addLog($deploymentId, 16, 'Eliminando subdominio: ' . $fullDomain . '...', 'info');

        $apiResult = $this->_cpanelApiCall('cpapi2', 'SubDomain', 'delsubdomain', ['domain' => $fullDomain]);
        $this->addLog($deploymentId, 16, 'cpapi2 delsubdomain (metodo=' . $apiResult['method'] . '): ' . substr($apiResult['raw'] ?? '', 0, 500), 'info');

        $subdomainDeleted = false;

        if ($apiResult['success']) {
            $cpError = $apiResult['data']['cpanelresult']['error'] ?? '';
            if (!empty($cpError)) {
                if (stripos($cpError, 'not exist') !== false || stripos($cpError, 'no existe') !== false) {
                    $this->addLog($deploymentId, 16, 'El subdominio ya no existe en cPanel (OK)', 'warning');
                    $subdomainDeleted = true;
                } else {
                    $this->addLog($deploymentId, 16, 'Error cpapi2 delsubdomain: ' . $cpError, 'error');
                    return 'Error eliminando subdominio: ' . $cpError;
                }
            } else {
                $this->addLog($deploymentId, 16, 'Subdominio eliminado exitosamente: ' . $fullDomain, 'success');
                $subdomainDeleted = true;
            }
        } else {
            // Fallback: uapi
            $this->addLog($deploymentId, 16, 'cpapi2 fallo (' . ($apiResult['error'] ?? '') . '), intentando con uapi...', 'warning');

            $apiResult2 = $this->_cpanelApiCall('uapi', 'SubDomain', 'delete_subdomain', ['domain' => $fullDomain]);
            $this->addLog($deploymentId, 16, 'uapi delete_subdomain (metodo=' . $apiResult2['method'] . '): ' . substr($apiResult2['raw'] ?? '', 0, 500), 'info');

            if ($apiResult2['success']) {
                $this->addLog($deploymentId, 16, 'Subdominio eliminado via uapi', 'success');
                $subdomainDeleted = true;
            } else {
                $raw = $apiResult2['raw'] ?? '';
                if (stripos($raw, 'not exist') !== false) {
                    $this->addLog($deploymentId, 16, 'El subdominio ya no existe (OK)', 'warning');
                    $subdomainDeleted = true;
                } else {
                    return 'Error eliminando subdominio. ' . ($apiResult2['error'] ?: $apiResult['error']);
                }
            }
        }

        if (!$subdomainDeleted) {
            return 'No se pudo eliminar el subdominio ' . $fullDomain;
        }

        // ---------------------------------------------------------------
        // FASE 2: Limpiar baseurl en local.php del sistema
        // ---------------------------------------------------------------
        $targetPath = $dep['target_path'];
        $localPhp = $targetPath . '/conf/local.php';

        if (file_exists($localPhp)) {
            $content = file_get_contents($localPhp);
            $original = $content;

            // Comentar la linea de baseurl (no eliminar, para referencia)
            $content = preg_replace(
                '/^(\$conf\[\'baseurl\'\]\s*=\s*.*?;)/m',
                '// [siedeployer: subdominio eliminado ' . date('Y-m-d H:i') . '] $1',
                $content
            );

            if ($content !== $original) {
                file_put_contents($localPhp, $content);
                $this->addLog($deploymentId, 16, 'Linea baseurl comentada en conf/local.php', 'success');
            } else {
                $this->addLog($deploymentId, 16, 'No se encontro baseurl en conf/local.php (OK)', 'info');
            }
        } else {
            $this->addLog($deploymentId, 16, 'Archivo conf/local.php no encontrado en ' . $targetPath, 'warning');
        }

        // ---------------------------------------------------------------
        // FASE 3: Verificar que la carpeta del sistema sigue intacta
        // ---------------------------------------------------------------
        if (is_dir($targetPath)) {
            $this->addLog($deploymentId, 16, 'Carpeta del sistema intacta: ' . $targetPath, 'success');
        } else {
            $this->addLog($deploymentId, 16, 'ADVERTENCIA: La carpeta ' . $targetPath . ' no existe', 'warning');
        }

        $this->addLog($deploymentId, 16, 'Subdominio ' . $fullDomain . ' eliminado. La carpeta y datos del sistema NO fueron borrados.', 'success');
        $this->addLog($deploymentId, 16, 'NOTA: El certificado SSL asociado se invalida automaticamente al eliminar el subdominio.', 'info');

        return true;
    }

    // =========================================================================
    //  LIMPIEZA COMPLETA DE DESPLIEGUE
    // =========================================================================

    /**
     * Elimina un despliegue completo: subdominio, certificado SSL, carpeta del sistema
     * y registro en la base de datos.
     *
     * @param int    $deploymentId  ID del despliegue
     * @param array  $options       Opciones de limpieza:
     *   - delete_subdomain (bool) Eliminar subdominio de cPanel
     *   - delete_folder    (bool) Eliminar carpeta del sistema
     *   - delete_record    (bool) Eliminar registro de la DB del plugin
     *
     * @return array ['success' => bool, 'log' => string[]]
     */
    public function fullCleanup($deploymentId, $options = [])
    {
        $dep = $this->getDeployment($deploymentId);
        if (!$dep) return ['success' => false, 'log' => ['Despliegue no encontrado']];

        $log = [];
        $hasErrors = false;

        $deleteSubdomain = !empty($options['delete_subdomain']);
        $deleteFolder    = !empty($options['delete_folder']);
        $deleteRecord    = !empty($options['delete_record']);

        $subdomain  = trim($dep['subdomain']);
        $targetPath = $dep['target_path'];
        $cpUser     = self::$cpanelUser;
        // BUG-FIX: usar el dominio guardado en BD, no el hardcodeado
        $rootDomain = !empty($dep['root_domain']) ? $dep['root_domain'] : self::$rootDomain;
        $fullDomain = $subdomain . '.' . $rootDomain;

        // ---------------------------------------------------------------
        // FASE 1: Eliminar subdominio de cPanel
        // ---------------------------------------------------------------
        if ($deleteSubdomain && !empty($subdomain)) {
            $log[] = '[INFO] Eliminando subdominio: ' . $fullDomain . '...';

            $apiResult = $this->_cpanelApiCall('cpapi2', 'SubDomain', 'delsubdomain', ['domain' => $fullDomain]);

            $subOk = false;
            if ($apiResult['success']) {
                $cpError = $apiResult['data']['cpanelresult']['error'] ?? '';
                if (!empty($cpError) && stripos($cpError, 'not exist') === false) {
                    $log[] = '[ERROR] cpapi2: ' . $cpError;
                    $hasErrors = true;
                } else {
                    $subOk = true;
                }
            } else {
                // Fallback uapi
                $apiResult2 = $this->_cpanelApiCall('uapi', 'SubDomain', 'delete_subdomain', ['domain' => $fullDomain]);
                $raw = $apiResult2['raw'] ?? '';
                if ($apiResult2['success'] || stripos($raw, 'not exist') !== false) {
                    $subOk = true;
                } else {
                    $log[] = '[ERROR] No se pudo eliminar subdominio: ' . ($apiResult2['error'] ?: $apiResult['error']);
                    $hasErrors = true;
                }
            }

            if ($subOk) {
                $log[] = '[OK] Subdominio eliminado (' . ($apiResult['method'] ?? 'api') . '): ' . $fullDomain;

                // Comentar baseurl en local.php
                $localPhp = $targetPath . '/conf/local.php';
                if (file_exists($localPhp)) {
                    $content = file_get_contents($localPhp);
                    $newContent = preg_replace(
                        '/^(\$conf\[\'baseurl\'\]\s*=\s*.*?;)/m',
                        '// [siedeployer cleanup ' . date('Y-m-d H:i') . '] $1',
                        $content
                    );
                    if ($newContent !== $content) {
                        file_put_contents($localPhp, $newContent);
                        $log[] = '[OK] Linea baseurl comentada en conf/local.php';
                    }
                }
            }
        }

        // ---------------------------------------------------------------
        // FASE 2: Eliminar carpeta del sistema
        // ---------------------------------------------------------------
        if ($deleteFolder && !empty($targetPath)) {
            if (!is_dir($targetPath)) {
                $log[] = '[INFO] Carpeta no existe (ya eliminada): ' . $targetPath;
            } else {
                $log[] = '[INFO] Eliminando carpeta: ' . $targetPath . '...';

                // Validar que es una ruta segura (dentro de public_html)
                $basePath = $this->getBasePath();
                if (strpos(realpath($targetPath), realpath($basePath)) !== 0) {
                    $log[] = '[ERROR] Ruta fuera de la base permitida. No se eliminara por seguridad.';
                    $hasErrors = true;
                } else {
                    $folderDeleted = false;

                    // Intentar con exec() si esta disponible
                    if ($this->_execAvailable()) {
                        $cmd = 'rm -rf ' . escapeshellarg($targetPath) . ' 2>&1';
                        $output = [];
                        $returnVar = -1;
                        @exec($cmd, $output, $returnVar);
                        $folderDeleted = ($returnVar === 0 && !is_dir($targetPath));
                    }

                    // Fallback: PHP recursivo
                    if (!$folderDeleted) {
                        $this->deleteDirectoryRecursive($targetPath);
                        $folderDeleted = !is_dir($targetPath);
                    }

                    if ($folderDeleted) {
                        $log[] = '[OK] Carpeta eliminada: ' . $targetPath;
                    } else {
                        $log[] = '[ERROR] No se pudo eliminar la carpeta completamente';
                        $hasErrors = true;
                    }
                }
            }
        }

        // ---------------------------------------------------------------
        // FASE 3: Eliminar registro de la base de datos del plugin
        // ---------------------------------------------------------------
        if ($deleteRecord) {
            $log[] = '[INFO] Eliminando registro del despliegue...';
            $this->deleteDeployment($deploymentId);
            $log[] = '[OK] Registro eliminado de la base de datos';
        }

        return [
            'success' => !$hasErrors,
            'log'     => $log,
        ];
    }

    // =========================================================================
    //  EDITOR DOCUMENTAL (Paso 19)
    // =========================================================================

    /** @var array Carpetas wiki donde buscar para reemplazos masivos */
    protected static $docEditorNamespaces = [
        'administrativa', 'comercial', 'compartidos', 'manual_funciones',
        'operativo', 'rrhh', 'administrativo', 'hse', 'hseq', 'ambiental',
        'calidad', 'financiero', 'gerencia', 'seguridad', 'sistemas',
        'sst', 'ssta', 'produccion', 'contabilidad',
    ];

    /**
     * Lee el manual de calidad del sistema desplegado y extrae las variables editables.
     *
     * @param int $deploymentId
     * @return array  Campos extraidos o ['error' => 'mensaje']
     */
    /**
     * Resuelve el target_path real del despliegue.
     * Si el target_path almacenado no contiene el manual, intenta resolverlo
     * desde el subdomain: /home/{cpUser}/public_html/{subdomain}/
     *
     * @param array $dep  Registro del despliegue
     * @return array  ['path' => string] o ['error' => string, 'tried' => array]
     */
    protected function _resolveTargetPath($dep)
    {
        $tried = [];

        // 1. Intentar con target_path almacenado
        $tp = rtrim($dep['target_path'] ?? '', '/');
        if ($tp !== '') {
            $testFile = $tp . '/data/pages/compartidos/manual_de_calidad.txt';
            $tried[] = $tp;
            if (file_exists($testFile)) {
                return ['path' => $tp];
            }
        }

        // 2. Intentar con el subdomain: /home/{cpUser}/public_html/{subdomain}/
        $subdomain = trim($dep['subdomain'] ?? '');
        if ($subdomain !== '') {
            $cpUser = self::$cpanelUser;
            $altPath = '/home/' . $cpUser . '/public_html/' . $subdomain;
            if ($altPath !== $tp) {
                $tried[] = $altPath;
                $testFile2 = $altPath . '/data/pages/compartidos/manual_de_calidad.txt';
                if (file_exists($testFile2)) {
                    return ['path' => $altPath];
                }
            }

            // 3. Intentar con punto: /home/{cpUser}/{subdomain}.{domain}/
            foreach (self::$availableDomains as $domain) {
                $altPath2 = '/home/' . $cpUser . '/' . $subdomain . '.' . $domain;
                if ($altPath2 !== $tp) {
                    $tried[] = $altPath2;
                    $testFile3 = $altPath2 . '/data/pages/compartidos/manual_de_calidad.txt';
                    if (file_exists($testFile3)) {
                        return ['path' => $altPath2];
                    }
                }
            }
        }

        return ['error' => 'No se encontro el manual de calidad en ninguna ruta', 'tried' => $tried];
    }

    public function loadDocEditorData($deploymentId)
    {
        $dep = $this->getDeployment($deploymentId);
        if (!$dep) return ['error' => 'Despliegue no encontrado'];

        // Resolver la ruta real del sistema
        $pathResult = $this->_resolveTargetPath($dep);
        if (isset($pathResult['error'])) {
            $msg = $pathResult['error'] . '. Rutas intentadas: ' . implode(', ', $pathResult['tried']);
            return ['error' => $msg];
        }

        $targetPath = $pathResult['path'];
        $manualFile = $targetPath . '/data/pages/compartidos/manual_de_calidad.txt';

        $content = file_get_contents($manualFile);
        if ($content === false) {
            return ['error' => 'No se pudo leer el archivo manual_de_calidad.txt en: ' . $manualFile];
        }

        $fields = [];
        $debug  = []; // Info de diagnóstico
        $debug['ruta_resuelta'] = $targetPath;
        $debug['ruta_almacenada'] = rtrim($dep['target_path'] ?? '', '/');
        if ($debug['ruta_resuelta'] !== $debug['ruta_almacenada']) {
            $debug['ruta_nota'] = 'Ruta corregida automaticamente desde subdomain: ' . ($dep['subdomain'] ?? '');
        }

        // =================================================================
        //  DATOS GENERALES — extraer la sección GENERALIDADES para debug
        // =================================================================
        $seccion = '';
        if (preg_match('/GENERALIDADES(.*?)(?:=====|NORMATIVIDAD)/is', $content, $secM)) {
            $seccion = $secM[1];
            $debug['seccion_generalidades'] = 'Encontrada (' . strlen($seccion) . ' chars)';
        } else {
            // Si no hay sección GENERALIDADES, usar todo el contenido
            $seccion = $content;
            $debug['seccion_generalidades'] = 'No encontrada, usando todo el archivo';
        }

        // =================================================================
        //  EXTRACCION GENERICA — NO usa acentos en el regex
        // =================================================================
        // Estrategia: capturar TODOS los patrones **Label:** Valor de la sección
        // usando solo [^*] (cualquier byte que no sea asterisco).
        // Luego identificar cada campo por substrings ASCII del label (stripos).
        // Esto evita completamente problemas de encoding UTF-8/Latin-1 con acentos.
        $allExtracted = [];
        if (preg_match_all('/\*\*\s*([^*]+?)\s*:\s*\*\*\s*(.+?)(?=\s*\*\*|\s*$)/m', $seccion, $exMatches, PREG_SET_ORDER)) {
            foreach ($exMatches as $exM) {
                $allExtracted[] = [
                    'raw_label' => trim($exM[1]),
                    'raw_value' => trim($exM[2]),
                ];
            }
        }
        $debug['campos_genericos_encontrados'] = count($allExtracted);
        $debug['labels_encontrados'] = array_map(function($e) { return $e['raw_label']; }, $allExtracted);

        foreach ($allExtracted as $af) {
            $lbl = $af['raw_label'];
            $val = $af['raw_value'];

            // --- Nombre / Razón Social ---
            if (stripos($lbl, 'Nombre') !== false && !isset($fields['nombre'])) {
                $fields['nombre'] = ['label' => 'Nombre / Razón Social', 'value' => $val];
                $debug['nombre'] = 'OK';
            }
            // --- Nit / NIT ---
            elseif (stripos($lbl, 'Nit') !== false && stripos($lbl, 'Nombre') === false && !isset($fields['nit_cc'])) {
                $tipo = (strpos($lbl, 'NIT') !== false) ? 'NIT' : 'Nit';
                $fields['nit_cc'] = ['label' => $tipo, 'value' => $val, 'tipo' => $tipo];
                $debug['nit_cc'] = 'OK (' . $tipo . ')';
            }
            // --- C.C. ---
            elseif ((strpos($lbl, 'C.C') !== false || strpos($lbl, 'C. C') !== false) && !isset($fields['nit_cc'])) {
                $fields['nit_cc'] = ['label' => 'C.C.', 'value' => $val, 'tipo' => 'C.C.'];
                $debug['nit_cc'] = 'OK (C.C.)';
            }
            // --- Socios ---
            elseif (stripos($lbl, 'Socio') !== false && !isset($fields['socios'])) {
                $fields['socios'] = ['label' => 'Socios', 'value' => $val];
                $debug['socios'] = 'OK';
            }
            // --- Dirección (buscar "Direcci" = ASCII puro, funciona sin importar encoding) ---
            elseif (stripos($lbl, 'Direcci') !== false && !isset($fields['direccion'])) {
                $fields['direccion'] = ['label' => 'Dirección', 'value' => $val];
                $debug['direccion'] = 'OK';
            }
            // --- Teléfono (buscar "Tel" + "fono" = ambos ASCII puro) ---
            elseif (stripos($lbl, 'Tel') !== false && stripos($lbl, 'fono') !== false && !isset($fields['telefono'])) {
                $fields['telefono'] = ['label' => 'Teléfono', 'value' => $val];
                $debug['telefono'] = 'OK';
            }
            // --- Correo electrónico ---
            elseif (stripos($lbl, 'Correo') !== false && !isset($fields['correo'])) {
                // Extraer email de link wiki [[:maito:X|X]] o [[mailto:X|X]]
                if (preg_match('/\[\[.*?\|(.*?)\]\]/', $val, $cm)) {
                    $fields['correo'] = ['label' => 'Correo electrónico', 'value' => trim($cm[1])];
                } elseif (preg_match('/[\w\.\-]+@[\w\.\-]+\.\w+/', $val, $cm)) {
                    $fields['correo'] = ['label' => 'Correo electrónico', 'value' => trim($cm[0])];
                } else {
                    $fields['correo'] = ['label' => 'Correo electrónico', 'value' => $val];
                }
                $debug['correo'] = 'OK';
            }
        }

        // Marcar campos no encontrados en debug
        foreach (['nombre', 'nit_cc', 'socios', 'direccion', 'telefono', 'correo'] as $k) {
            if (!isset($debug[$k])) $debug[$k] = 'No encontrado';
        }

        // --- T.P. (tarjeta profesional — colon es opcional, regex aparte) ---
        if (preg_match('/\*\*\s*T\.?\s*P\.?\s*:?\s*\*\*\s*(.+?)(?=\s*\*\*|\s*$)/mi', $seccion, $m)) {
            $fields['tp'] = ['label' => 'T.P.', 'value' => trim($m[1])];
            $debug['tp'] = 'OK';
        } else {
            $debug['tp'] = 'No encontrado (puede no existir)';
        }

        // --- NOTA - nombre del miembro natural ---
        if (preg_match('/La persona natural\s+(.+?)\s+miembro/i', $content, $m)) {
            $fields['nota_miembro'] = ['label' => 'Nombre del miembro (NOTA)', 'value' => trim($m[1])];
            $debug['nota_miembro'] = 'OK';
        } else {
            $debug['nota_miembro'] = 'No encontrado';
        }

        // =================================================================
        //  CUADRO DE FIRMAS del Manual
        // =================================================================
        // Buscar tabla DokuWiki después de "CUADRO DE FIRMAS" o "FIRMA DE QUIEN"
        $firmaFound = false;
        if (preg_match('/(?:CUADRO DE FIRMAS|FIRMA DE QUIEN)[^\n]*\n((?:[\^\|][^\n]*\n)+)/is', $content, $tableMatch)) {
            $tableBlock = $tableMatch[1];
            $tableRows  = [];

            // Extraer cada fila de datos |CONTENIDO| (excluyendo encabezados ^...^)
            $lines = preg_split('/\r?\n/', $tableBlock);
            foreach ($lines as $line) {
                $line = trim($line);
                // Saltar encabezados (^...^) y líneas vacías
                if ($line === '' || $line[0] === '^') continue;
                // Extraer contenido entre pipes |...|
                if (preg_match('/\|\s*(.+?)\s*\|/', $line, $lm)) {
                    $cellVal = trim($lm[1]);
                    // Saltar celdas vacías y celdas que son solo imágenes {{...}}
                    if ($cellVal === '') continue;
                    if (preg_match('/^\{\{[^}]*\}\}$/', $cellVal)) continue;
                    $tableRows[] = $cellVal;
                }
            }

            $debug['firma_rows'] = $tableRows;

            if (isset($tableRows[0])) {
                $fields['firmante_nombre'] = ['label' => 'Nombre firmante', 'value' => $tableRows[0]];
                $firmaFound = true;
            }
            if (isset($tableRows[1])) {
                $fields['firmante_cargo'] = ['label' => 'Cargo firmante', 'value' => $tableRows[1]];
            }
            if (isset($tableRows[2])) {
                $ccRaw = $tableRows[2];
                if (preg_match('/(?:C\.?C\.?|N\.?I\.?T\.?)\s*\.?\s*([\d\s\.\-]+)/i', $ccRaw, $ccM)) {
                    $fields['firmante_cc'] = ['label' => 'C.C. / NIT firmante', 'value' => trim($ccM[1])];
                } else {
                    $fields['firmante_cc'] = ['label' => 'C.C. / NIT firmante', 'value' => $ccRaw];
                }
            }
        }
        $debug['cuadro_firmas'] = $firmaFound ? 'OK' : 'No encontrado';

        // =================================================================
        //  CÓDIGO DE ÉTICA
        // =================================================================
        $eticaFile = $targetPath . '/data/pages/compartidos/codigo_de_etica.txt';
        if (file_exists($eticaFile)) {
            $eticaContent = file_get_contents($eticaFile);
            if ($eticaContent !== false) {
                if (preg_match('/(?:CUADRO DE FIRMAS|FIRMA DE QUIEN)[^\n]*\n((?:[\^\|][^\n]*\n)+)/is', $eticaContent, $etMatch)) {
                    $etRows = [];
                    $etLines = preg_split('/\r?\n/', $etMatch[1]);
                    foreach ($etLines as $line) {
                        $line = trim($line);
                        if ($line === '' || $line[0] === '^') continue;
                        if (preg_match('/\|\s*(.+?)\s*\|/', $line, $lm)) {
                            $cv = trim($lm[1]);
                            if ($cv === '' || preg_match('/^\{\{[^}]*\}\}$/', $cv)) continue;
                            $etRows[] = $cv;
                        }
                    }
                    if (isset($etRows[0])) {
                        $fields['etica_firmante'] = ['label' => 'Firmante Código de Ética', 'value' => $etRows[0]];
                    }
                    for ($i = 1; $i < count($etRows); $i++) {
                        if (preg_match('/(?:NIT|C\.?C\.?)\s*\.?\s*([\d\s\.\-]+)/i', $etRows[$i], $etCcM)) {
                            $fields['etica_nit'] = ['label' => 'NIT/C.C. Código de Ética', 'value' => trim($etCcM[1])];
                            break;
                        }
                    }
                }
            }
            $debug['codigo_etica'] = 'Archivo encontrado';
        } else {
            $debug['codigo_etica'] = 'Archivo no existe';
        }

        // Incluir muestra de la sección GENERALIDADES para diagnóstico
        if (!empty($seccion)) {
            // Primeras 600 chars de la sección para debug visual
            $debug['muestra_seccion'] = mb_substr(trim($seccion), 0, 600);
        }

        return ['fields' => $fields, '_debug' => $debug, '_format' => 'v2'];
    }

    /**
     * Aplica cambios masivos del editor documental en todos los documentos del sistema.
     *
     * @param int   $deploymentId
     * @param array $changes  Array de {campo => {old, new}} solo con campos modificados
     * @return array  ['files_processed', 'files_modified', 'errors', 'detail']
     */
    public function applyDocEditorChanges($deploymentId, $changes)
    {
        $dep = $this->getDeployment($deploymentId);
        if (!$dep) return ['files_processed' => 0, 'files_modified' => 0, 'errors' => ['Despliegue no encontrado'], 'detail' => []];

        // Resolver la ruta real del sistema (misma lógica que loadDocEditorData)
        $pathResult = $this->_resolveTargetPath($dep);
        if (isset($pathResult['error'])) {
            return ['files_processed' => 0, 'files_modified' => 0, 'errors' => [$pathResult['error'] . '. Rutas: ' . implode(', ', $pathResult['tried'])], 'detail' => []];
        }

        $targetPath = $pathResult['path'];
        $pagesPath  = $targetPath . '/data/pages';

        $filesProcessed = 0;
        $filesModified  = 0;
        $errors  = [];
        $detail  = [];

        // Determinar el nombre principal viejo y nuevo (para búsqueda masiva)
        $oldNombre = isset($changes['nombre']) ? trim($changes['nombre']['old']) : '';
        $newNombre = isset($changes['nombre']) ? trim($changes['nombre']['new']) : '';
        $nombreChanged = ($oldNombre !== '' && $newNombre !== '' && $oldNombre !== $newNombre);

        // Determinar correo viejo y nuevo
        $oldCorreo = isset($changes['correo']) ? trim($changes['correo']['old']) : '';
        $newCorreo = isset($changes['correo']) ? trim($changes['correo']['new']) : '';
        $correoChanged = ($oldCorreo !== '' && $newCorreo !== '' && $oldCorreo !== $newCorreo);

        // =====================================================
        // 1. ACTUALIZAR MANUAL DE CALIDAD
        // =====================================================
        $manualFile = $pagesPath . '/compartidos/manual_de_calidad.txt';
        if (file_exists($manualFile)) {
            $content = file_get_contents($manualFile);
            $original = $content;

            // Reemplazar cada campo estructurado
            // Patrones de label SIN acentos en el regex.
            // Usa .{1,3} para el byte(s) del acento (cubre UTF-8, Latin-1, sin acento).
            $fieldMap = [
                'nombre'    => 'Nombre',
                'nit_cc'    => null, // se maneja especialmente por el tipo
                'tp'        => null,
                'socios'    => 'Socios?',
                'direccion' => 'Direcci.{1,3}n',
                'telefono'  => 'Tel.{1,3}fonos?',
            ];

            foreach ($fieldMap as $key => $label) {
                if (!isset($changes[$key]) || $changes[$key]['old'] === $changes[$key]['new']) continue;
                $old = $changes[$key]['old'];
                $new = $changes[$key]['new'];

                if ($label !== null) {
                    // Regex: **Label:**  VALOR → **Label:**  NUEVO_VALOR
                    // Sin flag u — matching a nivel de bytes, compatible con cualquier encoding
                    $pattern = '/(\*\*\s*' . $label . '\s*:\s*\*\*\s*)' . preg_quote($old, '/') . '/i';
                    $content = preg_replace($pattern, '${1}' . $new, $content);
                }
            }

            // NIT/C.C. especial
            if (isset($changes['nit_cc']) && $changes['nit_cc']['old'] !== $changes['nit_cc']['new']) {
                $oldVal = $changes['nit_cc']['old'];
                $newVal = $changes['nit_cc']['new'];
                $content = preg_replace(
                    '/(\*\*\s*(?:Nit|NIT|C\.?\s*C\.?)\s*:\s*\*\*\s*)' . preg_quote($oldVal, '/') . '/i',
                    '${1}' . $newVal,
                    $content
                );
            }

            // T.P. especial
            if (isset($changes['tp']) && $changes['tp']['old'] !== $changes['tp']['new']) {
                $oldVal = $changes['tp']['old'];
                $newVal = $changes['tp']['new'];
                $content = preg_replace(
                    '/(\*\*\s*[Tt]\.?\s*[Pp]\.?\s*:\s*\*\*\s*)' . preg_quote($oldVal, '/') . '/i',
                    '${1}' . $newVal,
                    $content
                );
            }

            // Correo electrónico - reemplazar en link wiki y texto plano
            if ($correoChanged) {
                $content = str_replace($oldCorreo, $newCorreo, $content);
                // También actualizar el namespace del link maito si existe
                $content = str_replace(':maito:' . $oldCorreo, ':maito:' . $newCorreo, $content);
            }

            // NOTA - nombre del miembro
            if (isset($changes['nota_miembro']) && $changes['nota_miembro']['old'] !== $changes['nota_miembro']['new']) {
                $content = str_replace($changes['nota_miembro']['old'], $changes['nota_miembro']['new'], $content);
            }

            // Cuadro de firmas - nombre
            if (isset($changes['firmante_nombre']) && $changes['firmante_nombre']['old'] !== $changes['firmante_nombre']['new']) {
                $content = str_replace($changes['firmante_nombre']['old'], $changes['firmante_nombre']['new'], $content);
            }

            // Cuadro de firmas - cargo
            if (isset($changes['firmante_cargo']) && $changes['firmante_cargo']['old'] !== $changes['firmante_cargo']['new']) {
                $content = str_replace($changes['firmante_cargo']['old'], $changes['firmante_cargo']['new'], $content);
            }

            // Cuadro de firmas - C.C.
            if (isset($changes['firmante_cc']) && $changes['firmante_cc']['old'] !== $changes['firmante_cc']['new']) {
                $content = str_replace($changes['firmante_cc']['old'], $changes['firmante_cc']['new'], $content);
            }

            // Búsqueda masiva del nombre principal dentro del manual
            if ($nombreChanged) {
                $content = str_replace($oldNombre, $newNombre, $content);
            }

            $filesProcessed++;
            if ($content !== $original) {
                file_put_contents($manualFile, $content);
                $filesModified++;
                $detail[] = 'Manual de Calidad actualizado';
            }
        } else {
            $errors[] = 'No se encontro manual_de_calidad.txt';
        }

        // =====================================================
        // 2. ACTUALIZAR CÓDIGO DE ÉTICA
        // =====================================================
        $eticaFile = $pagesPath . '/compartidos/codigo_de_etica.txt';
        if (file_exists($eticaFile)) {
            $content = file_get_contents($eticaFile);
            $original = $content;

            // Firmante del código de ética
            if (isset($changes['etica_firmante']) && $changes['etica_firmante']['old'] !== $changes['etica_firmante']['new']) {
                $content = str_replace($changes['etica_firmante']['old'], $changes['etica_firmante']['new'], $content);
            }

            // NIT/C.C. del código de ética
            if (isset($changes['etica_nit']) && $changes['etica_nit']['old'] !== $changes['etica_nit']['new']) {
                $content = str_replace($changes['etica_nit']['old'], $changes['etica_nit']['new'], $content);
            }

            // Búsqueda masiva del nombre principal
            if ($nombreChanged) {
                $content = str_replace($oldNombre, $newNombre, $content);
            }

            $filesProcessed++;
            if ($content !== $original) {
                file_put_contents($eticaFile, $content);
                $filesModified++;
                $detail[] = 'Codigo de Etica actualizado';
            }
        }

        // =====================================================
        // 3. BÚSQUEDA MASIVA EN TODAS LAS CARPETAS
        // =====================================================
        // Construir tabla de reemplazos masivos (old => new) para TODOS los campos modificados
        $massReplacements = [];

        foreach ($changes as $key => $ch) {
            $oldV = trim($ch['old']);
            $newV = trim($ch['new']);
            if ($oldV === '' || $newV === '' || $oldV === $newV) continue;
            // Evitar duplicados (ej: nombre ya estaria por $nombreChanged)
            if (!isset($massReplacements[$oldV])) {
                $massReplacements[$oldV] = $newV;
            }
        }

        // Si hay correo con link wiki, agregar también variante del namespace maito
        if ($correoChanged) {
            $massReplacements[':maito:' . $oldCorreo] = ':maito:' . $newCorreo;
            $massReplacements['[[' . $oldCorreo] = '[[' . $newCorreo;
        }

        $foldersScanned = 0;
        $foldersSkipped = [];

        if (!empty($massReplacements)) {
            foreach (self::$docEditorNamespaces as $ns) {
                $nsPath = $pagesPath . '/' . $ns;
                if (!is_dir($nsPath)) {
                    $foldersSkipped[] = $ns;
                    continue;
                }
                $foldersScanned++;

                $iterator = new \RecursiveIteratorIterator(
                    new \RecursiveDirectoryIterator($nsPath, \RecursiveDirectoryIterator::SKIP_DOTS)
                );

                foreach ($iterator as $fileInfo) {
                    if ($fileInfo->getExtension() !== 'txt') continue;

                    $filePath = $fileInfo->getPathname();

                    // Saltar los que ya procesamos arriba (manual y código de ética)
                    $relPath = str_replace($pagesPath . '/', '', $filePath);
                    if ($relPath === 'compartidos/manual_de_calidad.txt' || $relPath === 'compartidos/codigo_de_etica.txt') {
                        continue;
                    }

                    $content = file_get_contents($filePath);
                    if ($content === false) continue;

                    $original = $content;
                    $filesProcessed++;

                    // Aplicar TODOS los reemplazos masivos
                    foreach ($massReplacements as $searchStr => $replaceStr) {
                        $content = str_replace($searchStr, $replaceStr, $content);
                    }

                    if ($content !== $original) {
                        file_put_contents($filePath, $content);
                        $filesModified++;
                        $readablePath = str_replace('\\', '/', $relPath);
                        $detail[] = $readablePath;
                    }
                }
            }
        }

        return [
            'files_processed'  => $filesProcessed,
            'files_modified'   => $filesModified,
            'folders_scanned'  => $foldersScanned,
            'folders_skipped'  => $foldersSkipped,
            'replacements'     => count($massReplacements),
            'errors'           => $errors,
            'detail'           => $detail,
        ];
    }

    // =========================================================================
    //  MIGRACION MASIVA DE DOMINIOS
    // =========================================================================

    /**
     * Escanea todos los subdominios configurados en cPanel para el usuario.
     * Cruza con sistemas SIE detectados (doku.php) y lee titulo de local.php.
     */
    public function scanAllSubdomains()
    {
        $cpUser = self::$cpanelUser;
        $subdomains = [];

        // Intento 1: cpapi2 SubDomain listsubdomains
        $result = $this->_cpanelApiCall('cpapi2', 'SubDomain', 'listsubdomains');

        if ($result['success'] && isset($result['data']['cpanelresult']['data'])) {
            foreach ($result['data']['cpanelresult']['data'] as $sub) {
                $subdomains[] = $this->_buildSubdomainEntry($sub, $cpUser);
            }
        }

        // Fallback: uapi SubDomain list_subdomains
        if (empty($subdomains)) {
            $result2 = $this->_cpanelApiCall('uapi', 'SubDomain', 'list_subdomains');

            if ($result2['success']) {
                $items = $result2['data']['result']['data'] ?? [];
                foreach ($items as $sub) {
                    $subdomains[] = $this->_buildSubdomainEntry($sub, $cpUser);
                }
            } else {
                // Ambos metodos cPanel fallaron - Fallback: escaneo por filesystem
                $basePath = $this->getBasePath();
                $dirs = @scandir($basePath);
                if ($dirs) {
                    foreach ($dirs as $dir) {
                        if ($dir === '.' || $dir === '..') continue;
                        $fullPath = rtrim($basePath, '/') . '/' . $dir;
                        if (!is_dir($fullPath)) continue;
                        $hConf = file_exists($fullPath . '/conf/local.php') || file_exists($fullPath . '/conf/dokuwiki.php');
                        if (!$hConf || !is_dir($fullPath . '/data')) continue;

                        $title = $this->_readSystemTitle($fullPath);
                        $subdomains[] = [
                            'subdomain'   => $dir,
                            'rootdomain'  => self::$rootDomain,
                            'full_domain' => $dir . '.' . self::$rootDomain,
                            'dir'         => $fullPath,
                            'full_dir'    => $fullPath,
                            'is_sie'      => true,
                            'sie_title'   => $title,
                            '_source'     => 'filesystem',
                        ];
                    }
                }
                if (empty($subdomains)) {
                    return ['error' => 'No se pudo conectar a cPanel y no se encontraron sistemas en ' . $basePath];
                }
            }
        }

        // Filtrar entradas vacias
        $subdomains = array_filter($subdomains);

        usort($subdomains, function ($a, $b) {
            return strcasecmp($a['subdomain'], $b['subdomain']);
        });

        return ['subdomains' => array_values($subdomains), 'total' => count($subdomains)];
    }

    /**
     * Construye una entrada de subdominio a partir de los datos de la API.
     */
    private function _buildSubdomainEntry($sub, $cpUser)
    {
        $subName    = $sub['domain'] ?? '';
        $rootDomain = $sub['rootdomain'] ?? '';
        $dir        = $sub['dir'] ?? ($sub['documentroot'] ?? '');

        if (empty($subName)) return null;

        $fullDir  = $this->_resolveSubdomainDir($dir, $cpUser);
        $isSie    = (file_exists($fullDir . '/conf/local.php') || file_exists($fullDir . '/conf/dokuwiki.php')) && is_dir($fullDir . '/data');
        $sieTitle = $isSie ? $this->_readSystemTitle($fullDir) : '';

        return [
            'subdomain'   => $subName,
            'rootdomain'  => $rootDomain,
            'full_domain' => $subName . '.' . $rootDomain,
            'dir'         => $dir,
            'full_dir'    => $fullDir,
            'is_sie'      => $isSie,
            'sie_title'   => $sieTitle,
        ];
    }

    /**
     * Lista los dominios configurados en la cuenta cPanel (main + addon + parked).
     * Verifica cuales de nuestros $availableDomains estan activos.
     */
    public function listConfiguredDomains()
    {
        $result = [
            'main_domain'          => '',
            'addon_domains'        => [],
            'parked_domains'       => [],
            'all_domains'          => [],
            'available_configured' => [],
        ];

        // uapi DomainInfo list_domains
        $apiResult = $this->_cpanelApiCall('uapi', 'DomainInfo', 'list_domains');

        if ($apiResult['success'] && isset($apiResult['data']['result']['data'])) {
            $d = $apiResult['data']['result']['data'];
            $result['main_domain']    = $d['main_domain'] ?? '';
            $result['addon_domains']  = $d['addon_domains'] ?? ($d['addondomains'] ?? []);
            $result['parked_domains'] = $d['parked_domains'] ?? ($d['parkeddomains'] ?? []);
        } else {
            // Fallback: cpapi2 AddonDomain listaddondomains
            $apiResult2 = $this->_cpanelApiCall('cpapi2', 'AddonDomain', 'listaddondomains');

            if ($apiResult2['success'] && isset($apiResult2['data']['cpanelresult']['data'])) {
                foreach ($apiResult2['data']['cpanelresult']['data'] as $addon) {
                    if (!empty($addon['domain'])) {
                        $result['addon_domains'][] = $addon['domain'];
                    }
                }
            } else {
                // Ambos metodos cPanel fallaron - Fallback: usar datos estaticos configurados
                $result['main_domain'] = self::$rootDomain;
                $result['addon_domains'] = [];
                $result['_source'] = 'static_fallback';
            }
            if (empty($result['main_domain'])) {
                $result['main_domain'] = self::$rootDomain;
            }
        }

        // Compilar todos los dominios
        $all = [];
        if (!empty($result['main_domain'])) $all[] = $result['main_domain'];
        $all = array_merge($all, $result['addon_domains'], $result['parked_domains']);
        $result['all_domains'] = array_values(array_unique(array_filter($all)));

        // Verificar cuales de nuestros dominios estan configurados
        foreach (self::$availableDomains as $domain) {
            $result['available_configured'][$domain] = in_array($domain, $result['all_domains']);
        }

        return $result;
    }

    /**
     * Crea un nuevo subdominio bajo un dominio diferente, apuntando a la misma carpeta.
     * Fase 1 de la migracion.
     */
    public function migrateCreateSubdomain($subdomain, $targetDomain, $docRoot)
    {
        $cpUser     = self::$cpanelUser;
        $fullDomain = $subdomain . '.' . $targetDomain;

        // Normalizar docRoot a ruta relativa desde home
        $relDir     = $docRoot;
        $homePrefix = '/home/' . $cpUser . '/';
        if (strpos($docRoot, $homePrefix) === 0) {
            $relDir = substr($docRoot, strlen($homePrefix));
        }

        $params = ['domain' => $subdomain, 'rootdomain' => $targetDomain, 'dir' => $relDir];

        // cpapi2 SubDomain addsubdomain
        $apiResult = $this->_cpanelApiCall('cpapi2', 'SubDomain', 'addsubdomain', $params);

        if ($apiResult['success']) {
            $cpError = $apiResult['data']['cpanelresult']['error'] ?? '';
            if (!empty($cpError)) {
                if (stripos($cpError, 'already exist') !== false || stripos($cpError, 'ya existe') !== false) {
                    return ['success' => true, 'message' => 'Subdominio ya existe: ' . $fullDomain, 'already_exists' => true];
                }
                return 'Error cPanel: ' . $cpError;
            }
            return ['success' => true, 'message' => 'Subdominio creado (' . $apiResult['method'] . '): ' . $fullDomain];
        }

        // Fallback: uapi
        $apiResult2 = $this->_cpanelApiCall('uapi', 'SubDomain', 'addsubdomain', $params);

        if ($apiResult2['success']) {
            return ['success' => true, 'message' => 'Subdominio creado via uapi (' . $apiResult2['method'] . '): ' . $fullDomain];
        }

        $raw = $apiResult2['raw'] ?? '';
        if (stripos($raw, 'already exist') !== false) {
            return ['success' => true, 'message' => 'Subdominio ya existe: ' . $fullDomain, 'already_exists' => true];
        }

        return 'Error creando subdominio ' . $fullDomain . '. ' . ($apiResult2['error'] ?: $apiResult['error']);
    }

    /**
     * Actualiza el baseurl en conf/local.php de un sistema para el nuevo dominio.
     * Crea backup automatico antes de modificar.
     */
    public function migrateUpdateBaseurl($systemPath, $subdomain, $newDomain)
    {
        $localPhp = rtrim($systemPath, '/') . '/conf/local.php';

        if (!file_exists($localPhp)) {
            return 'Archivo conf/local.php no encontrado en ' . $systemPath;
        }

        $content  = file_get_contents($localPhp);
        $original = $content;
        $newUrl   = 'https://' . $subdomain . '.' . $newDomain . '/';
        $oldUrl   = '';

        // Buscar baseurl existente (activo)
        if (preg_match('/^\$conf\[\'baseurl\'\]\s*=\s*\'([^\']*)\'\s*;/m', $content, $m)) {
            $oldUrl  = $m[1];
            $content = preg_replace(
                '/^(\$conf\[\'baseurl\'\]\s*=\s*\')([^\']*)(\';\s*)$/m',
                '${1}' . addcslashes($newUrl, '\\\'$') . '${3}',
                $content
            );
        } elseif (preg_match('/^\/\/\s*\[siedeployer:.*?\]\s*\$conf\[\'baseurl\'\]/m', $content)) {
            // baseurl comentado por deleteSubdomain anterior, agregar nueva linea
            $content = preg_replace(
                '/(^\/\/\s*\[siedeployer:.*?\]\s*\$conf\[\'baseurl\'\].*$)/m',
                "$1\n\$conf['baseurl'] = '" . addcslashes($newUrl, '\\\'$') . "';",
                $content,
                1
            );
        } else {
            // No existe baseurl, agregar al final
            $content = rtrim($content) . "\n\$conf['baseurl'] = '" . addcslashes($newUrl, '\\\'$') . "';\n";
        }

        if ($content !== $original) {
            // Backup antes de modificar
            $backup = $localPhp . '.bak.' . date('Ymd_His');
            @copy($localPhp, $backup);

            file_put_contents($localPhp, $content);
            return ['success' => true, 'old_url' => $oldUrl, 'new_url' => $newUrl, 'backup' => basename($backup)];
        }

        return ['success' => true, 'message' => 'No se requirio cambio en baseurl'];
    }

    /**
     * Elimina un subdominio de un dominio especifico.
     * Fase 2 de la migracion.
     */
    public function migrateDeleteSubdomain($subdomain, $sourceDomain)
    {
        $fullDomain = $subdomain . '.' . $sourceDomain;

        // cpapi2 SubDomain delsubdomain
        $apiResult = $this->_cpanelApiCall('cpapi2', 'SubDomain', 'delsubdomain', ['domain' => $fullDomain]);

        if ($apiResult['success']) {
            $cpError = $apiResult['data']['cpanelresult']['error'] ?? '';
            if (!empty($cpError)) {
                if (stripos($cpError, 'not exist') !== false || stripos($cpError, 'no existe') !== false) {
                    return ['success' => true, 'message' => 'Subdominio ya no existe: ' . $fullDomain];
                }
                return 'Error cPanel: ' . $cpError;
            }
            return ['success' => true, 'message' => 'Subdominio eliminado (' . $apiResult['method'] . '): ' . $fullDomain];
        }

        // Fallback: uapi
        $apiResult2 = $this->_cpanelApiCall('uapi', 'SubDomain', 'delete_subdomain', ['domain' => $fullDomain]);

        if ($apiResult2['success']) {
            return ['success' => true, 'message' => 'Subdominio eliminado via uapi (' . $apiResult2['method'] . '): ' . $fullDomain];
        }

        $raw = $apiResult2['raw'] ?? '';
        if (stripos($raw, 'not exist') !== false) {
            return ['success' => true, 'message' => 'Subdominio ya no existe: ' . $fullDomain];
        }

        return 'Error eliminando subdominio ' . $fullDomain . '. ' . ($apiResult2['error'] ?: $apiResult['error']);
    }

    /**
     * Solicita certificado SSL via AutoSSL para el usuario cPanel.
     */
    public function requestAutoSSL()
    {
        $cpUser = self::$cpanelUser;

        // whmapi1 start_autossl_check_for_one_user
        $apiResult = $this->_cpanelApiCall('whmapi1', '', 'start_autossl_check_for_one_user', ['username' => $cpUser]);

        if ($apiResult['success']) {
            return ['success' => true, 'message' => 'AutoSSL iniciado (' . $apiResult['method'] . ') para ' . $cpUser];
        }

        // Metodo alternativo: binario directo (solo funciona con exec())
        if ($this->_execAvailable()) {
            $output2 = [];
            $returnVar2 = -1;
            @exec('/usr/local/cpanel/bin/autossl_check --user=' . escapeshellarg($cpUser) . ' 2>&1', $output2, $returnVar2);

            if ($returnVar2 === 0) {
                return ['success' => true, 'message' => 'AutoSSL iniciado via binario directo'];
            }
        }

        return ['success' => false, 'message' => 'No se pudo iniciar AutoSSL automaticamente. ' . ($apiResult['error'] ?: 'Ejecutelo manualmente desde WHM > AutoSSL.')];
    }

    // --- Descarga de archivos actuales por paso ---

    /**
     * Descarga archivos actuales del sistema destino segun el paso indicado.
     *
     * @param int $deploymentId ID del despliegue
     * @param int $stepNumber   Numero del paso (6, 8, 9, 10)
     * @return array ['type'=>'zip'|'file', 'filename'=>string, 'content'=>string, 'mime'=>string] o ['error'=>string]
     */
    public function downloadStepFiles($deploymentId, $stepNumber)
    {
        $dep = $this->getDeployment($deploymentId);
        if (!$dep) {
            return ['error' => 'Despliegue no encontrado'];
        }

        $targetPath = rtrim($dep['target_path'], '/');
        if (!is_dir($targetPath)) {
            return ['error' => 'Directorio del sistema no existe: ' . $targetPath];
        }

        switch ($stepNumber) {
            case 6:
                return $this->_downloadAsZip($targetPath, [
                    'lib/images/smileys/logo.png',
                    'lib/images/smileys/logo.gif',
                ], 'logos_actuales.zip');

            case 8:
                return $this->_downloadAsZip($targetPath, [
                    'data/media/wiki/dokuwiki-128.png',
                    'data/media/wiki/favicon.ico',
                    'lib/tpl/20cones/favicon.ico',
                    'lib/tpl/21cones/favicon.ico',
                    'favicon.ico',
                ], 'imagenes_favicons_actuales.zip');

            case 9:
                return $this->_downloadSingleFile($targetPath, 'conf/acl.auth.php');

            case 10:
                return $this->_downloadSingleFile($targetPath, 'conf/users.auth.php');

            default:
                return ['error' => 'Paso no soportado para descarga: ' . $stepNumber];
        }
    }

    /**
     * Crea un ZIP en memoria con los archivos indicados.
     */
    protected function _downloadAsZip($basePath, $relativePaths, $zipName)
    {
        $tmpFile = tempnam(sys_get_temp_dir(), 'sd_zip_');
        $zip = new ZipArchive();
        if ($zip->open($tmpFile, ZipArchive::CREATE | ZipArchive::OVERWRITE) !== true) {
            @unlink($tmpFile);
            return ['error' => 'No se pudo crear archivo ZIP temporal'];
        }

        $added = 0;
        foreach ($relativePaths as $relPath) {
            $fullPath = $basePath . '/' . $relPath;
            if (file_exists($fullPath)) {
                $zip->addFile($fullPath, $relPath);
                $added++;
            }
        }

        if ($added === 0) {
            $zip->close();
            @unlink($tmpFile);
            return ['error' => 'No se encontro ningun archivo para descargar en el sistema destino'];
        }

        $zip->close();
        $content = file_get_contents($tmpFile);
        @unlink($tmpFile);

        return [
            'type'     => 'zip',
            'filename' => $zipName,
            'content'  => $content,
            'mime'     => 'application/zip',
        ];
    }

    /**
     * Descarga un archivo individual del sistema destino.
     */
    protected function _downloadSingleFile($basePath, $relativePath)
    {
        $fullPath = $basePath . '/' . $relativePath;
        if (!file_exists($fullPath)) {
            return ['error' => 'Archivo no encontrado: ' . $relativePath];
        }

        $content = file_get_contents($fullPath);
        $filename = basename($relativePath);

        return [
            'type'     => 'file',
            'filename' => $filename,
            'content'  => $content,
            'mime'     => 'application/octet-stream',
        ];
    }

    // --- Actualizaciones: Sistema Base y Parches ---

    /**
     * Verifica el contenido de /public_html/sistema/ o una subcarpeta.
     * @param string $subpath Subruta relativa dentro de sistema/ (ej: 'bin', 'lib/plugins')
     * @return array ['success'=>bool, 'items'=>array]
     */
    public function checkSistemaFolder($subpath = '')
    {
        $startTime = microtime(true);
        $sistemaPath = rtrim($this->getConf('server_base_path'), '/') . '/sistema';

        // Construir ruta completa con subcarpeta
        $targetPath = $sistemaPath;
        $displayPath = '/public_html/sistema/';
        if ($subpath !== '') {
            // Sanitizar: quitar .., barras iniciales, etc.
            $subpath = trim(str_replace('\\', '/', $subpath), '/');
            $subpath = preg_replace('/\.\./', '', $subpath);
            $subpath = preg_replace('#/+#', '/', $subpath);
            $targetPath = $sistemaPath . '/' . $subpath;
            $displayPath = '/public_html/sistema/' . $subpath . '/';
        }

        if (!is_dir($targetPath)) {
            return ['success' => false, 'error' => 'La carpeta no existe: ' . $displayPath];
        }

        $items = [];
        $dh = @opendir($targetPath);
        if ($dh === false) {
            return ['success' => false, 'error' => 'No se pudo leer la carpeta: ' . $displayPath];
        }

        while (($entry = readdir($dh)) !== false) {
            if ($entry === '.' || $entry === '..') continue;

            // Proteccion contra timeout (max 10 seg)
            if ((microtime(true) - $startTime) > 10) {
                $items[] = ['name' => '... (tiempo limite alcanzado)', 'type' => 'file', 'size' => ''];
                break;
            }

            $fullPath = $targetPath . '/' . $entry;
            $isDir = is_dir($fullPath);
            $item = ['name' => $entry, 'type' => $isDir ? 'dir' : 'file'];
            if ($isDir) {
                // Contar items con opendir (mas rapido que scandir para solo contar)
                $count = 0;
                $sdh = @opendir($fullPath);
                if ($sdh) {
                    while (($se = readdir($sdh)) !== false) {
                        if ($se !== '.' && $se !== '..') $count++;
                    }
                    closedir($sdh);
                }
                $item['count'] = $count;
            } else {
                $size = @filesize($fullPath);
                if ($size === false) {
                    $item['size'] = '?';
                } elseif ($size < 1024) {
                    $item['size'] = $size . ' B';
                } elseif ($size < 1048576) {
                    $item['size'] = round($size / 1024, 1) . ' KB';
                } else {
                    $item['size'] = round($size / 1048576, 1) . ' MB';
                }
            }
            $items[] = $item;
        }
        closedir($dh);

        // Ordenar: directorios primero, luego archivos
        usort($items, function ($a, $b) {
            if ($a['type'] !== $b['type']) return $a['type'] === 'dir' ? -1 : 1;
            return strcasecmp($a['name'], $b['name']);
        });

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

        return [
            'success' => true,
            'items'   => $items,
            'path'    => $targetPath,
            'display' => $displayPath,
            'subpath' => $subpath,
            'time'    => $elapsed . 's',
        ];
    }

    /**
     * Limpia todo el contenido de /public_html/sistema/.
     * @return array ['success'=>bool, 'message'=>string]
     */
    public function cleanSistemaFolder()
    {
        $sistemaPath = rtrim($this->getConf('server_base_path'), '/') . '/sistema';
        if (!is_dir($sistemaPath)) {
            return ['success' => false, 'error' => 'La carpeta sistema no existe: ' . $sistemaPath];
        }

        $deleted = false;

        // Intentar con exec() solo si esta disponible
        if ($this->_execAvailable()) {
            $escaped = escapeshellarg($sistemaPath);
            // Borrar contenido pero NO la carpeta misma (timeout 60 seg)
            $cmd = "timeout 60 find {$escaped} -mindepth 1 -delete 2>&1";
            @exec($cmd, $outArr, $exitCode);

            if ($exitCode !== 0) {
                $cmd2 = "timeout 60 rm -rf {$escaped} 2>&1 && mkdir -p {$escaped}";
                @exec($cmd2, $outArr2, $exitCode2);
                $deleted = ($exitCode2 === 0);
            } else {
                $deleted = true;
            }
        }

        // Fallback: PHP recursivo (si exec no disponible o fallo)
        if (!$deleted) {
            $this->_deleteContents($sistemaPath);
        }

        // Verificar que quedo vacia
        $remaining = @scandir($sistemaPath);
        $count = 0;
        if ($remaining) {
            foreach ($remaining as $r) {
                if ($r !== '.' && $r !== '..') $count++;
            }
        }

        if ($count === 0) {
            return ['success' => true, 'message' => 'Carpeta /public_html/sistema/ limpiada correctamente.'];
        } else {
            return ['success' => false, 'error' => 'No se pudieron eliminar todos los archivos. Quedan ' . $count . ' elementos.'];
        }
    }

    /**
     * Elimina recursivamente el contenido de una carpeta sin eliminar la carpeta misma.
     */
    protected function _deleteContents($dir)
    {
        $entries = @scandir($dir);
        if ($entries === false) return;

        foreach ($entries as $entry) {
            if ($entry === '.' || $entry === '..') continue;
            $path = $dir . '/' . $entry;
            if (is_dir($path)) {
                $this->_deleteContents($path);
                @rmdir($path);
            } else {
                @unlink($path);
            }
        }
    }

    /**
     * Sube un ZIP y lo descomprime en /public_html/sistema/.
     * Extrae archivo por archivo para mayor control y reporte.
     * @param array $file Datos del archivo subido ($_FILES['sistema_zip'])
     * @return array ['success'=>bool, 'message'=>string, 'details'=>array]
     */
    public function uploadSistemaZip($file)
    {
        $sistemaPath = rtrim($this->getConf('server_base_path'), '/') . '/sistema';
        if (!is_dir($sistemaPath)) {
            if (!@mkdir($sistemaPath, 0755, true)) {
                return ['success' => false, 'error' => 'No se pudo crear la carpeta: ' . $sistemaPath];
            }
        }

        $tmpFile = $file['tmp_name'];
        $zip = new ZipArchive();
        if ($zip->open($tmpFile) !== true) {
            return ['success' => false, 'error' => 'El archivo no es un ZIP valido'];
        }

        $numFiles = $zip->numFiles;

        // Listar directorios de primer nivel que vienen en el ZIP
        $zipTopDirs = [];
        for ($i = 0; $i < $numFiles; $i++) {
            $name = $zip->getNameIndex($i);
            $top = explode('/', $name)[0];
            if (!isset($zipTopDirs[$top])) $zipTopDirs[$top] = 0;
            $zipTopDirs[$top]++;
        }

        // Extraer archivo por archivo para mayor control
        $extractOk = 0;
        $extractFail = [];
        for ($i = 0; $i < $numFiles; $i++) {
            $entryName = $zip->getNameIndex($i);
            // Crear directorio destino si no existe
            $destPath = $sistemaPath . '/' . $entryName;
            $destDir = dirname($destPath);
            if (!is_dir($destDir)) {
                @mkdir($destDir, 0755, true);
            }
            // Si es una entrada de directorio (termina en /), solo crear la carpeta
            if (substr($entryName, -1) === '/') {
                if (!is_dir($destPath)) {
                    @mkdir($destPath, 0755, true);
                }
                $extractOk++;
                continue;
            }
            // Extraer archivo individual
            $content = $zip->getFromIndex($i);
            if ($content !== false) {
                if (@file_put_contents($destPath, $content) !== false) {
                    $extractOk++;
                } else {
                    $extractFail[] = $entryName . ' (no se pudo escribir)';
                }
            } else {
                $extractFail[] = $entryName . ' (no se pudo leer del ZIP)';
            }
        }
        $zip->close();

        // Directorios criticos que DEBEN existir despues de la extraccion
        $criticalDirs = ['bin', 'conf', 'inc', 'lib', 'vendor'];
        $missing = [];
        $present = [];
        foreach ($criticalDirs as $cd) {
            $cdPath = $sistemaPath . '/' . $cd;
            if (is_dir($cdPath)) {
                $entries = @scandir($cdPath);
                $count = $entries ? count(array_diff($entries, ['.', '..'])) : 0;
                $present[] = $cd . '/ (' . $count . ' items)';
            } else {
                $missing[] = $cd . '/';
            }
        }

        // Construir respuesta detallada
        $message = "ZIP procesado: $extractOk/$numFiles entradas extraidas en /public_html/sistema/.";
        if (!empty($extractFail)) {
            $message .= ' ERRORES: ' . count($extractFail) . ' archivos fallaron.';
        }

        $details = [
            'total_en_zip'   => $numFiles,
            'extraidos_ok'   => $extractOk,
            'errores'        => $extractFail,
            'dirs_en_zip'    => $zipTopDirs,
            'dirs_criticos'  => [
                'presentes' => $present,
                'faltantes' => $missing,
            ],
        ];

        $success = empty($extractFail) && empty($missing);
        if (!empty($missing)) {
            $message .= ' ADVERTENCIA: Faltan directorios criticos: ' . implode(', ', $missing);
        }

        return [
            'success' => $success || $extractOk > 0, // Exito parcial si algo se extrajo
            'message' => $message,
            'details' => $details,
            'warn'    => !empty($missing) || !empty($extractFail),
        ];
    }

    /**
     * Nombre del directorio temporal de parches dentro de server_base_path (public_html).
     */
    const PATCH_TMP_DIR = '_siedeployer_patch';

    /**
     * Mapeo de nombres de parches a carpetas y archivo de ejecucion.
     */
    protected static $patchDefs = [
        'limpieza'      => ['dir' => 'limpieza',      'file' => 'limpiar.php'],
        'parche_masivo' => ['dir' => 'parche_masivo',  'file' => 'copiar.php'],
        'tokens'        => ['dir' => 'tokens',         'file' => 'copiar.php'],
        'mantenimiento' => ['dir' => 'mantenimiento',  'file' => 'activar.php'],
        'backup'        => ['dir' => 'backup',         'file' => 'copiar.php'],
        'fix_home'      => ['dir' => 'fix_home',       'file' => 'aplicar.php'],
    ];

    /**
     * Despliega un parche a un directorio temporal web-accesible y devuelve su URL.
     *
     * Los parches son aplicaciones web interactivas (HTML + AJAX) que el usuario
     * debe abrir en el navegador. Se colocan en la raiz de public_html
     * (server_base_path) para operar sobre TODOS los subdominios.
     * URL accesible via rootDomain (ej: https://systemsie.site/_siedeployer_patch/).
     *
     * @param string $patchName Nombre del parche
     * @param array  $options   Opciones (action, exclude_list)
     * @return array ['success'=>bool, 'url'=>string, 'log'=>array] o ['error'=>string]
     */
    public function deployPatch($patchName, $options = [])
    {
        // Sanitizar
        $patchName = preg_replace('/[^a-z0-9_]/', '', strtolower($patchName));

        if (!isset(self::$patchDefs[$patchName])) {
            return ['success' => false, 'error' => 'Parche desconocido: ' . $patchName];
        }

        $def = self::$patchDefs[$patchName];
        $patchDir = dirname(__FILE__) . '/patches/' . $def['dir'];
        if (!is_dir($patchDir)) {
            return ['success' => false, 'error' => 'Carpeta del parche no encontrada: patches/' . $def['dir'] . '/'];
        }

        // Determinar archivo a ejecutar (mantenimiento tiene activar/desactivar)
        $executeFile = $def['file'];
        if ($patchName === 'mantenimiento') {
            $action = isset($options['action']) ? $options['action'] : 'activar';
            $executeFile = ($action === 'desactivar') ? 'desactivar.php' : 'activar.php';
        }

        // Ruta destino: en la raiz de public_html (server_base_path)
        // Los parches necesitan operar desde la raiz para escanear TODOS los subdominios
        $realBasePath = rtrim($this->getConf('server_base_path'), '/');
        if (empty($realBasePath) || !is_dir($realBasePath)) {
            $realBasePath = rtrim($this->getBasePath(), '/');
        }
        $patchDestDir = $realBasePath . '/' . self::PATCH_TMP_DIR;
        $log = [];
        $log[] = 'server_base_path config: ' . $this->getConf('server_base_path');
        $log[] = 'realBasePath resuelto: ' . $realBasePath;
        $log[] = 'patchDestDir: ' . $patchDestDir;

        // Crear carpeta destino
        if (!is_dir($patchDestDir)) {
            if (!@mkdir($patchDestDir, 0755, true)) {
                return ['success' => false, 'error' => 'No se pudo crear directorio temporal: ' . $patchDestDir];
            }
        }

        // Copiar archivos del parche
        $patchFiles = @scandir($patchDir);
        if (!$patchFiles) {
            return ['success' => false, 'error' => 'No se pudieron leer los archivos del parche'];
        }

        $copiedFiles = [];
        foreach ($patchFiles as $pf) {
            if ($pf === '.' || $pf === '..') continue;
            $src = $patchDir . '/' . $pf;
            $dst = $patchDestDir . '/' . $pf;
            if (is_file($src)) {
                if (@copy($src, $dst)) {
                    $copiedFiles[] = $pf;
                    $log[] = 'Copiado: ' . $pf;
                } else {
                    $log[] = 'ERROR copiando: ' . $pf;
                }
            } elseif (is_dir($src)) {
                $this->_copyDir($src, $dst);
                $copiedFiles[] = $pf . '/';
                $log[] = 'Copiado directorio: ' . $pf . '/';
            }
        }

        if (empty($copiedFiles)) {
            return ['success' => false, 'error' => 'No se copiaron archivos del parche', 'log' => $log];
        }

        // === Garantizar .htaccess funcional ===
        // RewriteEngine Off evita que DokuWiki intercepte URLs del parche
        // php_value dentro de IfModule evita 500 en PHP-FPM
        $htaccessDst = $patchDestDir . '/.htaccess';
        $htContent  = "# Auto-generado por siedeployer\n";
        $htContent .= "RewriteEngine Off\n\n";
        $htContent .= "<IfModule mod_php.c>\n";
        $htContent .= "    php_value max_execution_time 400\n";
        $htContent .= "    php_value memory_limit 256M\n";
        $htContent .= "    php_value output_buffering Off\n";
        $htContent .= "    php_value zlib.output_compression Off\n";
        $htContent .= "</IfModule>\n";
        $htContent .= "<IfModule mod_php7.c>\n";
        $htContent .= "    php_value max_execution_time 400\n";
        $htContent .= "    php_value memory_limit 256M\n";
        $htContent .= "    php_value output_buffering Off\n";
        $htContent .= "    php_value zlib.output_compression Off\n";
        $htContent .= "</IfModule>\n\n";
        $htContent .= "# Proteger archivos sensibles\n";
        $htContent .= "<FilesMatch \"(password\\.txt|progreso\\.json|backup\\.log)$\">\n";
        $htContent .= "    Order Allow,Deny\n";
        $htContent .= "    Deny from all\n";
        $htContent .= "</FilesMatch>\n";
        file_put_contents($htaccessDst, $htContent);
        $log[] = '.htaccess creado con RewriteEngine Off (PHP-FPM compatible)';

        // === Actualizar rutas en los archivos copiados ===
        // $realBasePath ya fue definido arriba para $patchDestDir

        // 1) config.php: SEARCH_PATH y SEARCH_PATHS
        $configFile = $patchDestDir . '/config.php';
        if (file_exists($configFile)) {
            $configContent = file_get_contents($configFile);
            // SEARCH_PATH (string)
            $configContent = preg_replace(
                "/define\s*\(\s*'SEARCH_PATH'\s*,\s*'[^']*'\s*\)/",
                "define('SEARCH_PATH', '" . addslashes($realBasePath) . "')",
                $configContent
            );
            // SEARCH_PATHS (array) para mantenimiento
            $configContent = preg_replace(
                "/define\s*\(\s*'SEARCH_PATHS'\s*,\s*\[.*?\]\s*\)/s",
                "define('SEARCH_PATHS', [\n    '" . addslashes($realBasePath) . "',\n])",
                $configContent
            );
            file_put_contents($configFile, $configContent);
            $log[] = 'SEARCH_PATH actualizado a: ' . $realBasePath;
        }

        // 2) activar.php: tiene $RUTA hardcodeada (no usa config.php)
        $activarFile = $patchDestDir . '/activar.php';
        if (file_exists($activarFile)) {
            $activarContent = file_get_contents($activarFile);
            $activarContent = preg_replace(
                "/\\$RUTA\s*=\s*'[^']*'/",
                "\$RUTA = '" . addslashes($realBasePath) . "'",
                $activarContent
            );
            file_put_contents($activarFile, $activarContent);
            $log[] = '$RUTA actualizada en activar.php';
        }

        // 3) parche_masivo: actualizar SOURCE_ZIP, SOURCE_DIR y exclusiones
        if ($patchName === 'parche_masivo' && file_exists($configFile)) {
            $configContent = file_get_contents($configFile);

            // SOURCE_ZIP: apuntar a server_base_path/sistema.zip
            $configContent = preg_replace(
                "/define\s*\(\s*'SOURCE_ZIP'\s*,\s*[^)]+\)/",
                "define('SOURCE_ZIP', '" . addslashes($realBasePath) . "/sistema.zip')",
                $configContent
            );
            // SOURCE_DIR: apuntar a server_base_path/sistema (donde el deployer extrae)
            $configContent = preg_replace(
                "/define\s*\(\s*'SOURCE_DIR'\s*,\s*[^)]+\)/",
                "define('SOURCE_DIR', '" . addslashes($realBasePath) . "/sistema')",
                $configContent
            );
            file_put_contents($configFile, $configContent);
            $log[] = 'SOURCE_ZIP/SOURCE_DIR actualizados a: ' . $realBasePath . '/sistema[.zip]';

            // Exclusiones opcionales
            if (!empty($options['exclude_list'])) {
                $configContent = file_get_contents($configFile);
                $excludes = array_map('trim', explode(',', $options['exclude_list']));
                $excludePhp = "'" . implode("',\n    '", array_map('addslashes', $excludes)) . "'";
                $configContent = preg_replace(
                    '/\$EXCLUSION_LIST\s*=\s*array\s*\(.*?\);/s',
                    '$EXCLUSION_LIST = array(' . "\n    " . $excludePhp . "\n" . ');',
                    $configContent
                );
                file_put_contents($configFile, $configContent);
                $log[] = 'Exclusiones configuradas: ' . $options['exclude_list'];
            }
        }

        // Construir URL accesible via dominio raiz (el parche vive en public_html root)
        $protocol = (!empty($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off') ? 'https' : 'http';
        $patchUrl = $protocol . '://' . self::$rootDomain . '/' . self::PATCH_TMP_DIR . '/' . $executeFile;
        $log[] = 'URL del parche: ' . $patchUrl;
        $log[] = 'Destino fisico: ' . $patchDestDir;

        return [
            'success' => true,
            'url'     => $patchUrl,
            'message' => 'Parche "' . $patchName . '" desplegado. Abra la URL para ejecutarlo.',
            'log'     => $log,
        ];
    }

    /**
     * Elimina el directorio temporal de parches.
     * @return array ['success'=>bool, 'message'=>string]
     */
    public function cleanupPatch()
    {
        $cleaned = 0;
        $errors = [];

        // 1. Limpiar ubicacion correcta (server_base_path root)
        $realBasePath = rtrim($this->getConf('server_base_path'), '/');
        if (empty($realBasePath) || !is_dir($realBasePath)) {
            $realBasePath = rtrim($this->getBasePath(), '/');
        }
        $patchDestDir = $realBasePath . '/' . self::PATCH_TMP_DIR;

        if (is_dir($patchDestDir)) {
            $this->_deleteContents($patchDestDir);
            @rmdir($patchDestDir);
            if (is_dir($patchDestDir)) {
                $errors[] = 'No se pudo eliminar: ' . $patchDestDir;
            } else {
                $cleaned++;
            }
        }

        // 2. Limpiar ubicacion antigua (DOKU_INC) por compatibilidad
        $oldPatchDir = DOKU_INC . self::PATCH_TMP_DIR;
        if (is_dir($oldPatchDir) && $oldPatchDir !== $patchDestDir) {
            $this->_deleteContents($oldPatchDir);
            @rmdir($oldPatchDir);
            if (!is_dir($oldPatchDir)) {
                $cleaned++;
            }
        }

        if (!empty($errors)) {
            return ['success' => false, 'error' => implode('; ', $errors)];
        }

        if ($cleaned === 0) {
            return ['success' => true, 'message' => 'No hay archivos de parche para limpiar.'];
        }

        return ['success' => true, 'message' => 'Archivos de parche eliminados correctamente.'];
    }

    /**
     * Mantiene compatibilidad - redirige a deployPatch.
     * @deprecated Usar deployPatch() en su lugar
     */
    public function runPatch($patchName, $options = [])
    {
        return $this->deployPatch($patchName, $options);
    }

    /**
     * Copia recursivamente un directorio.
     */
    protected function _copyDir($src, $dst)
    {
        if (!is_dir($dst)) {
            @mkdir($dst, 0755, true);
        }
        $entries = @scandir($src);
        if (!$entries) return;

        foreach ($entries as $entry) {
            if ($entry === '.' || $entry === '..') continue;
            $srcPath = $src . '/' . $entry;
            $dstPath = $dst . '/' . $entry;
            if (is_dir($srcPath)) {
                $this->_copyDir($srcPath, $dstPath);
            } else {
                @copy($srcPath, $dstPath);
            }
        }
    }

    // --- Helpers internos para migracion ---

    /**
     * Resuelve la ruta absoluta de un directorio de subdominio.
     */
    protected function _resolveSubdomainDir($dir, $cpUser)
    {
        if (strpos($dir, '/') === 0) {
            return $dir;
        }
        return '/home/' . $cpUser . '/' . ltrim($dir, '/');
    }

    /**
     * Lee el titulo del sistema desde conf/local.php.
     */
    protected function _readSystemTitle($fullDir)
    {
        $localPhp = $fullDir . '/conf/local.php';
        if (!file_exists($localPhp)) return '';

        $content = @file_get_contents($localPhp);
        if ($content && preg_match('/\$conf\s*\[\s*[\'"]title[\'"]\s*\]\s*=\s*\'([^\']*)\'/s', $content, $m)) {
            return $m[1];
        }
        return '';
    }

    // =========================================================================
    //  UTILIDADES DE FILESYSTEM
    // =========================================================================

    protected function deleteDirectoryContents($dir)
    {
        if (!is_dir($dir)) return;

        $items = @scandir($dir);
        if (!$items) return;

        foreach ($items as $item) {
            if ($item === '.' || $item === '..') continue;

            $path = $dir . '/' . $item;

            if (is_dir($path)) {
                $this->deleteDirectoryRecursive($path);
            } else {
                @unlink($path);
            }
        }
    }

    protected function deleteDirectoryRecursive($dir)
    {
        if (!is_dir($dir)) return true;

        $items = @scandir($dir);
        if ($items) {
            foreach ($items as $item) {
                if ($item === '.' || $item === '..') continue;

                $path = $dir . '/' . $item;

                if (is_dir($path)) {
                    $this->deleteDirectoryRecursive($path);
                } else {
                    @unlink($path);
                }
            }
        }

        return @rmdir($dir);
    }

    protected function cleanDirectoryKeepFiles($dir, $keepFiles)
    {
        if (!is_dir($dir)) return;

        $items = @scandir($dir);
        if (!$items) return;

        foreach ($items as $item) {
            if ($item === '.' || $item === '..') continue;

            $path = $dir . '/' . $item;

            if (is_dir($path)) {
                $this->deleteDirectoryRecursive($path);
            } elseif (!in_array($item, $keepFiles)) {
                @unlink($path);
            }
        }
    }

    // =========================================================================
    //  AJUSTE INDIVIDUAL - Copia protegida desde /sistema/ a un subdominio
    // =========================================================================

    /** Directorios protegidos (se saltan completos, rutas relativas al subdominio) */
    protected static $individualProtectedDirs = [
        'data/media',
        'data/pages',
    ];

    /** Archivos protegidos (no se sobrescriben, rutas relativas al subdominio) */
    protected static $individualProtectedFiles = [
        'data/meta/b3p.sqlite3',
        'data/meta/do.sqlite3',
        'data/meta/struct.sqlite3',
        'favicon.ico',
        'lib/images/smileys/logo.gif',
        'lib/images/smileys/logo.png',
        'conf/acl.auth.php',
        'conf/local.php',
        'conf/users.auth.php',
        'lib/tpl/20cones/favicon.ico',
        'lib/tpl/20cones/images/apple-touch-icon.png',
        'lib/tpl/20cones/images/dokuwiki-128.png',
        'lib/tpl/20cones/images/favicon.ico',
        'lib/tpl/20cones/images/logo_sie_asesorias.png',
        'lib/tpl/20cones/images/header-gradient.png',
        'lib/tpl/20cones/images/icon.png',
        'lib/tpl/20cones/images/logo.png',
        'lib/tpl/20cones/images/page-gradient.png',
        'lib/tpl/21cones/favicon.ico',
        'lib/tpl/21cones/icon.png',
        'lib/tpl/21cones/images/apple-touch-icon.png',
        'lib/tpl/21cones/images/dokuwiki-128.png',
        'lib/tpl/21cones/images/logo_sie_asesorias.png',
        'lib/tpl/21cones/images/favicon.ico',
        'lib/tpl/21cones/images/header-gradient.png',
        'lib/tpl/21cones/images/icon.png',
        'lib/tpl/21cones/images/logo.png',
        'lib/tpl/21cones/images/page-gradient.png',
        'lib/plugins/bez/tpl/thread_report.php',
        'lib/plugins/bez/tpl/thread.php',
        'lib/plugins/bez/tpl/thread_box.php',
        'lib/plugins/bez/meta/ajax_handler.php',
        'lib/plugins/bez/meta/crear_paginas_proyecto.php',
        'lib/plugins/niccproyectos/images/logo_sie_asesorias.png',
        'data/media/wiki/dokuwiki-128.png',
        'data/media/wiki/favicon.ico',
    ];

    /**
     * Aplica ajuste individual: copia archivos de /sistema/ a un subdominio
     * protegiendo archivos y directorios propios del cliente.
     *
     * @param string $targetPath Ruta absoluta del subdominio destino
     * @return array Resultado con success, message, stats, log
     */
    public function applyIndividualUpdate($targetPath)
    {
        // 1. Validar ruta destino
        $targetPath = rtrim($targetPath, '/');
        if (!is_dir($targetPath)) {
            return ['success' => false, 'error' => 'Ruta destino no existe: ' . $targetPath];
        }
        // Detectar DokuWiki: conf/local.php o conf/dokuwiki.php + data/
        // (doku.php puede no existir si el subdominio ya fue limpiado)
        $hasConf = file_exists($targetPath . '/conf/local.php')
                || file_exists($targetPath . '/conf/dokuwiki.php');
        $hasData = is_dir($targetPath . '/data');
        if (!$hasConf || !$hasData) {
            return ['success' => false, 'error' => 'No es un sitio DokuWiki válido: ' . $targetPath];
        }

        // 2. Obtener ruta de sistema
        $sistemaPath = rtrim($this->getConf('server_base_path'), '/') . '/sistema';
        if (!is_dir($sistemaPath)) {
            // Fallback: intentar con getBasePath()
            $sistemaPath = rtrim($this->getBasePath(), '/') . '/sistema';
        }
        if (!is_dir($sistemaPath)) {
            return ['success' => false, 'error' => 'Carpeta sistema no encontrada: ' . $sistemaPath];
        }

        // 3. Verificar que sistema tiene contenido
        $entries = @scandir($sistemaPath);
        if (!$entries || count($entries) <= 2) {
            return ['success' => false, 'error' => 'Carpeta sistema está vacía: ' . $sistemaPath];
        }

        // 4. Ejecutar copia protegida
        $log = [];
        $stats = ['copied' => 0, 'skipped' => 0, 'errors' => 0, 'dirs_created' => 0];

        $log[] = "Origen: $sistemaPath";
        $log[] = "Destino: $targetPath";
        $log[] = "Dirs protegidos: " . count(self::$individualProtectedDirs);
        $log[] = "Archivos protegidos: " . count(self::$individualProtectedFiles);
        $log[] = "---";

        $this->_copyDirProtected(
            $sistemaPath,
            $targetPath,
            '',
            self::$individualProtectedDirs,
            self::$individualProtectedFiles,
            $log,
            $stats
        );

        $log[] = "---";
        $log[] = "Copiados: {$stats['copied']} | Protegidos: {$stats['skipped']} | Errores: {$stats['errors']} | Dirs creados: {$stats['dirs_created']}";

        $siteName = basename($targetPath);
        return [
            'success' => true,
            'message' => "Ajuste individual aplicado a {$siteName}",
            'stats'   => $stats,
            'log'     => $log,
        ];
    }

    /**
     * Copia recursiva con exclusiones de directorios y archivos protegidos.
     *
     * @param string $src        Ruta origen absoluta
     * @param string $dst        Ruta destino absoluta
     * @param string $relPath    Ruta relativa acumulada (empieza vacío)
     * @param array  $protDirs   Lista de directorios protegidos (rutas relativas)
     * @param array  $protFiles  Lista de archivos protegidos (rutas relativas)
     * @param array  &$log       Array de mensajes de log
     * @param array  &$stats     Array de contadores (copied, skipped, errors, dirs_created)
     */
    protected function _copyDirProtected($src, $dst, $relPath, $protDirs, $protFiles, &$log, &$stats)
    {
        $entries = @scandir($src);
        if (!$entries) return;

        // Crear directorio destino si no existe
        if (!is_dir($dst)) {
            if (@mkdir($dst, 0755, true)) {
                $stats['dirs_created']++;
            } else {
                $log[] = "[ERROR] No se pudo crear directorio: $dst";
                $stats['errors']++;
                return;
            }
        }

        foreach ($entries as $entry) {
            if ($entry === '.' || $entry === '..') continue;

            $srcPath = $src . '/' . $entry;
            $dstPath = $dst . '/' . $entry;
            $currentRel = ($relPath !== '') ? $relPath . '/' . $entry : $entry;

            if (is_dir($srcPath)) {
                // Verificar si es un directorio protegido
                if (in_array($currentRel, $protDirs)) {
                    $log[] = "[PROTEGIDO DIR] $currentRel";
                    $stats['skipped']++;
                    continue;
                }
                // Recurrir al subdirectorio
                $this->_copyDirProtected($srcPath, $dstPath, $currentRel, $protDirs, $protFiles, $log, $stats);
            } else {
                // Verificar si es un archivo protegido
                if (in_array($currentRel, $protFiles)) {
                    $log[] = "[PROTEGIDO] $currentRel";
                    $stats['skipped']++;
                    continue;
                }
                // Copiar archivo
                if (@copy($srcPath, $dstPath)) {
                    $stats['copied']++;
                } else {
                    $log[] = "[ERROR] No se pudo copiar: $currentRel";
                    $stats['errors']++;
                }
            }
        }
    }
}
