<?php

namespace Intouch\Framework\OfficeHelper;

use DateInterval;
use DateTime;
use Intouch\Framework\Core\FileHelper;
use Intouch\Framework\Dates\Date;
use RuntimeException;
use stdClass;

/**
 * Clase para manejar la lectura de archivos Excel grandes de forma eficiente en memoria.
 * Requiere una versión de PhpSpreadsheet que soporte el método ListWorksheetInfo() para optimización (ej. 1.13.0+).
 */
class BigExcel {

    private string $DefaultTemplate             = __DIR__ . "/ExcelTemplate";
    private string $Filename                    = '';
    private string $Extension                   = '';
    private int    $RowsIgnored                 = 0;
    private bool   $FieldRowExists              = true;
    private bool   $UseDefultTemplate           = false;
    private string $DtoName                     = '';
    private array  $FieldNames                  = [];
    private int    $MaxLen                      = 0; // Cambiado a int y valor inicial 0
    private string $SharedStringsFixedLenFile   = '';
    private string $FinalRowsFile               = '';
    private string $FinalCombinedFile           = '';
    private string $workingFolder               = ''; // Para la limpieza de archivos temporales

    // Nuevas propiedades para manejar SharedStrings en disco
    private string $sharedStringsIndexFile      = ''; // Archivo temporal para índice string => index
    private string $sharedStringsDataFile       = ''; // Archivo temporal para datos ordenados
    private int    $sharedStringsCount          = 0;  // Contador de strings únicos

    /**
     * Constructor privado para forzar el uso del método estático FromFile.
     * Valida que la versión/funcionalidad de PhpSpreadsheet sea compatible con el procesamiento eficiente.
     *
     * @param string $filename Ruta al archivo Excel.
     * @param string $dtoName Nombre completamente cualificado de la clase DTO a la que mapear cada fila.
     * @param string $extension Extensión del archivo (ej. 'Xlsx', 'Csv').
     * @param int $rowsIgnored Número de filas a ignorar al inicio (ej. cabecera).
     * @throws \RuntimeException Si la funcionalidad requerida de PhpSpreadsheet no está disponible.
     */
    private function __construct(string $filename, string $dtoName, string $extension, int $rowsIgnored, bool $fieldRowExists, bool $useDefaultTemplate = false)
    {        
        if ($useDefaultTemplate)
            $filename = $this->DefaultTemplate;
        
        $this->Filename             = $filename;
        $this->DtoName              = $dtoName;
        $this->Extension            = $extension;
        $this->RowsIgnored          = $rowsIgnored;
        $this->FieldRowExists       = $fieldRowExists;
        $this->UseDefultTemplate    = $useDefaultTemplate;

        // Procesamos el archivo para extraer las hojas en xml
        if (!file_exists($filename)) {
            throw new RuntimeException("El archivo no está disponible o no se ha cargado correctamente");
        }

        // Establecer la codificación interna a UTF-8 para funciones multi-byte (si mbstring está habilitado)
        // Esto es crucial para que las operaciones de cadena manejen correctamente los caracteres UTF-8.
        if (extension_loaded('mbstring')) {
            mb_internal_encoding("UTF-8");
            mb_regex_encoding("UTF-8");
        } else {
            error_log("Advertencia: La extensión 'mbstring' no está habilitada. Podría haber problemas con caracteres multi-byte.");
        }

        if ($this->UseDefultTemplate)
            $this->ConfigurarXlsxTemplate();
        else
            $this->ConfigurarXlsx($filename);
    }

    /**
     * Crea una instancia de la clase BigExcel, preparando el lector para el archivo.
     *
     * @param string $filename Ruta al archivo Excel.
     * @param string $dtoName Nombre completamente cualificado de la clase DTO a la que mapear cada fila.
     * @param string $extension Extensión del archivo (ej. 'Xlsx', 'Csv').
     * @param int $rowsIgnored Número de filas a ignorar al inicio (ej. cabecera).
     * @param bool $fieldRowExists Indica si la primera fila contiene los nombres de los campos.
     * @return BigExcel
     * @throws \InvalidArgumentException Si el archivo no existe.
     * @throws \RuntimeException Si la funcionalidad de PhpSpreadsheet es incompatible (llamada al constructor).
     */
    public static function FromFile(string $filename, string $dtoName, string $extension = 'Xlsx', int $rowsIgnored = 1, bool $fieldRowExists = true): BigExcel
    {
        if (!file_exists($filename)) {
            throw new \InvalidArgumentException("El archivo Excel no existe en la ruta: " . $filename);
        }

        return new self($filename, $dtoName, $extension, $rowsIgnored, $fieldRowExists);
    }

    /**
     * Crea una instancia de la clase BigExcel, preparando el escritor para el archivo.
     *
     * @return BigExcel
     * @throws \InvalidArgumentException Si el archivo no existe.
     * @throws \RuntimeException Si la funcionalidad de PhpSpreadsheet es incompatible (llamada al constructor).
     */
    public static function FromDefaultTemplate(): BigExcel
    {
        return new self("", "", "Xlsx", 0, true, true);
    }


    private function ConfigurarXlsx($filename)
    {
        $excelInfo = $this->DescomprimirXlsx($filename);
        $this->workingFolder = $excelInfo->WorkingFolder; // Guardar la carpeta de trabajo para limpieza
        if (!$this->UseDefultTemplate)
            $excelInfo = $this->PrepararHojas($excelInfo);
    }

    private function ConfigurarXlsxTemplate()
    {
        $excelInfo = $this->CopyFolderTemplate();
        $this->workingFolder = $excelInfo->WorkingFolder; // Guardar la carpeta de trabajo para limpieza
    }  

    /**
     * Descomprime un archivo XLSX en una carpeta temporal única,
     * ubicada en el mismo directorio que el XLSX original, y devuelve
     * un objeto con información detallada de la descompresión.
     *
     * NOTA: Esta función crea una carpeta temporal. Eres responsable
     * de limpiar esta carpeta ($info->WorkingFolder) después de usarla.
     * Se recomienda usar un bloque try...finally en tu código principal
     * para asegurar la limpieza, llamando a FileHelper::removeDir().
     *
     * @param string $filename La ruta completa al archivo XLSX.
     * @return ExcelUnzipInfo Un objeto que contiene la ruta de la carpeta de trabajo,
     * la ruta de la carpeta 'xl', y los nombres de archivo de cada hoja XML.
     * @throws RuntimeException Si el archivo XLSX no existe, la carpeta no se puede crear, o la descompresión falla.
     */
    function DescomprimirXlsx(string $filename): ExcelUnzipInfo {

        // 1. Validar que el archivo XLSX exista.
        if (!file_exists($filename)) {
            throw new RuntimeException("Error: El archivo XLSX no existe en la ruta: " . $filename);
        }

        // 2. Determinar la ruta de la carpeta de trabajo temporal única.
        $path = (object)pathinfo($filename, PATHINFO_ALL);

        $excelDirectory = $path->dirname; // pathinfo($filename, PATHINFO_DIRNAME);
        $workingFolder = $excelDirectory . DIRECTORY_SEPARATOR . $path->filename . '_' . uniqid();

        // 3. Crear la carpeta temporal. ¡Este paso es crucial!
        if (!mkdir($workingFolder, 0777, true)) {
            throw new RuntimeException("No se pudo crear la carpeta temporal: " . $workingFolder . ". Verifique los permisos.");
        }

        // 4. Escapar las rutas para el comando shell.
        $escapedFilename = escapeshellarg($filename);
        $escapedWorkingFolder = escapeshellarg($workingFolder);

        // 5. Construir y ejecutar el comando unzip.
        $command = "unzip -o {$escapedFilename} -d {$escapedWorkingFolder}";

        $output = [];
        $return_var = 0;
        exec($command, $output, $return_var);

        // 6. Verificar el resultado de la descompresión.
        if ($return_var !== 0) {
            // Si falla la descompresión, intentar limpiar la carpeta que se creó.
            FileHelper::removeDir($workingFolder); // <--- ¡Llamada correcta a tu método estático!
            $errorMsg = "Error al descomprimir el archivo XLSX. Código de retorno: {$return_var}. Salida: " . implode("\n", $output);
            throw new RuntimeException($errorMsg);
        }

        // 7. Recopilar la información para el objeto ExcelUnzipInfo.
        $xlFolder = $workingFolder . DIRECTORY_SEPARATOR . 'xl';
        $sharedStringsFilename = $xlFolder . "/sharedStrings.xml";
        $sheetXmlsDir = $xlFolder . DIRECTORY_SEPARATOR . 'worksheets';
        $sheetFilenames = [];
        $rowsFilename = []; // Inicializar para evitar errores si no hay hojas

        // Encontrar los archivos XML que constituyen las hojas del excel
        if (is_dir($sheetXmlsDir)) {
            $files = scandir($sheetXmlsDir);
            foreach ($files as $file) {

                $path = (object)pathinfo($file, PATHINFO_ALL);

                // Asegurarse de que sea un archivo XML y no un directorio o '.'/'..'
                if (isset($path->extension) && $path->extension === 'xml' && is_file($sheetXmlsDir . DIRECTORY_SEPARATOR . $file)) {
                    $fullSheetPath = $sheetXmlsDir . DIRECTORY_SEPARATOR . $file;
                    // Intentar leer las primeras líneas o buscar la etiqueta <worksheet>
                    // Para archivos grandes, es mejor no leer todo el contenido.
                    // Usamos file_get_contents con un límite para verificar la cabecera.
                    $fileContentPreview = file_get_contents($fullSheetPath, false, null, 0, 1024); // Leer los primeros 1KB

                    if ($fileContentPreview !== false && str_contains($fileContentPreview, '<worksheet')) {
                        $sheetFilenames[$path->filename] = $fullSheetPath;
                        $rowsFilename[$path->filename] = '';
                    }
                }
            }
        }

        // 8. Crear y devolver el objeto ExcelUnzipInfo.
        return new ExcelUnzipInfo(
            $filename,
            $workingFolder,
            $xlFolder,
            $sharedStringsFilename,
            $sheetFilenames,
            $rowsFilename
        );
    }


    function CmdGetDimension(string $escapedSheetPath, string $escapedFinalRowsPath): string {
        // --- 1. Extraer los datos de <dimension> ---
        // Buscamos la primera ocurrencia de <dimension ref="..." en todo el archivo.
        // shell_exec devuelve la salida completa del comando.
        $commandDimension = "grep -oP '<dimension ref=\"\\K[^\"]+' {$escapedSheetPath} | head -n 1";
        $dimension = shell_exec($commandDimension);
        return trim((string)$dimension); // Asegurar que el resultado sea string
    }

    function CmdProcessRows(string $escapedSheetPath, string $escapedFinalRowsPath): void {

        $commandProcessRows = sprintf(
            'sed "s|<row|\\n<row|g; s|</sheetData>|\\n</sheetData>|g" %s | grep \'<row\' > %s',
            $escapedSheetPath,
            $escapedFinalRowsPath
        );

        $output = [];
        $return_var = 0;
        exec($commandProcessRows, $output, $return_var);

        if ($return_var !== 0) {
            // No limpiar $tempSheetPath aquí porque el proceso de filas no lo crea,
            // sino que es un archivo temporal dentro de PrepaprarHojas
            throw new RuntimeException("Error al procesar/filtrar las etiquetas <row> para " . basename($escapedSheetPath) . ". Salida: " . implode("\n", $output));
        }
    }

    function CmdProcessFixedLen(ExcelUnzipInfo $excelUnzipInfo): void { // Modificado para solo recibir ExcelUnzipInfo
        $sharedStringsFilename      = $excelUnzipInfo->SharedStringsFilename;
        // El archivo .vars ya no es necesario, podemos procesar directamente
        $sharedStringsFixedLenFile  = $sharedStringsFilename . ".vars_fixedlen";

        // Paso 1: Extraer solo las etiquetas <si> y su contenido.
        // Usamos xmllint para ser más robustos con XML, si está disponible.
        // Si xmllint no está disponible, se puede usar grep/sed como fallback.
        // Para asegurar que el "largo" sea solo el contenido significativo, no los tags.
        // Pero para el fixed-length, necesitamos el largo del XML completo de <si>.
        // La depuración que mostraste "<si><t xml:space="preserve">Comercializadora Lovac spa </t></"
        // sugiere que awk ya está trabajando con el XML completo de <si>, no solo con <t>.
        // Así que mantengamos la forma actual de extraer las líneas de <si>.

        $commandExtractSi = sprintf(
            'sed "s|<si>|\\n<si>|g; s|</sst>|\\n</sst>|g" %s | grep \'<si>\'',
            escapeshellarg($sharedStringsFilename)
        );

        // Paso 2: Calcular MAX_LEN y crear el archivo de largo fijo
        // Usamos un subshell para pasar MAX_LEN a awk de forma segura
        // Limpiamos los saltos de línea y CR/LF para un cálculo de longitud preciso.
        // El `tr -d '\n\r'` es crucial para que `length($0)` en awk calcule el largo *sin* el salto de línea.
        // Luego, awk añadirá *un* salto de línea al final.
        $commandProcessFixedLenStrings = sprintf(
            'export LC_ALL="C.UTF-8"; %s | ' . // Pipe de la extracción de <si> directamente a la subshell de awk
            '(TEMP_CONTENT=$(cat); ' . // Capturar la salida de la pipe en una variable temporal para doble pasada de awk
            'MAX_LEN=$(echo "$TEMP_CONTENT" | tr -d "\n\r" | awk \'{len = length($0); if (len > max_len) max_len = len} END { print max_len }\'); ' .
            'MAX_LEN=$((MAX_LEN + 10)); ' . // Margen de seguridad.
            'echo "$TEMP_CONTENT" | tr -d "\n\r" | awk -v max_len="$MAX_LEN" \'{ printf "%%-"max_len"s\\n", $0 }\' > %s; ' .
            'echo "MAX_LEN:$MAX_LEN")',
            $commandExtractSi, // La salida de este comando es la entrada para el subshell.
            escapeshellarg($sharedStringsFixedLenFile)
        );

        $output = [];
        $return_var = 0;
        exec($commandProcessFixedLenStrings, $output, $return_var);

        $max_len = null;
        if ($return_var === 0) {
            foreach ($output as $line) {
                if (preg_match('/^MAX_LEN:(\d+)$/', $line, $matches)) {
                    $max_len = (int)$matches[1];
                    break;
                }
            }

            if ($max_len !== null) {
                $this->MaxLen = $max_len;
                $this->SharedStringsFixedLenFile = $sharedStringsFixedLenFile;
                error_log("DEBUG: CmdProcessFixedLen - MAX_LEN calculado: {$this->MaxLen}. Archivo: {$this->SharedStringsFixedLenFile}");
            } else {
                throw new RuntimeException("Error: El archivo de shared strings de largo fijo pudo haber sido creado, pero no se pudo obtener MAX_LEN del output.");
            }
        } else {
             if (file_exists($sharedStringsFixedLenFile)) {
                unlink($sharedStringsFixedLenFile);
            }
            throw new RuntimeException("Error al ejecutar el comando Bash para shared strings de largo fijo. Salida: " . implode("\n", $output));
        }
    }

    /**
     * Prepara los archivos XML de las hojas de un XLSX descomprimido usando comandos de shell.
     * Esto es crucial para procesar archivos muy grandes sin cargar todo el contenido en memoria.
     *
     * @param ExcelUnzipInfo $excelUnzipInfo Objeto con la información de la descompresión del XLSX.
     * @return ExcelUnzipInfo El objeto ExcelUnzipInfo actualizado.
     * @throws RuntimeException Si ocurre algún error durante el procesamiento de las hojas.
     */
    function PrepararHojas(ExcelUnzipInfo $excelUnzipInfo): ExcelUnzipInfo {
        if (empty($excelUnzipInfo->SheetFilenames)) {
            return $excelUnzipInfo;
        }

        // Procesar las shared strings una sola vez para todo el Excel
        $this->CmdProcessFixedLen($excelUnzipInfo);


        foreach ($excelUnzipInfo->SheetFilenames as $sheetFilename => $sheetPath) {
            if (!file_exists($sheetPath) || !is_writable($sheetPath)) {
                throw new RuntimeException("Error: El archivo de hoja no existe o no tiene permisos de escritura: " . $sheetPath);
            }

            $escapedSheetPath = escapeshellarg($sheetPath);
            $finalRowsPath = $sheetPath . ".rows";
            $escapedFinalRowsPath = escapeshellarg($finalRowsPath);

            $this->FinalRowsFile = $finalRowsPath; // Asignamos el último processed file para que lo use ProcessRows

            $dimension = $this->CmdGetDimension($escapedSheetPath, $escapedFinalRowsPath);
            $this->CmdProcessRows($escapedSheetPath, $escapedFinalRowsPath);
            $excelUnzipInfo->RowsFilenames[$sheetFilename] = $finalRowsPath;
        }

        return $excelUnzipInfo;
    }

    /**
     * Obtiene una shared string específica del archivo de largo fijo.
     * Este es un método privado de la clase BigExcel.
     *
     * @param int $index El índice (base 0) de la shared string a recuperar.
     * @return string|null La shared string solicitada, o null si hay un error (por ejemplo, archivo no encontrado o índice fuera de rango).
     */
    private function GetSharedString(int $index): ?string
    {
        // Cada registro ocupa $this->MaxLen + 1 (por el salto de línea) bytes.
        // Añadir un pequeño margen de lectura para capturar el posible byte extra.
        $readLength = (int)$this->MaxLen + 1 + 5; // Leer 5 bytes extras por seguridad.

        if ($index = 5435) {
            $stop = 1;
        }
        // Calcular el offset inicial
        $offset = $index * ((int)$this->MaxLen + 1); // El offset se basa en el tamaño exacto del registro

        $handle = fopen($this->SharedStringsFixedLenFile, 'rb');
        if (!$handle) {
            throw new RuntimeException("Error: No se pudo abrir el archivo de shared strings de largo fijo: " . $this->SharedStringsFixedLenFile);
        }

        // Mover el puntero del archivo
        if (fseek($handle, $offset, SEEK_SET) === -1) {
            fclose($handle);
            // Esto es un error si el archivo no es lo suficientemente grande para el offset
            error_log("ERROR: fseek falló en offset $offset en " . $this->SharedStringsFixedLenFile);
            return null;
        }

        // Leer la línea con el margen extra
        $line = fread($handle, $readLength);
        fclose($handle);

        if ($line === false || empty($line)) {
            error_log("ERROR: fread falló o línea vacía para índice $index en " . $this->SharedStringsFixedLenFile);
            return null;
        }

        // Depuración: Mostrar la longitud real en bytes y el contenido de la línea leída
        error_log("DEBUG: GetSharedString - Indice: $index, Offset: $offset, Leído: " . strlen($line) . " bytes.");
        error_log("DEBUG: GetSharedString - Contenido RAW (primeros 50 bytes): " . substr(str_replace(["\n", "\r"], ['\n', '\r'], $line), 0, 50));
        error_log("DEBUG: GetSharedString - Contenido RAW (últimos 50 bytes): " . substr(str_replace(["\n", "\r"], ['\n', '\r'], $line), -50));


        // Eliminar el padding, saltos de línea y cualquier carácter invisible al principio/final.
        // trim() es multi-byte safe si mb_internal_encoding está configurado.
        // Podemos ser más agresivos con preg_replace para eliminar bytes no imprimibles.
        $cleanLine = trim($line); // Eliminar espacios y saltos de línea
        // Eliminar cualquier caracter no imprimible (ASCII 0-31 y 127) excepto salto de línea/tabulación
        // También puede intentar eliminar el BOM si lo encuentra (aunque raro a mitad de archivo)
        $cleanLine = preg_replace('/^[\x00-\x1F\x7F]+/', '', $cleanLine); // Eliminar caracteres de control al inicio
        $cleanLine = preg_replace('/[\x00-\x1F\x7F]+$/', '', $cleanLine); // Eliminar caracteres de control al final

        // Si la depuración muestra espacios al PRINCIPIO de la línea,
        // esto es porque la cadena original en el XML tenía esos espacios
        // y awk los incluyó, o el `printf` no se comportó como esperamos.
        // Si `awk` usó `printf "%%-"max_len"s\n", $0`, el relleno debería ser al FINAL.
        // Pero la depuración que mostraste "                                    <si>..."
        // implica que los espacios están al principio. Esto es muy extraño.
        // Esto sugiere que el contenido de `$0` en `awk` ya tiene esos espacios,
        // o que `$0` en awk no es el XML que esperas.

        // Por ahora, asumamos que necesitamos quitar esos espacios iniciales si no forman parte del XML.
        // Esto es un parche, la causa raíz debería investigarse en la generación del archivo.
        $cleanLine = ltrim($cleanLine);


        // Extraer el texto puro de la cadena compartida.
        $extractedText = '';
        if (preg_match_all('/<t>(.*?)<\/t>/isu', $cleanLine, $matches)) {
            foreach ($matches[1] as $match) {
                $extractedText .= html_entity_decode($match, ENT_QUOTES | ENT_XML1, 'UTF-8');
            }
        } else {
            // Fallback robusto usando SimpleXML.
            $tempSiXml = '<root>' . $cleanLine . '</root>'; // Envolver para hacer un XML válido
            $libxmlState = libxml_use_internal_errors(true);
            $siElement = simplexml_load_string($tempSiXml);
            libxml_use_internal_errors($libxmlState);

            if ($siElement !== false && isset($siElement->si)) {
                $tempText = '';
                foreach ($siElement->si->xpath('.//t') as $tNode) { // Buscar todos los nodos <t> dentro de <si>
                    $tempText .= (string)$tNode;
                }
                $extractedText = html_entity_decode($tempText, ENT_QUOTES | ENT_XML1, 'UTF-8');
            } else {
                error_log("Advertencia: No se pudo extraer texto de shared string mediante regex o SimpleXML. Contenido RAW: " . substr($cleanLine, 0, 100));
                // Si todo falla, intentamos una extracción más simple asumiendo que el texto está entre <t> y </t>
                if (preg_match('/<t.*?>(.*?)<\/t>/isu', $cleanLine, $simpleMatches)) {
                    $extractedText = html_entity_decode($simpleMatches[1], ENT_QUOTES | ENT_XML1, 'UTF-8');
                } else {
                    $extractedText = $cleanLine; // Último recurso: devolver la línea limpia tal cual
                }
            }
            libxml_clear_errors();
        }

        return $extractedText;
    }

    /**
     * Convierte un índice de columna basado en letras (A, B, AA) a un índice numérico (0, 1, 26).
     * Este es un método helper privado.
     *
     * @param string $colStr La cadena de la columna (ej. "A", "AB").
     * @return int El índice numérico de la columna (base 0).
     */
    private function ColCharToIndex(string $colStr): int
    {
        $colIndex = 0;
        $colLength = mb_strlen($colStr); // Usar mb_strlen
        for ($i = 0; $i < $colLength; $i++) {
            $colIndex = $colIndex * 26 + (ord(mb_substr($colStr, $i, 1)) - ord('A') + 1); // Usar mb_substr
        }
        return $colIndex - 1; // Ajustar a base 0
    }

    /**
     * Convierte un índice de columna  numérico (0, 1, 26)  a un índice basado en letras (A, B, AA).
     * Este es un método helper privado.
     *
     * @param int El índice numérico de la columna (base 0).
     * @return string $colStr La cadena de la columna (ej. "A", "AB").
     */
    function getExcelColumnLetter($index)
    {
        $letter = '';
        while ($index >= 0) {
            $mod = $index % 26;
            $letter = chr(65 + $mod) . $letter;
            $index = intval($index / 26) - 1;
        }
        return strtoupper($letter);
    }

    /**
     * Procesa una única cadena XML de fila para extraer los datos de las celdas.
     * Este método reemplaza la lógica de preg_match_all.
     *
     * @param string $rowXmlString La cadena XML completa de una fila (ej. "<row r="X">...</row>").
     * @return array Un array asociativo de datos de celda mapeados por índice de columna numérica.
     * @throws \RuntimeException Si la cadena XML de la fila es inválida.
     */
    private function ParseRowCells(string $rowXmlString): array
    {
        // Deshabilitar errores de entidades para SimpleXML, útil para XML de Excel
        $libxmlState = libxml_use_internal_errors(true); // Guarda el estado actual
        $rowElement = simplexml_load_string($rowXmlString);
        libxml_use_internal_errors($libxmlState); // Restaura el estado

        if ($rowElement === false) {
            $errors = libxml_get_errors();
            $errorMessage = "Error al parsear el XML de la fila. Errores: ";
            foreach ($errors as $error) {
                $errorMessage .= trim($error->message) . " en línea " . $error->line . "; ";
            }
            libxml_clear_errors(); // Limpiar errores
            throw new \RuntimeException($errorMessage);
        }

        $cellValuesByIndex = [];

        // Iterar sobre cada elemento <c> (celda) dentro de la fila
        foreach ($rowElement->c as $cell) {
            $colRef = (string)$cell->attributes()->r;
                        
            if ($colRef == 'S688') {
                $stop = 1;
            }

            // Extraer la parte de la columna (ej. "A", "B")
            preg_match('/^([A-Z]+)/', $colRef, $colMatches);
            $colString = $colMatches[1];
            $colIndex = $this->ColCharToIndex($colString); // Convertir "A" -> 0, "B" -> 1

            // Obtener el atributo 's' (estilo) si existe. Es crucial para fechas.
            $styleIndex = (string)$cell->attributes()->s; // "1", "2", etc.
            $cellType = (string)$cell->attributes()->t; // Tipo de celda (ej. "s", "n", o vacío si no está)
            $cellRawValue = (string)$cell->v;

            $actualCellValue = '';

            switch ($cellType) {
                case 's': // Shared String
                    $sharedStringIndex = (int)$cellRawValue;
                    $actualCellValue = $this->GetSharedString($sharedStringIndex);
                    break;
                case 'n': // Number (podría ser una fecha)
                case '':  // Si no hay atributo 't', Excel a menudo asume numérico
                    if (is_numeric($cellRawValue)) {
                        $numericValue = (float)$cellRawValue;

                        // *** Lógica para detectar y convertir fechas ***
                        if ($numericValue >= 1 && $numericValue < 65000) {
                            $convertedDate = Date::ConvertExcelDateTimeToYMDHIS($numericValue);

                            if ($convertedDate !== false) {
                                $actualCellValue = $convertedDate;
                            } else {
                                $actualCellValue = $numericValue;
                            }
                        } else {
                            $actualCellValue = $numericValue; // No es numérico o no es una fecha
                        }
                    } else {
                        $actualCellValue = $cellRawValue; // No es numérico o no es una fecha
                    }
                    break;
                case 'b': // Boolean (0 o 1)
                    $actualCellValue = ($cellRawValue === '1' ? true : false);
                    break;
                default: // Por defecto, se toma el valor crudo
                    $actualCellValue = $cellRawValue;
                    break;
            }
            $cellValuesByIndex[$colIndex] = $actualCellValue;
        }

        return $cellValuesByIndex;
    }


    /**
     * Procesa el archivo Excel fila por fila, aplicando callbacks para cada paso.
     * Esta función está optimizada para la RAM y requiere el método ListWorksheetInfo().
     *
     * @param bool $breakOnEmptyRow Si es true, detiene el procesamiento al encontrar una fila vacía.
     * @param callable|null $saveRowCallback Callback para procesar/guardar cada objeto DTO de fila. Recibe (object $dto, mixed $payload).
     * @param mixed $payload Datos adicionales que se pasarán a los callbacks.
     * @param callable|null $errorRowCallback Callback para manejar errores en filas específicas. Recibe (\Exception $e, int $rowIndex, mixed $rowData, mixed $payload).
     * @param callable|null $validateRowCallback Callback para validar cada fila antes de procesar. Recibe (array $rowData, int $rowIndex, mixed $payload). Debe retornar true si es válida, false si no.
     * @throws \RuntimeException Para errores durante la lectura o procesamiento del archivo.
     */
    public function ProcessRows(
        bool $breakOnEmptyRow = false,
        ?callable $saveRowCallback = null,
        $payload = null,
        ?callable $errorRowCallback = null,
        ?callable $validateRowCallback = null): void
    {
        // Recorrer las propiedades del DTO para generar el encabezado de la tabla
        $dtoName = $this->DtoName;
        $this->FieldNames = []; // Reiniciar a un array vacío para que se llene dinámicamente

        // Abrir el archivo de entrada (sheet1.xml.rows)
        $rowsHandle = fopen($this->FinalRowsFile, 'r');
        if (!$rowsHandle) {
            throw new \RuntimeException("No se pudo abrir el archivo de filas procesadas: " . $this->FinalRowsFile);
        }

        $totalRowsProcessed = 0; // Número de filas que se intentaron procesar (incluyendo las salteadas por validación)
        $rowIndexInFile = 0; // El índice de fila tal como aparece en el XML (r="X")

        // --- Bucle principal para leer el archivo de filas línea por línea ---
        while (($rowLine = fgets($rowsHandle)) !== false) {
            $totalRowsProcessed++;

            // Ignorar filas de cabecera al inicio
            if (($totalRowsProcessed -1) < $this->RowsIgnored) { // Ajuste para que $totalRowsProcessed refleje el contador de líneas en el archivo
                continue;
            }

            $rowLine = trim($rowLine);

            // Manejar filas vacías
            if ($breakOnEmptyRow && empty($rowLine)) {
                //echo "Se encontró una fila vacía y 'breakOnEmptyRow' es true. Deteniendo procesamiento en la fila " . ($totalRowsProcessed - 1) . ".\n";
                break;
            } elseif (empty($rowLine)) {
                continue; // Si la fila está vacía y no se debe romper, simplemente saltar
            }

            try {
                // 1. Extraer el número de fila del XML (r="X")
                // Si no se encuentra 'r="X"', usamos $totalRowsProcessed (ajustado) como fallback
                preg_match('/<row r="(\d+)"/', $rowLine, $rowMatches);
                $rowIndexInFile = (int)($rowMatches[1] ?? ($totalRowsProcessed - $this->RowsIgnored));

                // Usar la nueva función ParseRowCells para obtener los valores de las celdas
                $cellValuesByIndex = $this->ParseRowCells($rowLine);

                // Si es la fila de "campos", llenar la información para los futuros DTO
                // Esto se ejecuta una sola vez para la primera fila de datos (después de RowsIgnored)
                if ($this->FieldRowExists && ($totalRowsProcessed - $this->RowsIgnored) === 1) {
                    $this->FieldNames = []; // Reiniciar FieldNames, ya que ahora los obtenemos del Excel
                    foreach($cellValuesByIndex as $colIndex => $fieldValue) {
                        // Limpiar y transformar el nombre del campo para que coincida con las propiedades del DTO
                        // Eliminar caracteres no alfanuméricos y reemplazar espacios con guiones bajos
                        $cleanFieldName = preg_replace('/[^a-zA-Z0-9_]/', '', str_replace(' ', '_', $fieldValue));
                        $this->FieldNames[$cleanFieldName] = $colIndex;
                    }
                } else {
                    // `$parsedRowData` será el array asociativo que se pasará a los callbacks
                    $parsedRowData = [];
                    // Mapear los valores de las celdas al formato del DTO (usando $this->FieldNames)
                    foreach ($this->FieldNames as $propertyName => $colIndex) { // $propertyName es el nombre del DTO, $colIndex es el índice de la columna
                        $parsedRowData[$propertyName] = $cellValuesByIndex[$colIndex] ?? null;
                    }

                    // 4. Invocar el callback de validación (si está definido)
                    if ($validateRowCallback !== null) {
                        if (!call_user_func($validateRowCallback, $parsedRowData, $rowIndexInFile, $payload)) {
                            //echo "Fila {$rowIndexInFile} no pasó la validación. Saltando.\n";
                            continue; // Pasar a la siguiente fila en el bucle
                        }
                    }

                    // 5. Crear la instancia del DTO y asignar los valores
                    $dto = new $this->DtoName();
                    foreach ($parsedRowData as $propertyName => $value) {
                        // Solo asignar si la propiedad realmente existe en el DTO para evitar errores
                        if (property_exists($dto, $propertyName)) {
                            try {
                                $dto->$propertyName = $value;
                            }
                            catch (\TypeError $ex) {
                                // Puedes loggear esto en lugar de solo asignar $stop = 1
                                error_log("TypeError al asignar valor a la propiedad '{$propertyName}' en fila {$rowIndexInFile}: " . $ex->getMessage());
                            }
                            catch (\Exception $ex) {
                                // Puedes loggear esto
                                error_log("Excepción al asignar valor a la propiedad '{$propertyName}' en fila {$rowIndexInFile}: " . $ex->getMessage());
                            }
                        } else {
                            // Opcional: loggear si una propiedad del DTO no coincide con un campo del Excel
                             error_log("Advertencia: Propiedad '{$propertyName}' no existe en DTO '{$this->DtoName}' para la fila {$rowIndexInFile}.");
                        }
                    }

                    // 6. Invocar el callback para guardar/procesar la fila (si está definido)
                    if ($saveRowCallback !== null) {
                        call_user_func($saveRowCallback, $dto, $payload);
                    }
                }

            } catch (\Throwable $e) { // Capturar cualquier error que ocurra durante el procesamiento de ESTA fila
                // Si hay un callback de error, lo invocamos
                if ($errorRowCallback !== null) {
                    // Pasar los datos de la fila que se intentaba procesar
                    call_user_func($errorRowCallback, $e, $rowIndexInFile, $parsedRowData ?? [], $payload);
                } else {
                    // Si no hay callback de error, lanzamos una RuntimeException
                    // Esto detendrá el procesamiento si no se maneja más arriba
                    throw new \RuntimeException(
                        "Error inesperado procesando fila {$rowIndexInFile} del Excel: " . $e->getMessage(),
                        $e->getCode(),
                        $e
                    );
                }
            }
        }

        // Cierre de los manejadores de archivos
        fclose($rowsHandle);
    }

    /**
     * Inicializa los archivos temporales para SharedStrings
     */
    private function initializeSharedStringsFiles(): void
    {
        $this->sharedStringsIndexFile = $this->workingFolder . '/shared_strings_index.tmp';
        $this->sharedStringsDataFile = $this->workingFolder . '/shared_strings_data.tmp';

        // Crear archivos vacíos
        touch($this->sharedStringsIndexFile);
        touch($this->sharedStringsDataFile);
    }

    /**
     * Recolecta todas las cadenas únicas de los datos en la primera pasada
     * usando comandos de disco para optimización de memoria
     *
     * @param BigExcelDefinition $excelDefinition
     */
    private function collectSharedStringsFromData(BigExcelDefinition $excelDefinition): void
    {
        $tempAllStringsFile = $this->workingFolder . '/all_strings.tmp';

        // Extraer todas las cadenas de texto de todos los archivos de datos (formato JSONL)
        foreach ($excelDefinition->Hojas as $hoja) {
            if (!file_exists($hoja->RutaArchivoEntrada)) {
                continue;
            }

            // Para archivos JSONL (una línea JSON por línea), usar jq -r para cada línea
            $escapedInputFile = escapeshellarg($hoja->RutaArchivoEntrada);
            $escapedTempFile = escapeshellarg($tempAllStringsFile);

            // Extraer valores de string de JSONL usando grep/sed (sin dependencia de jq)
            // Buscar patrones "campo":"valor" y extraer solo los valores de string (no números puros)
            $command = "grep -oP '\"[^\"]+\":\\s*\"[^\"]+\"' {$escapedInputFile} | sed 's/.*\":\\s*\"\\(.*\\)\"/\\1/' | grep -v '^[0-9.\\-]*\$' | grep -v '^$' >> {$escapedTempFile} 2>/dev/null || true";
            exec($command);
        }

        if (file_exists($tempAllStringsFile)) {
            // Filtrar líneas vacías, ordenar y obtener valores únicos
            $escapedTempFile = escapeshellarg($tempAllStringsFile);
            $escapedDataFile = escapeshellarg($this->sharedStringsDataFile);

            $command = "grep -v '^$' {$escapedTempFile} | uniq > {$escapedDataFile}";
            exec($command);

            // Contar líneas para obtener el total de strings únicos
            $countCommand = "wc -l < {$escapedDataFile}";
            $this->sharedStringsCount = (int)trim(shell_exec($countCommand));

            // Solo crear índice si hay strings
            if ($this->sharedStringsCount > 0) {
                $escapedIndexFile = escapeshellarg($this->sharedStringsIndexFile);
                $createIndexCommand = "awk '{print \$0 \":\" NR-1}' {$escapedDataFile} > {$escapedIndexFile}";
                exec($createIndexCommand);
            }

            // Limpiar archivo temporal
            unlink($tempAllStringsFile);
        }
    }

    /**
     * Agrega un string al sistema SharedStrings si no existe y devuelve su índice
     *
     * @param string $string La cadena a agregar
     * @return int El índice de la cadena
     */
    private function addToSharedStrings(string $string): int
    {

        // No aplicar escape aquí - se hará en generateSharedStringsXml
        $processedString = $string;

        // Primero buscar si ya existe
        $existingIndex = $this->getSharedStringIndex($string);
        if ($existingIndex !== null) {
            return $existingIndex;
        }

        // Agregar nuevo string
        $escapedDataFile = escapeshellarg($this->sharedStringsDataFile);
        $escapedString = escapeshellarg($processedString);

        // Agregar al archivo de datos
        $command = "echo {$escapedString} >> {$escapedDataFile}";
        exec($command);

        // Obtener el nuevo índice (número de líneas - 1)
        $countCommand = "wc -l < {$escapedDataFile}";
        $newIndex = (int)trim(shell_exec($countCommand)) - 1;

        // Actualizar contador
        $this->sharedStringsCount = $newIndex + 1;

        // Agregar al archivo de índice
        $escapedIndexFile = escapeshellarg($this->sharedStringsIndexFile);
        $indexCommand = "echo {$escapedString}:{$newIndex} >> {$escapedIndexFile}";
        exec($indexCommand);

        return $newIndex;
    }

    /**
     * Busca el índice de una cadena en los archivos de SharedStrings usando grep
     *
     * @param string $string La cadena a buscar
     * @return int|null El índice de la cadena o null si no se encuentra
     */
    private function getSharedStringIndex(string $string): ?int
    {
        // Si no hay índice, no hay SharedStrings
        if (empty($this->sharedStringsIndexFile) || !file_exists($this->sharedStringsIndexFile)) {
            return null;
        }

        // No aplicar escape aquí - buscar string original
        $processedString = $string;

        $tempPattern = tempnam(sys_get_temp_dir(), 'pattern_');
        file_put_contents($tempPattern, '^' . preg_quote($processedString, '/') . ':');
        $escapedTempPattern = escapeshellarg($tempPattern);
        $escapedIndexFile = escapeshellarg($this->sharedStringsIndexFile);

        // Buscar usando el patrón desde archivo temporal y obtener el último campo
        $command = "grep -f {$escapedTempPattern} {$escapedIndexFile} | head -n 1 | awk -F: '{print \$NF}'";
        $result = trim(shell_exec($command));
        unlink($tempPattern);

        return $result !== '' ? (int)$result : null;
    }

    /**
     * Genera el archivo sharedStrings.xml final usando los datos recolectados
     */
    private function generateSharedStringsXml(): void
    {
        // Solo generar si tenemos strings
        if ($this->sharedStringsCount === 0) {
            return;
        }

        $sharedStringsPath = $this->workingFolder . '/xl/sharedStrings.xml';
        $escapedDataFile = escapeshellarg($this->sharedStringsDataFile);
        $escapedOutputFile = escapeshellarg($sharedStringsPath);

        // Crear el XML usando comandos de shell para optimizar memoria
        $xmlHeader = '<?xml version="1.0" encoding="UTF-8" standalone="yes"?>' . "\n" .
                    '<sst xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main" count="' .
                    $this->sharedStringsCount . '" uniqueCount="' . $this->sharedStringsCount . '">';

        $xmlFooter = '</sst>';

        // Escribir header
        file_put_contents($sharedStringsPath, $xmlHeader);

        // Procesar cada línea y convertirla a formato XML
        $processCommand = 'while IFS= read -r line; do ' .
                         'escaped_line=$(echo "$line" | sed "s/&/\\&amp;/g; s/</\\&lt;/g; s/>/\\&gt;/g"); ' .
                         'echo "<si><t>$escaped_line</t></si>"; ' .
                         'done < ' . $escapedDataFile . ' >> ' . $escapedOutputFile;

        exec($processCommand);

        // Escribir footer
        file_put_contents($sharedStringsPath, $xmlFooter, FILE_APPEND);
    }

    /**
     * Destructor que limpia automáticamente los archivos temporales.
     */
    public function __destruct()
    {
        $this->cleanup();
    }

    /**
     * Limpia los archivos temporales creados durante el procesamiento del Excel.
     * Se debe llamar después de que el procesamiento haya finalizado.
     */
    public function cleanup(): void
    {
        // Limpiar archivos específicos de SharedStrings primero
        if (!empty($this->sharedStringsIndexFile) && file_exists($this->sharedStringsIndexFile)) {
            unlink($this->sharedStringsIndexFile);
        }

        if (!empty($this->sharedStringsDataFile) && file_exists($this->sharedStringsDataFile)) {
            unlink($this->sharedStringsDataFile);
        }

        // Limpiar la carpeta completa de trabajo
        if (!empty($this->workingFolder) && is_dir($this->workingFolder)) {
            FileHelper::removeDir($this->workingFolder);
        }
    }


    /**
     * Procesa el archivo  fila por fila para crear guardar en Excel, aplicando callbacks para cada paso.
     * Esta función está optimizada para la RAM y requiere el método ListWorksheetInfo().
     *
     * @param string $pathFileInputData ruta del archivo de donde obtener los datos de filas.
     * @param callable|null $saveRowCallback Callback para procesar/guardar cada objeto DTO de fila. Recibe (object $dto, mixed $payload).
     * @param mixed $payload Datos adicionales que se pasarán a los callbacks.
     * @param callable|null $errorRowCallback Callback para manejar errores en filas específicas. Recibe (\Exception $e, int $rowIndex, mixed $rowData, mixed $payload).
     * @param callable|null $validateRowCallback Callback para validar cada fila antes de procesar. Recibe (array $rowData, int $rowIndex, mixed $payload). Debe retornar true si es válida, false si no.
     * @throws \RuntimeException Para errores durante la lectura o procesamiento del archivo.
     */
    public function WriteRows(
        BigExcelDefinition $excelDefinition,
        ?callable $saveRowCallback = null,
        $payload = null,
        ?callable $errorRowCallback = null,
        ?callable $validateRowCallback = null,
        $outputFilePath = ''
    ): void {
        // **PASADA 1**: Inicializar SharedStrings (recolección durante procesamiento)
        $this->initializeSharedStringsFiles();

        /*
         * Variables para remplazar en los archivos
         * las etiquetas necesarias de definicion de
         * las hojas
         */
        $workbookHojas = '';
        $appHojas = '';
        $typesHojas = '';
        $relationships = '';

        $count = 0;
        foreach ($excelDefinition->Hojas as $hoja) {

            try {
                $count++;
                $result = $this->AgregarHoja($hoja, $count);
                $workbookHojas = $workbookHojas . $result->WorkbookHoja;
                $appHojas = $appHojas . $result->AppHoja;
                $typesHojas = $typesHojas . $result->TypesHoja;
                $relationships = $relationships . $result->Relationship;
            } catch (\Throwable $e) { // Capturar cualquier error que ocurra durante el procesamiento de ESTA fila
                // Si hay un callback de error, lo invocamos
                if ($errorRowCallback !== null) {
                    // Pasar los datos de la fila que se intentaba procesar
                    call_user_func($errorRowCallback, $e, $payload);
                } else {
                    // Si no hay callback de error, lanzamos una RuntimeException
                    // Esto detendrá el procesamiento si no se maneja más arriba
                    throw new \RuntimeException(
                        "Error inesperado procesando la hoja {$hoja->Nomre} del Excel: " . $e->getMessage(),
                        $e->getCode(),
                        $e
                    );
                }
            }
        }

        // Remplazar archivos necesarios para las hojas
        $this->CmdReplaceTextInArchive($this->workingFolder . "/xl/workbook.xml", "\[sheets\]", $workbookHojas);
        $this->CmdReplaceTextInArchive($this->workingFolder . "/docProps/app.xml", "\[sheets\]", $appHojas);
        $this->CmdReplaceTextInArchive($this->workingFolder . "/docProps/app.xml", "\[countSheet\]", $count);
        $this->CmdReplaceTextInArchive($this->workingFolder . "/[Content_Types].xml", "\[sheets\]", $typesHojas);
        $this->CmdReplaceTextInArchive($this->workingFolder . "/xl/_rels/workbook.xml.rels", "\[relationships\]", $relationships);

        // Generar SharedStrings XML después del procesamiento
        $this->generateSharedStringsXml();

        // Agregar referencias para SharedStrings
        $this->updateConfigFilesForSharedStrings();

        // Limpiar archivos temporales antes de crear el ZIP
        $this->cleanupTemporaryFiles();

        $rutaArchivo = $this->CrearArchivoXlsx();
        $file = (object)pathinfo($excelDefinition->NombreArchivo, PATHINFO_ALL);        

        // Se limpia directorio temporal del xlsx
        if (isset($outputFilePath) && $outputFilePath != '') {
            $destinoCompleto = $outputFilePath . '/' . $excelDefinition->NombreArchivo . ".xlsx";

            // 2. Intentar copiar el archivo
            copy($rutaArchivo, $destinoCompleto);
        }
        else {
            $this->DownloadFile($rutaArchivo, $file->filename . (isset($file->extension) ? $file->extension : ".xlsx"));
        }

        FileHelper::removeDir($this->workingFolder);        
    }

    /**
     * Agrega una nueva hoja y se asigna los valores correspondientes al xml
     *
     * @param BigExcelSheet $hoja Objeto que define las propiedades de la hoja.
     * @param int $contadorHoja Numero que define el contador asignado a la hoja (ejemplo sheet1, sheet2).
     * @return object Retorna un objecto con los valores asginados a la hoja para modificar en los archivos necesarios del xlsx.
     */
    private function AgregarHoja(BigExcelSheet $hoja, $contadorHoja)
    {
        // Abrir el archivo de entrada
        $rowsHandle = fopen($hoja->RutaArchivoEntrada, 'r');
        if (!$rowsHandle) {
            throw new \RuntimeException("No se pudo abrir el archivo de filas procesadas: " . $hoja->RutaArchivoEntrada);
        }

        $pathSheet = $this->PrepararHoja($contadorHoja);

        $hojaXml = fopen($pathSheet, "a+");
        $totalRowsProcessed = 0; // Número de filas que se intentaron procesar (incluyendo las salteadas por validación)
        $rowIndexInFile = 1; // El índice de fila tal como aparece en el XML (r="X")
        $setHeader = true;

        $letraUltimaColumna = "";

        // --- Bucle principal para leer el archivo de filas línea por línea ---
        while (($rowLine = fgets($rowsHandle)) !== false) {
            $totalRowsProcessed++;

            $rowLine = trim($rowLine);

            // Manejar filas vacías
            if (empty($rowLine))
                continue; // Si la fila está vacía y no se debe romper, simplemente saltar

            try {
                $rowJon = json_decode($rowLine);
                if (!isset($rowJon))
                    continue;

                $rowArray = get_object_vars($rowJon);

                // Se agrega la primera fila como cabecera 
                if ($setHeader) {
                    // Se agrega fila.
                    $rowFile = '<row r="' . $rowIndexInFile . '" spans="1:4" x14ac:dyDescent="0.25">';
                    fwrite($hojaXml, $rowFile);

                    $count = 0;
                    foreach ($rowArray as $key => $value) {
                        $letraUltimaColumna = $this->getExcelColumnLetter($count);
                        /* 
                         * Se valida que si viene definida las cabecera la columna debe 
                         * estar siempre presente en los datos obtenidos del archivo para
                         * tomar el nombre definido en la cabera sino se salta
                         */
                        if (isset($hoja->Cabeceras)) {
                            if (!isset($hoja->Cabeceras->{$key}))
                                continue;
                            $key = $hoja->Cabeceras->{$key};
                        }

                        // Para headers, siempre usar texto directo para evitar inconsistencias
                        $escapedKey = $this->ValidateAndReplaceChar($key);
                        $cel = '<c r="' . $letraUltimaColumna . '1"><v>' . $escapedKey . '</v></c>';
                        fwrite($hojaXml, $cel);
                        $count++;
                    }
                    fwrite($hojaXml, '</row>');
                    $setHeader = false;
                    $rowIndexInFile++;
                }

                // Se agrega fila.
                $rowFile = '<row r="' . $rowIndexInFile . '" spans="1:4" x14ac:dyDescent="0.25">';
                fwrite($hojaXml, $rowFile);

                $count = 0;
                foreach ($rowArray as $key => $value) {
                    $letra = $this->getExcelColumnLetter($count);

                    /* 
                     * Se valida que si viene definida las cabecera la columna debe 
                     * estar siempre presente en los datos obtenidos del archivo
                     * sino se salta
                     */
                    if (isset($hoja->Cabeceras) && !isset($hoja->Cabeceras->{$key})) continue;

                    if (isset($value) && $value !== '') {
                        // Determinar si es string o número con mejor lógica
                        if (is_string($value) && !is_numeric($value) && trim($value) !== '') {
                            // Es una cadena de texto - usar SharedStrings
                            $sharedStringIndex = $this->addToSharedStrings($value);
                            $cel = '<c r="' . $letra . $rowIndexInFile . '" t="s"><v>' . $sharedStringIndex . '</v></c>';
                        } else {
                            // Es un número, fecha o valor que no necesita SharedStrings
                            $cleanValue = $this->ValidateAndReplaceChar($value);
                            $cel = '<c r="' . $letra . $rowIndexInFile . '"><v>' . $cleanValue . '</v></c>';
                        }
                    } else {
                        // Celda vacía - formato correcto sin contenido
                        $cel = '<c r="' . $letra . $rowIndexInFile . '"/>';
                    }
                    fwrite($hojaXml, $cel);
                    $count++;
                }
                fwrite($hojaXml, '</row>');

                $rowIndexInFile++;
            } catch (\Throwable $e) { // Capturar cualquier error que ocurra durante el procesamiento de ESTA fila

                // Si no hay callback de error, lanzamos una RuntimeException
                // Esto detendrá el procesamiento si no se maneja más arriba
                throw new \RuntimeException(
                    "Error inesperado procesando fila {$rowIndexInFile} del Excel: " . $e->getMessage(),
                    $e->getCode(),
                    $e
                );
            }
        }

        fwrite($hojaXml, '
                    </sheetData>
                      <pageMargins left="0.7" right="0.7" top="0.75" bottom="0.75" header="0.3" footer="0.3" />
                    </worksheet>
                ');

        // Cierre de los manejadores de archivos
        fclose($rowsHandle);
        fclose($hojaXml);

        $this->CmdReplaceTextInArchive($pathSheet, "\[dimension\]", "A1:" . $letraUltimaColumna . ($rowIndexInFile - 1));

        $result = new stdClass();
        $result->WorkbookHoja = '<sheet name=\"' . $hoja->Nombre . '\" sheetId=\"' . $contadorHoja . '\" r:id=\"rId' . $contadorHoja . '\"/>';
        $result->AppHoja = '<vt:lpstr>' . $hoja->Nombre . '</vt:lpstr>';
        $result->TypesHoja = '<Override PartName=\"/xl/worksheets/sheet' . $contadorHoja . '.xml\" ContentType=\"application/vnd.openxmlformats-officedocument.spreadsheetml.worksheet+xml\" />';
        $result->Relationship = '<Relationship Id=\"rId' . $contadorHoja . '\" Type=\"http://schemas.openxmlformats.org/officeDocument/2006/relationships/worksheet\" Target=\"worksheets/sheet' . $contadorHoja . '.xml\" />';

        return $result;
    }



    /**
     * Copiar carpeta base del template para una nueva ruta con nuevo nombre.
     *
     * NOTA: Esta función crea una carpeta temporal. Eres responsable
     * de limpiar esta carpeta ($info->WorkingFolder) después de usarla.
     * Se recomienda usar un bloque try...finally en tu código principal
     * para asegurar la limpieza, llamando a FileHelper::removeDir().
     *
     * @param string $filename La ruta completa al archivo XLSX.
     * @return ExcelUnzipInfo Un objeto que contiene la ruta de la carpeta de trabajo,
     * la ruta de la carpeta 'xl', y los nombres de archivo de cada hoja XML.
     * @throws RuntimeException Si el archivo XLSX no existe, la carpeta no se puede crear, o la descompresión falla.
     */
    private function CopyFolderTemplate(): ExcelUnzipInfo
    {

        // 1. Validar que la carpeta del XLSX exista.
        if (!file_exists($this->DefaultTemplate)) {
            throw new RuntimeException("Error: La carpeta del XLSX no existe en la ruta: " . $this->DefaultTemplate);
        }
        $filename = $this->DefaultTemplate;

        // 2. Determinar la ruta de la carpeta de trabajo temporal única.
        $path = (object)pathinfo($filename, PATHINFO_ALL);

        $excelDirectory = $path->dirname . "/../../LocalData";
        $workingFolder = $excelDirectory . DIRECTORY_SEPARATOR . $path->filename . '_' . uniqid();

        // 3. Crear la carpeta temporal. ¡Este paso es crucial!
        if (!mkdir($workingFolder, 0777, true)) {
            throw new RuntimeException("No se pudo crear la carpeta temporal: " . $workingFolder . ". Verifique los permisos.");
        }

        // 4. Escapar las rutas para el comando shell.
        $escapedFilename = escapeshellarg($filename);
        $escapedWorkingFolder = escapeshellarg($workingFolder . DIRECTORY_SEPARATOR);

        // 5. Construir y ejecutar el comando cp.
        $command = "cp -r {$escapedFilename}/* {$escapedWorkingFolder}";

        $output = [];
        $return_var = 0;
        exec($command, $output, $return_var);

        // 6. Verificar el resultado de la copia.
        if ($return_var !== 0) {
            // Si falla la copia, se intentar limpiar la carpeta que se creó.
            FileHelper::removeDir($workingFolder); // <--- ¡Llamada correcta a tu método estático!
            $errorMsg = "Error al copiar el contendio de la carpeta de archivo XLSX. Código de retorno: {$return_var}. Salida: " . implode("\n", $output);
            throw new RuntimeException($errorMsg);
        }

        // 7. Recopilar la información para el objeto ExcelUnzipInfo.
        $xlFolder = $workingFolder . DIRECTORY_SEPARATOR . 'xl';
        $sharedStringsFilename = $xlFolder . "/sharedStrings.xml";
        $sheetFilenames = [];
        $rowsFilename = []; // Inicializar para evitar errores si no hay hojas

        // 8. Crear y devolver el objeto ExcelUnzipInfo.
        return new ExcelUnzipInfo(
            $filename,
            $workingFolder,
            $xlFolder,
            $sharedStringsFilename,
            $sheetFilenames,
            $rowsFilename
        );
    }

    /**
     * Crear un archivo xml en base al template para cada hoja
     *
     * @param int $numeroHoja Numero asignado a la nueva hoja.
     * @return string Ruta de la nueva hoja 
     */
    private function PrepararHoja($numeroHoja): string
    {

        $rutaHojaTemp = $this->workingFolder . "/xl/worksheets/sheet_template.xml";

        $rutaHoja =  $this->workingFolder . "/xl/worksheets/sheet" . $numeroHoja . ".xml";

        $escapedFilename = escapeshellarg($rutaHojaTemp);
        $escapedWorkingFolder = escapeshellarg($rutaHoja);

        // 1. Construir y ejecutar el comando de copia.
        $command = "cp {$escapedFilename} {$escapedWorkingFolder}";

        $output = [];
        $return_var = 0;
        exec($command, $output, $return_var);

        // 2. Verificar el resultado de la copia.
        if ($return_var !== 0) {
            // Si falla la copia, se intentar limpiar la carpeta que se creó.
            FileHelper::removeDir($this->workingFolder); // <--- ¡Llamada correcta a tu método estático!
            $errorMsg = "Error al copiar el contendio de la carpeta de archivo XLSX. Código de retorno: {$return_var}. Salida: " . implode("\n", $output);
            throw new RuntimeException($errorMsg);
        }

        return $rutaHoja;
    }

    /**
     * Se crea el archivo xlsx en base a la carpeta base del template 
     *
     * @return string Ruta de archivo xlsx creado.
     */
    private function CrearArchivoXlsx(): string
    {
        $rutaHojaTemp = $this->workingFolder . "/xl/worksheets/sheet_template.xml";

        $escapedFilename = escapeshellarg($rutaHojaTemp);

        // 1. Elimianar archivo template de la hoja.
        $command = "rm -rf  {$escapedFilename}";

        $output = [];
        $return_var = 0;
        exec($command, $output, $return_var);

        // 2. Verificar el resultado de la eliminacion.
        if ($return_var !== 0) {
            // Si falla la eliminacion, se intentar limpiar la carpeta que se creó.
            FileHelper::removeDir($this->workingFolder); // <--- ¡Llamada correcta a tu método estático!
            $errorMsg = "Error al eliminar el archivo de template. Código de retorno: {$return_var}. Salida: " . implode("\n", $output);
            throw new RuntimeException($errorMsg);
        }


        $path = (object)pathinfo(realpath($this->workingFolder), PATHINFO_ALL);
        // 3. Crear archivo zip de la carpeta de trabajo y convertir en xlsx
        $directory = $path->dirname . DIRECTORY_SEPARATOR . $path->filename;

        $command = "cd " . $directory . " && zip -r {$path->filename}.zip . && mv {$path->filename}.zip {$path->filename}.xlsx";

        $output = [];
        $return_var = 0;
        exec($command, $output, $return_var);

        // 4. Verificar el resultado de la creacion del archivo.
        if ($return_var !== 0) {
            // Si falla la eliminacion, se intentar limpiar la carpeta que se creó.
            FileHelper::removeDir($this->workingFolder); // <--- ¡Llamada correcta a tu método estático!
            $errorMsg = "Error al crear el archivo desde el template. Código de retorno: {$return_var}. Salida: " . implode("\n", $output);
            throw new RuntimeException($errorMsg);
        }

        return $directory . DIRECTORY_SEPARATOR . $path->filename . ".xlsx";
    }

    /**
     * Actualiza los archivos de configuración para incluir las referencias de SharedStrings
     */
    private function updateConfigFilesForSharedStrings(): void
    {
        // Solo actualizar si se generó el archivo sharedStrings.xml
        $sharedStringsPath = $this->workingFolder . '/xl/sharedStrings.xml';
        if (!file_exists($sharedStringsPath)) {
            return;
        }

        // 1. Actualizar Content_Types.xml usando PHP en lugar de sed
        $contentTypesPath = $this->workingFolder . "/[Content_Types].xml";
        if (file_exists($contentTypesPath)) {
            $content = file_get_contents($contentTypesPath);
            $sharedStringsType = '    <Override PartName="/xl/sharedStrings.xml" ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.sharedStrings+xml" />';

            $content = str_replace('</Types>', $sharedStringsType . "\n</Types>", $content);
            file_put_contents($contentTypesPath, $content);
        }

        // 2. Actualizar workbook.xml.rels usando PHP en lugar de sed
        $relsPath = $this->workingFolder . "/xl/_rels/workbook.xml.rels";
        if (file_exists($relsPath)) {
            $content = file_get_contents($relsPath);
            $nextRId = $this->getNextRelationshipId($relsPath);

            $sharedStringsRel = '    <Relationship Id="rId' . $nextRId . '" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/sharedStrings" Target="sharedStrings.xml" />';

            $content = str_replace('</Relationships>', $sharedStringsRel . "\n</Relationships>", $content);
            file_put_contents($relsPath, $content);
        }
    }

    /**
     * Limpia archivos temporales específicos antes de crear el ZIP
     */
    private function cleanupTemporaryFiles(): void
    {
        $tempFiles = [
            $this->sharedStringsIndexFile,
            $this->sharedStringsDataFile,
            $this->workingFolder . '/all_strings.tmp'
        ];

        foreach ($tempFiles as $tempFile) {
            if (!empty($tempFile) && file_exists($tempFile)) {
                unlink($tempFile);
            }
        }

        // Limpiar archivos Zone.Identifier que pueden aparecer
        $zoneFiles = glob($this->workingFolder . '/**/*.Zone.Identifier');
        foreach ($zoneFiles as $zoneFile) {
            if (file_exists($zoneFile)) {
                unlink($zoneFile);
            }
        }
    }

    /**
     * Obtiene el próximo ID de relación disponible del archivo workbook.xml.rels
     */
    private function getNextRelationshipId(string $relsPath): int
    {
        $content = file_get_contents($relsPath);

        // Buscar todos los rId existentes
        preg_match_all('/rId(\d+)/', $content, $matches);

        $maxId = 0;
        if (!empty($matches[1])) {
            $maxId = max(array_map('intval', $matches[1]));
        }

        return $maxId + 1;
    }

    /**
     * Remplaza un texto dentro de un archivo
     *
     * @param string $archive Ruta de archivo donde cambiar txto.
     * @param string $search Texto a buscar para el remplazo.
     * @param string $replace Texto a cambiar.
     */
    private function CmdReplaceTextInArchive($archive, $search, $replace): void
    {
        $escapedFilename = escapeshellarg($archive);

        // 1. Construir y ejecutar el comando para remplazar.
        $command = 'sed -i "s|' . $search . '|' . $replace . '|g" ' . $escapedFilename;

        $output = [];
        $return_var = 0;
        exec($command, $output, $return_var);

        // 2. Verificar el resultado del remplazo.
        if ($return_var !== 0) {
            $errorMsg = "Error al remplazar el contendio pedido. Código de retorno: {$return_var}. Salida: " . implode("\n", $output);
            throw new RuntimeException($errorMsg);
        }
    }


    /**
     * Replazar carecteres no permitidos en el archivo xml 
     *
     * @param string $text texto a validar y replazar.
     * @return string Un string luego de quitar caracteres invalidos.
     */
    private function ValidateAndReplaceChar($text): string
    {
        /*
         *  Eliminar caracteres inválidos según XML 1.0
         *  Permitidos: \x09 (tab), \x0A (LF), \x0D (CR), y rango Unicode válido
         */
        $result =  preg_replace('/[^\x09\x0A\x0D\x20-\x{D7FF}\x{E000}-\x{FFFD}]/u', '', $text);

        /*
         *  Es null cuando no cumple con la condicion de replazo
         *  se asigna de nuevo el texto enviado para continuar con las validaciones
         */
        if (!isset($result))
            $result = $text;

        /*
         * Escapar caracteres reservados de XML (&, <, >, ", ')
         */
        $result = htmlspecialchars($result, ENT_QUOTES | ENT_XML1, 'UTF-8');

        return $result;
    }

    /**
     * Descargar archivo xlsx
     *
     * @param string $fileName Ruta completa del archivo a descargar.
     * @param ?string $fileNameDownload Nombre del archivo que recibe el archivo descargado.
     */
    private function DownloadFile($fileName, $fileNameDownload = null)
    {
        if (!isset($fileNameDownload)) {
            $path = (object)pathinfo($fileName, PATHINFO_ALL);
            $fileNameDownload = $path->basename;
        }

        header('Content-Type: application/vnd.openxmlformats-officedocument.spreadsheetml.sheet');
        header('Content-Disposition: attachment; filename="' . $fileNameDownload . '"');
        header('Content-Length: ' . filesize($fileName));
        header('Pragma: public');
        header('Cache-Control: must-revalidate');
        header('Expires: 0');

        readfile($fileName);
    }

}