<?php
/**
 * SIE-IA Engine - Motor de Inteligencia Artificial en PHP
 *
 * Reemplaza la API Python (FastAPI en Render) ejecutando todo localmente.
 * - Llama a Groq API directamente via cURL (chat + vision OCR)
 * - Lee archivos del filesystem de DokuWiki (sin descargar/resubir)
 * - Usa SQLite local para consumo e historico
 * - Autenticacion via DokuWiki (sin JWT)
 *
 * Requiere en el servidor: pdftotext, pdftoppm (poppler-utils)
 */
namespace dokuwiki\plugin\bez\meta;

// Handler global: cualquier error fatal sale como JSON (nunca HTML)
set_exception_handler(function($e) {
    if (!headers_sent()) header('Content-Type: application/json; charset=utf-8');
    http_response_code(500);
    echo json_encode(['status' => 'error', 'error' => 'Fatal: ' . $e->getMessage()], JSON_UNESCAPED_UNICODE);
    exit;
});
set_error_handler(function($severity, $msg, $file, $line) {
    if (!(error_reporting() & $severity)) return false;
    throw new \ErrorException($msg, 0, $severity, $file, $line);
});
register_shutdown_function(function() {
    $err = error_get_last();
    if ($err && in_array($err['type'], [E_ERROR, E_PARSE, E_CORE_ERROR, E_COMPILE_ERROR])) {
        if (!headers_sent()) header('Content-Type: application/json; charset=utf-8');
        http_response_code(500);
        echo json_encode(['status' => 'error', 'error' => 'Fatal: ' . $err['message'] . ' en ' . $err['file'] . ':' . $err['line']], JSON_UNESCAPED_UNICODE);
    }
});

if (!defined('DOKU_INC')) {
    define('DOKU_INC', realpath(__DIR__ . '/../../../../') . '/');
}
require_once DOKU_INC . 'inc/init.php';
require_once(__DIR__ . '/path_helper.php');

// =====================================================================
// AUTENTICACION
// =====================================================================
header('Content-Type: application/json; charset=utf-8');

if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
    _responder(['status' => 'error', 'error' => 'Método no permitido.'], 405);
}

if (empty($_SERVER['REMOTE_USER'])) {
    _responder(['status' => 'error', 'error' => 'No autorizado.'], 401);
}

// =====================================================================
// CONFIGURACION
// =====================================================================
global $conf;
$GLOBALS['SIA_GROQ_KEY']     = isset($conf['groq_api_key']) ? $conf['groq_api_key'] : '';
$GLOBALS['SIA_GROQ_MODEL']   = isset($conf['groq_model'])   ? $conf['groq_model']   : 'llama-3.3-70b-versatile';
$GLOBALS['SIA_GROQ_VISION']  = 'meta-llama/llama-4-scout-17b-16e-instruct';
$GLOBALS['SIA_TOPE_MES']     = isset($conf['ia_tope_mes'])  ? (int)$conf['ia_tope_mes'] : 10;
$GLOBALS['SIA_CONTEXTO_DEF'] = 'Necesito me generes un resumen corto, claro y completo del siguiente documento';

if (empty($GLOBALS['SIA_GROQ_KEY'])) {
    _responder(['status' => 'error', 'error' => 'API Key de Groq no configurada en el servidor.'], 500);
}

// Log file para SIE-IA
$GLOBALS['SIA_LOG'] = bez_resolve_path('data/log/sia_ia.log');
function _siaLog($mensaje, $nivel = 'INFO') {
    $logFile = $GLOBALS['SIA_LOG'];
    $dir = dirname($logFile);
    if (!is_dir($dir)) @mkdir($dir, 0755, true);
    $ts = date('Y-m-d H:i:s');
    $user = isset($_SERVER['REMOTE_USER']) ? $_SERVER['REMOTE_USER'] : '-';
    @file_put_contents($logFile, "[$ts] [$nivel] [$user] $mensaje\n", FILE_APPEND | LOCK_EX);
}

// =====================================================================
// BASE DE DATOS SQLite
// =====================================================================
if (!extension_loaded('pdo_sqlite')) {
    _siaLog("Extension pdo_sqlite NO disponible en el servidor", 'ERROR');
    _responder(['status' => 'error', 'error' => 'Extension pdo_sqlite no disponible en el servidor. Contacte al administrador.'], 500);
}

$dbPath = bez_resolve_path('data/meta/sia_ia.sqlite3');
try {
    $GLOBALS['SIA_DB'] = new \PDO('sqlite:' . $dbPath);
    $GLOBALS['SIA_DB']->setAttribute(\PDO::ATTR_ERRMODE, \PDO::ERRMODE_EXCEPTION);
    $GLOBALS['SIA_DB']->exec('PRAGMA journal_mode=WAL');
    _initDB($GLOBALS['SIA_DB']);
} catch (\Throwable $e) {
    _siaLog("Error SQLite: " . $e->getMessage(), 'ERROR');
    _responder(['status' => 'error', 'error' => 'Error de base de datos: ' . $e->getMessage()], 500);
}

// =====================================================================
// ROUTER
// =====================================================================
$accion = isset($_POST['accion']) ? $_POST['accion'] : '';

switch ($accion) {
    case 'resumen-sie':
        _accionResumenSie();
        break;
    case 'consulta-historico':
        _accionConsultaHistorico();
        break;
    default:
        _responder(['status' => 'error', 'error' => 'Acción no válida.'], 400);
}
exit;


// #####################################################################
//  ACCIONES
// #####################################################################

/**
 * POST accion=resumen-sie
 * Params: ruta_modulo, submodulo, titulo
 * El servidor lee archivos directamente del filesystem.
 */
function _accionResumenSie() {
    $db         = $GLOBALS['SIA_DB'];
    $groqKey    = $GLOBALS['SIA_GROQ_KEY'];
    $groqModel  = $GLOBALS['SIA_GROQ_MODEL'];
    $groqVision = $GLOBALS['SIA_GROQ_VISION'];
    $topeMes    = $GLOBALS['SIA_TOPE_MES'];
    $ctxDefault = $GLOBALS['SIA_CONTEXTO_DEF'];

    $rutaModulo = isset($_POST['ruta_modulo']) ? trim($_POST['ruta_modulo']) : '';
    $submodulo  = isset($_POST['submodulo'])   ? trim($_POST['submodulo'])   : '';
    $titulo     = isset($_POST['titulo'])      ? trim($_POST['titulo'])      : '';

    if (empty($rutaModulo)) {
        _responder(['status' => 'error', 'error' => 'Falta ruta_modulo.'], 400);
    }

    // Truncar log: solo conservar el último análisis
    @file_put_contents($GLOBALS['SIA_LOG'], '');

    _siaLog("resumen-sie inicio: ruta=$rutaModulo submodulo=$submodulo titulo=$titulo");

    // 1. Validar cupo mensual
    $cupo = _validarCupo($db, $topeMes);
    if (!$cupo['ok']) {
        _responder(['status' => 'error', 'error' => $cupo['mensaje']], 400);
    }

    // 2. Obtener archivos del modulo desde filesystem
    $archivos = _obtenerArchivosModulo($rutaModulo);
    if (empty($archivos)) {
        _responder(['status' => 'error', 'error' => 'No se encontraron archivos para procesar con IA.'], 400);
    }

    // 3. Consultar contexto personalizado
    $contexto = _consultarContexto($db, $submodulo, $titulo, $ctxDefault);

    // 4. Extraer texto de cada archivo
    $MAX_CHARS    = 400000; // ~100K tokens
    $MAX_ARCHIVOS = 20;

    $textosIncluidos   = [];
    $archivosExcluidos = [];
    $archivosSinTexto  = [];
    $diagnostico       = [];

    $lista = array_slice($archivos, 0, $MAX_ARCHIVOS);
    $temporalesWasabi = []; // Archivos descargados de Wasabi para limpiar al final

    foreach ($lista as $info) {
        $filePath = $info['path'];
        $fileName = $info['name'];
        $ext = strtolower(pathinfo($fileName, PATHINFO_EXTENSION));

        // Detectar stubs de Wasabi (0 bytes) y descargar temporalmente
        if (filesize($filePath) === 0) {
            $tmpPath = _descargarDesdeWasabi($filePath, $fileName);
            if ($tmpPath !== false) {
                $filePath = $tmpPath;
                $temporalesWasabi[] = $tmpPath;
                _siaLog("Wasabi download OK: $fileName -> $tmpPath (" . filesize($tmpPath) . " bytes)");
            } else {
                _siaLog("Wasabi download FAIL: $fileName (stub 0 bytes, no se pudo descargar)");
                $archivosSinTexto[] = $fileName;
                $diagnostico[] = ['archivo' => $fileName, 'estado' => 'stub_wasabi_no_descargado', 'chars' => 0];
                continue;
            }
        }

        // Extraer texto segun tipo
        $texto = '';
        if ($ext === 'pdf') {
            $texto = _extraerTextoPDF($filePath, $groqKey, $groqVision);
        } elseif (in_array($ext, ['xls', 'xlsx'])) {
            $texto = _extraerTextoExcel($filePath);
        }

        if (empty(trim($texto))) {
            $archivosSinTexto[] = $fileName;
            $diagnostico[] = ['archivo' => $fileName, 'estado' => 'sin_texto', 'chars' => 0];
            continue;
        }

        // Preprocesar (detecta ONU, etc.)
        $textoProcesado = _preprocesarTexto($texto, $fileName);

        if (strlen($textoProcesado) > $MAX_CHARS) {
            $archivosExcluidos[] = $fileName;
            $diagnostico[] = ['archivo' => $fileName, 'estado' => 'excluido_tamano', 'chars' => strlen($textoProcesado)];
            continue;
        }

        $textosIncluidos[] = "=== DOCUMENTO: $fileName ===\n$textoProcesado";
        $diagnostico[] = ['archivo' => $fileName, 'estado' => 'incluido', 'chars' => strlen($textoProcesado)];
    }

    // 5. Sin texto util: generar nota informativa
    $textoCompleto = implode("\n\n", $textosIncluidos);

    if (empty(trim($textoCompleto))) {
        $notas = [];
        if (!empty($archivosExcluidos)) {
            $notas[] = "Los siguientes documentos exceden el límite de procesamiento:\n"
                     . implode("\n", array_map(function($d){ return "  * $d"; }, $archivosExcluidos));
        }
        if (!empty($archivosSinTexto)) {
            $notas[] = "No se pudo extraer texto de los siguientes documentos (posiblemente escaneados):\n"
                     . implode("\n", array_map(function($d){ return "  * $d"; }, $archivosSinTexto));
        }
        if (!empty($notas)) {
            $resumenError = "**No fue posible generar un análisis con IA para esta actividad.**\n\n"
                          . implode("\n\n", $notas)
                          . "\n\n//Se recomienda subir los documentos en formato digital (no escaneado) o en formato Excel.//";
            _actualizarCupo($db);
            _guardarResumen($db, $resumenError, $rutaModulo);
            _limpiarTemporalesWasabi($temporalesWasabi);
            _responder([
                'status'               => 'success',
                'resumen_sie_ia'       => $resumenError,
                'tokens_in'            => 0,
                'tokens_out'           => 0,
                'diagnostico_archivos' => $diagnostico
            ]);
        }
        _responder(['status' => 'error', 'error' => 'No se pudo extraer texto de los archivos.'], 400);
    }

    // 6. Llamar a Groq para generar resumen
    $resultado = _llamarGroqChat($groqKey, $groqModel, $textoCompleto, $contexto);

    if ($resultado['error']) {
        _responder(['status' => 'error', 'error' => 'Error al generar resumen: ' . $resultado['error']], 500);
    }

    $resumen   = $resultado['resumen'];
    $tokensIn  = $resultado['tokens_in'];
    $tokensOut = $resultado['tokens_out'];

    // 7. Actualizar cupo y guardar historico
    _actualizarCupo($db);
    _guardarResumen($db, $resumen, $rutaModulo);

    // 8. Notas de archivos excluidos / sin texto
    if (!empty($archivosExcluidos)) {
        $resumen .= "\n\n----\n**Nota:** Fueron excluidos del análisis por tamaño:\n"
                  . implode("\n", array_map(function($d){ return "  * $d"; }, $archivosExcluidos));
    }
    if (!empty($archivosSinTexto)) {
        $resumen .= "\n\n----\n**Nota:** No se pudo extraer texto de:\n"
                  . implode("\n", array_map(function($d){ return "  * $d"; }, $archivosSinTexto));
    }

    // 9. Limpiar archivos temporales descargados de Wasabi
    _limpiarTemporalesWasabi($temporalesWasabi);

    _responder([
        'status'               => 'success',
        'resumen_sie_ia'       => $resumen,
        'tokens_in'            => $tokensIn,
        'tokens_out'           => $tokensOut,
        'diagnostico_archivos' => $diagnostico
    ]);
}

/**
 * POST accion=consulta-historico
 * Params: ruta_actividad
 */
function _accionConsultaHistorico() {
    $db = $GLOBALS['SIA_DB'];
    $rutaActividad = isset($_POST['ruta_actividad']) ? trim($_POST['ruta_actividad']) : '';
    if (empty($rutaActividad)) {
        _responder(['status' => 'error', 'error' => 'Falta ruta_actividad.'], 400);
    }
    $historico = _consultarHistorico($db, $rutaActividad);
    _responder($historico);
}


// #####################################################################
//  ARCHIVOS
// #####################################################################

/**
 * Escanea data/media/{namespace}/ buscando PDF, XLS, XLSX.
 * Replica la logica de getFilesDir() del plugin do/helper.php
 * pero retorna paths en vez de URLs.
 */
function _obtenerArchivosModulo($rutaModulo) {
    $namespace = (string)$rutaModulo;

    // Validar path traversal
    if (preg_match('/\.\./', $namespace) || preg_match('/[\/\\\\]/', $namespace)) {
        return [];
    }

    $mediaBase = realpath(bez_resolve_path('data/media'));
    if ($mediaBase === false) return [];

    // Construir lista de candidatos de rutas de media
    $candidatos = [];

    // 1. Namespace completo como carpeta
    $fullPath = str_replace(':', '/', $namespace);
    $candidatos[] = bez_resolve_path('data/media/') . $fullPath;

    // 2. Sin último segmento (proyectos normales: el último es la página)
    $colonPos = strrpos($namespace, ':');
    if ($colonPos !== false) {
        $baseNamespace = substr($namespace, 0, $colonPos);
        if (!empty($baseNamespace)) {
            $candidatos[] = bez_resolve_path('data/media/') . str_replace(':', '/', $baseNamespace);
        }
    }

    // 3. Proyectos administrativos: ruta real es varios/menuN/seccion
    //    proyectos:administrativos:ID:seccion → data/media/varios/menuID/seccion
    if (preg_match('/^proyectos:administrativos:(\d+):(.+)$/', $namespace, $m)) {
        $adminId = $m[1];
        $seccion = $m[2];
        // Candidato con seccion específica
        $candidatos[] = bez_resolve_path('data/media/') . 'varios/menu' . $adminId . '/' . $seccion;
        // Candidato carpeta completa del menu
        $candidatos[] = bez_resolve_path('data/media/') . 'varios/menu' . $adminId;
    }

    $path = null;
    foreach ($candidatos as $candidato) {
        $realPath = realpath($candidato);
        if ($realPath !== false && strpos($realPath, $mediaBase) === 0 && is_dir($candidato)) {
            $path = $candidato;
            _siaLog("_obtenerArchivosModulo: encontrado directorio: $candidato");
            break;
        }
    }

    if ($path === null) {
        _siaLog("_obtenerArchivosModulo: no se encontró directorio para '$rutaModulo'. Candidatos probados: " . implode(', ', $candidatos));
        return [];
    }

    $excluidos   = ['.htaccess', '_dummy', '.gitignore', '.gitkeep', 'Thumbs.db', '.DS_Store'];
    $extensiones = ['pdf', 'xls', 'xlsx'];
    $maxArchivos = 20;
    $maxTamano   = 50 * 1024 * 1024; // 50 MB (igual que Python)

    $archivos = [];

    $escanear = function($dir) use (&$escanear, &$archivos, $excluidos, $extensiones, $maxArchivos, $maxTamano) {
        $handle = @opendir($dir);
        if (!$handle) return;
        while (($file = readdir($handle)) !== false) {
            if ($file === '.' || $file === '..') continue;
            if (in_array($file, $excluidos)) continue;

            $filepath = $dir . '/' . $file;
            if (is_file($filepath)) {
                if (count($archivos) >= $maxArchivos) break;
                $ext = strtolower(pathinfo($file, PATHINFO_EXTENSION));
                if (!in_array($ext, $extensiones)) continue;
                if (filesize($filepath) > $maxTamano) continue;
                $archivos[] = ['path' => $filepath, 'name' => $file];
            } elseif (is_dir($filepath)) {
                if (count($archivos) < $maxArchivos) {
                    $escanear($filepath);
                }
            }
        }
        closedir($handle);
    };

    $escanear($path);
    return $archivos;
}

/**
 * Descarga un archivo stub (0 bytes) desde Wasabi a un directorio temporal.
 * Usa el helper de wasabisync para generar URL pre-firmada y descarga con cURL.
 *
 * @param string $localPath Ruta local del stub (0 bytes) en data/media/
 * @param string $fileName  Nombre del archivo
 * @return string|false     Ruta temporal del archivo descargado, o false si falla
 */
function _descargarDesdeWasabi($localPath, $fileName) {
    static $wsHelper = null;

    // Cargar helper wasabisync (cache estático para múltiples archivos)
    if ($wsHelper === null) {
        $wsHelper = plugin_load('helper', 'wasabisync');
    }
    if (!$wsHelper || !$wsHelper->isReady()) {
        _siaLog("_descargarDesdeWasabi: wasabisync helper no disponible o no ready");
        return false;
    }

    // Convertir ruta local a media_id de DokuWiki
    // Ejemplo: /home/.../data/media/proyectos/auditorias/.../archivo.pdf
    //       → proyectos:auditorias:...:archivo.pdf
    $mediaBase = realpath(bez_resolve_path('data/media/'));
    if ($mediaBase === false) {
        _siaLog("_descargarDesdeWasabi: no se pudo resolver data/media/");
        return false;
    }
    $dirReal = realpath(dirname($localPath));
    if ($dirReal === false) {
        _siaLog("_descargarDesdeWasabi: no se pudo resolver directorio de $localPath");
        return false;
    }
    $realLocal = $dirReal . '/' . basename($localPath);
    $relPath = str_replace($mediaBase . '/', '', $realLocal);
    $relPath = str_replace($mediaBase . '\\', '', $relPath); // Windows
    $mediaId = str_replace('/', ':', $relPath);

    // Buscar en SQLite de wasabisync
    $record = $wsHelper->dbLookup($mediaId);
    if (!$record || empty($record['wasabi_key'])) {
        _siaLog("_descargarDesdeWasabi: no se encontró registro Wasabi para media_id=$mediaId");
        return false;
    }

    // Generar URL pre-firmada (5 minutos)
    $url = $wsHelper->getPresignedUrl($record['wasabi_key'], 300);
    if (empty($url)) {
        _siaLog("_descargarDesdeWasabi: no se pudo generar URL pre-firmada para key=" . $record['wasabi_key']);
        return false;
    }

    // Descargar a temporal con nombre sanitizado
    $tmpDir = bez_resolve_path('data/tmp/');
    if (!is_dir($tmpDir)) @mkdir($tmpDir, 0755, true);
    $safeFileName = preg_replace('/[^a-zA-Z0-9._-]/', '_', $fileName);
    $tmpFile = $tmpDir . 'sia_wasabi_' . md5($mediaId) . '_' . $safeFileName;

    $ch = curl_init($url);
    $fp = @fopen($tmpFile, 'wb');
    if (!$fp) {
        _siaLog("_descargarDesdeWasabi: no se pudo abrir archivo temporal $tmpFile");
        curl_close($ch);
        return false;
    }
    curl_setopt($ch, CURLOPT_FILE, $fp);
    curl_setopt($ch, CURLOPT_FOLLOWLOCATION, true);
    curl_setopt($ch, CURLOPT_TIMEOUT, 120);
    curl_setopt($ch, CURLOPT_SSL_VERIFYPEER, true);
    curl_exec($ch);
    $httpCode = (int)curl_getinfo($ch, CURLINFO_HTTP_CODE);
    $curlErr = curl_error($ch);
    curl_close($ch);
    fclose($fp);

    if ($httpCode < 200 || $httpCode >= 300 || filesize($tmpFile) === 0) {
        _siaLog("_descargarDesdeWasabi: descarga falló HTTP=$httpCode err=$curlErr para $fileName");
        @unlink($tmpFile);
        return false;
    }

    return $tmpFile;
}

/**
 * Elimina archivos temporales descargados de Wasabi.
 * También limpia temporales huérfanos con más de 1 hora (protección contra crashes).
 */
function _limpiarTemporalesWasabi($temporales) {
    foreach ($temporales as $tmpFile) {
        if (file_exists($tmpFile)) {
            @unlink($tmpFile);
        }
    }
    // Limpiar temporales huérfanos (> 1 hora) por si un proceso anterior murió
    $tmpDir = bez_resolve_path('data/tmp/');
    $viejos = @glob($tmpDir . 'sia_wasabi_*');
    if ($viejos) {
        $limite = time() - 3600;
        foreach ($viejos as $viejo) {
            if (@filemtime($viejo) < $limite) {
                @unlink($viejo);
            }
        }
    }
}


// #####################################################################
//  EXTRACCION DE TEXTO
// #####################################################################

/**
 * Extrae texto de un PDF.
 * Pipeline: pdftotext → Ghostscript txtwrite → PHP puro → OCR (gs/Imagick + Groq Vision)
 */
function _extraerTextoPDF($filePath, $groqKey, $visionModel) {
    $texto = '';

    // --- Paso 1: pdftotext via shell ---
    $pdftotext = _findBinary('pdftotext');
    if (!empty($pdftotext)) {
        $cmd = escapeshellarg($pdftotext) . ' -layout ' . escapeshellarg($filePath) . ' -';
        $texto = (string)(_safeShellExec($cmd) ?: '');
        _siaLog("pdftotext shell: " . strlen(trim($texto)) . " chars");
    }

    // --- Paso 2: Ghostscript txtwrite (alternativa a pdftotext) ---
    if (empty(trim($texto))) {
        $gs = _findBinary('gs');
        if (!empty($gs)) {
            $tmpTxt = sys_get_temp_dir() . '/sia_gs_' . uniqid() . '.txt';
            $cmd = escapeshellarg($gs) . ' -sBATCH -dNOPAUSE -dQUIET -sDEVICE=txtwrite'
                 . ' -sOutputFile=' . escapeshellarg($tmpTxt)
                 . ' ' . escapeshellarg($filePath) . ' 2>/dev/null';
            _safeShellExec($cmd);
            if (file_exists($tmpTxt)) {
                $texto = (string)file_get_contents($tmpTxt);
                @unlink($tmpTxt);
            }
            _siaLog("Ghostscript txtwrite: " . strlen(trim($texto)) . " chars");
        }
    }

    // --- Paso 3: PHP puro si ningún binario disponible ---
    if (empty(trim($texto))) {
        $texto = _extraerTextoPDFpuro($filePath);
        _siaLog("PDF PHP puro: " . strlen(trim($texto)) . " chars");
    }

    // --- Contar paginas ---
    $numPaginas = _contarPaginasPDF($filePath);
    $charsPorPag = $numPaginas > 0 ? strlen(trim($texto)) / $numPaginas : strlen(trim($texto));

    // --- Paso 4: OCR si texto insuficiente ---
    if ($numPaginas > 0 && $charsPorPag < 50) {
        _siaLog("Texto insuficiente ($charsPorPag chars/pag), intentando OCR Vision");
        $textoOCR = _extraerTextoOCR($filePath, $groqKey, $visionModel);
        if (strlen(trim($textoOCR)) > strlen(trim($texto))) {
            $texto = $textoOCR;
            _siaLog("OCR Vision: " . strlen(trim($textoOCR)) . " chars");
        }
    }

    return trim($texto) ? $texto : '';
}

/**
 * Extractor de texto PDF en PHP puro (sin dependencias externas).
 * Funciona con PDFs digitales (texto embebido). No sirve para escaneados.
 * Descomprime streams FlateDecode y extrae texto de operadores BT/ET.
 */
function _extraerTextoPDFpuro($filePath) {
    $content = @file_get_contents($filePath);
    if (empty($content)) return '';

    $texto = '';

    // Buscar todos los stream...endstream
    if (!preg_match_all('/stream\r?\n(.*?)\r?\nendstream/s', $content, $matches)) {
        return '';
    }

    foreach ($matches[1] as $stream) {
        // Intentar descomprimir (FlateDecode es el mas comun)
        $decoded = @gzuncompress($stream);
        if ($decoded === false) {
            $decoded = @gzinflate($stream);
        }
        if ($decoded === false) {
            // Puede ser un stream sin comprimir
            $decoded = $stream;
        }

        // Buscar bloques de texto BT...ET
        if (!preg_match_all('/BT\s*(.*?)\s*ET/s', $decoded, $btBlocks)) {
            continue;
        }

        foreach ($btBlocks[1] as $block) {
            $lineText = '';

            // TJ: array de strings con kerning - [(texto) -100 (mas texto)] TJ
            if (preg_match_all('/\[(.*?)\]\s*TJ/s', $block, $tjArrays)) {
                foreach ($tjArrays[1] as $tjContent) {
                    if (preg_match_all('/\(([^)\\\\]*(?:\\\\.[^)\\\\]*)*)\)/', $tjContent, $strings)) {
                        $lineText .= implode('', $strings[1]);
                    }
                }
            }

            // Tj: string simple - (texto) Tj
            if (preg_match_all('/\(([^)\\\\]*(?:\\\\.[^)\\\\]*)*)\)\s*Tj/', $block, $tjSingle)) {
                $lineText .= implode('', $tjSingle[1]);
            }

            // ' operator: nueva linea + texto - (texto) '
            if (preg_match_all('/\(([^)\\\\]*(?:\\\\.[^)\\\\]*)*)\)\s*\'/', $block, $tjQuote)) {
                $lineText .= "\n" . implode('', $tjQuote[1]);
            }

            if (!empty(trim($lineText))) {
                $texto .= $lineText . "\n";
            }
        }
    }

    // Decodificar secuencias de escape PDF
    $texto = str_replace(
        ['\\(', '\\)', '\\\\', '\\n', '\\r', '\\t'],
        ['(',    ')',    '\\',  "\n",  "\r",  "\t"],
        $texto
    );

    // Limpiar caracteres no imprimibles (excepto newlines y tabs)
    $texto = preg_replace('/[^\x20-\x7E\x0A\x0D\x09\xC0-\xFF]/', ' ', $texto);
    // Colapsar espacios multiples
    $texto = preg_replace('/[ \t]{3,}/', '  ', $texto);
    // Limpiar lineas vacias consecutivas
    $texto = preg_replace('/\n{3,}/', "\n\n", $texto);

    return trim($texto);
}

/**
 * Cuenta paginas de un PDF usando pdfinfo.
 */
function _contarPaginasPDF($filePath) {
    // Opcion 1: pdfinfo
    $pdfinfo = _findBinary('pdfinfo');
    if (!empty($pdfinfo)) {
        $output = (string)(_safeShellExec(escapeshellarg($pdfinfo) . ' ' . escapeshellarg($filePath) . ' 2>/dev/null') ?: '');
        if (preg_match('/Pages:\s+(\d+)/', $output, $m)) {
            return (int)$m[1];
        }
    }
    // Opcion 2: Ghostscript
    $gs = _findBinary('gs');
    if (!empty($gs)) {
        $output = (string)(_safeShellExec(escapeshellarg($gs)
            . ' -q -dNODISPLAY -c "(' . str_replace("'", "\\'", $filePath) . ') (r) file runpdfbegin pdfpagecount = quit" 2>/dev/null') ?: '');
        $n = (int)trim($output);
        if ($n > 0) return $n;
    }
    // Opcion 3: Leer desde el contenido del PDF
    $content = @file_get_contents($filePath, false, null, 0, 50000);
    if ($content && preg_match('/\/N\s+(\d+)/', $content, $m)) {
        return max(1, (int)$m[1]);
    }
    // Fallback: estimar desde tamano
    return max(1, (int)(filesize($filePath) / 50000));
}

/**
 * OCR de PDF escaneado via Groq Vision API.
 * Renderiza paginas con pdftoppm → Ghostscript → Imagick → Groq Vision.
 */
function _extraerTextoOCR($filePath, $groqKey, $visionModel) {
    $maxPaginas = 10;
    $dpi = 120;
    $textos = [];

    $tmpDir = sys_get_temp_dir() . '/sia_ocr_' . uniqid();
    @mkdir($tmpDir, 0700, true);

    try {
        $rendered = false;

        // Opcion 1: pdftoppm (poppler-utils)
        $pdftoppm = _findBinary('pdftoppm');
        if (!empty($pdftoppm)) {
            $cmd = escapeshellarg($pdftoppm) . ' -png -r ' . $dpi . ' -gray'
                 . ' -l ' . $maxPaginas
                 . ' ' . escapeshellarg($filePath)
                 . ' ' . escapeshellarg($tmpDir . '/pag');
            _safeShellExec($cmd);
            $rendered = true;
            _siaLog("OCR render: pdftoppm");
        }

        // Opcion 2: Ghostscript
        if (!$rendered) {
            $gs = _findBinary('gs');
            if (!empty($gs)) {
                $cmd = escapeshellarg($gs) . ' -sBATCH -dNOPAUSE -dQUIET'
                     . ' -sDEVICE=pnggray -r' . $dpi
                     . ' -dFirstPage=1 -dLastPage=' . $maxPaginas
                     . ' -sOutputFile=' . escapeshellarg($tmpDir . '/pag-%03d.png')
                     . ' ' . escapeshellarg($filePath) . ' 2>/dev/null';
                _safeShellExec($cmd);
                $rendered = true;
                _siaLog("OCR render: ghostscript");
            }
        }

        // Opcion 3: Imagick PHP extension
        if (!$rendered && extension_loaded('imagick')) {
            try {
                $im = new \Imagick();
                $im->setResolution($dpi, $dpi);
                $im->readImage($filePath);
                $total = min($im->getNumberImages(), $maxPaginas);
                for ($p = 0; $p < $total; $p++) {
                    $im->setIteratorIndex($p);
                    $im->setImageFormat('png');
                    $im->setImageColorspace(\Imagick::COLORSPACE_GRAY);
                    $outPath = $tmpDir . '/pag-' . sprintf('%03d', $p + 1) . '.png';
                    $im->writeImage($outPath);
                }
                $im->clear();
                $im->destroy();
                $rendered = true;
                _siaLog("OCR render: imagick ($total pags)");
            } catch (\Throwable $e) {
                _siaLog("OCR imagick error: " . $e->getMessage(), 'WARN');
            }
        }

        if (!$rendered) {
            _siaLog("OCR: ningún renderizador disponible (pdftoppm/gs/imagick)");
            return '';
        }

        $imagenes = glob($tmpDir . '/pag-*.png');
        if (empty($imagenes)) {
            // pdftoppm puede generar sin guion (pag01.png)
            $imagenes = glob($tmpDir . '/pag*.png');
        }
        sort($imagenes);

        foreach ($imagenes as $i => $imgPath) {
            // Pausa anti rate-limit (2s entre llamadas)
            if ($i > 0) usleep(2000000);

            $imgData = @file_get_contents($imgPath);
            if (empty($imgData)) continue;

            $base64 = base64_encode($imgData);
            unset($imgData);

            $textoP = _llamarGroqVision($groqKey, $visionModel, $base64, $i + 1);
            if (!empty($textoP)) {
                $textos[] = $textoP;
            }
            unset($base64);
        }
    } finally {
        // Limpiar temporales
        $tmpFiles = glob($tmpDir . '/*');
        if ($tmpFiles) array_map('unlink', $tmpFiles);
        @rmdir($tmpDir);
    }

    return implode("\n", $textos);
}

/**
 * Extrae texto de Excel (.xls, .xlsx).
 * Intenta PhpSpreadsheet si esta disponible, sino ssconvert.
 */
function _extraerTextoExcel($filePath) {
    // Intentar PhpSpreadsheet
    $autoloads = [
        DOKU_INC . 'vendor/autoload.php',
        __DIR__ . '/../../../../vendor/autoload.php',
    ];
    foreach ($autoloads as $al) {
        if (file_exists($al)) { require_once $al; break; }
    }

    if (class_exists('\\PhpOffice\\PhpSpreadsheet\\IOFactory')) {
        try {
            $spreadsheet = \PhpOffice\PhpSpreadsheet\IOFactory::load($filePath);
            $texto = '';
            foreach ($spreadsheet->getAllSheets() as $sheet) {
                $texto .= $sheet->getTitle() . "\n";
                foreach ($sheet->toArray() as $row) {
                    $texto .= implode("\t", array_map(function($c) {
                        return $c !== null ? (string)$c : '';
                    }, $row)) . "\n";
                }
                $texto .= "\n";
            }
            return $texto;
        } catch (\Exception $e) {
            // Continuar con fallback
        }
    }

    // Fallback: ssconvert (gnumeric)
    $ssconvert = _findBinary('ssconvert');
    if (!empty($ssconvert)) {
        $tmpCsv = sys_get_temp_dir() . '/sia_xl_' . uniqid() . '.csv';
        _safeShellExec(escapeshellarg($ssconvert) . ' ' . escapeshellarg($filePath)
                  . ' ' . escapeshellarg($tmpCsv) . ' 2>/dev/null');
        if (file_exists($tmpCsv)) {
            $texto = file_get_contents($tmpCsv);
            @unlink($tmpCsv);
            return $texto;
        }
    }

    return '';
}


// #####################################################################
//  GROQ API
// #####################################################################

/**
 * Llama a Groq Chat API para generar resumen.
 * System prompt identico al de la version Python.
 */
function _llamarGroqChat($apiKey, $modelo, $texto, $contexto) {
    $systemPrompt = "Eres un experto en análisis de archivos técnicos, financieros y de resultados. "
        . "Tu tarea es interpretar, resumir y explicar información proveniente de documentos "
        . "como PDF, Excel, reportes contables y otros archivos estructurados. "
        . "Debes identificar y entender cálculos, tablas, resultados y tendencias de forma clara y ordenada. "
        . "Si los datos parecen incompletos o ambiguos, indica las posibles interpretaciones. "
        . "Siempre da respuestas en español. "
        . "Siempre da respuesta puntual y clara. "
        . "IMPORTANTE: Los documentos proporcionados están separados por encabezados '=== DOCUMENTO: nombre_archivo ==='. "
        . "Cada encabezado indica un documento independiente proporcionado por el usuario. "
        . "Si el nombre del archivo sugiere un tipo de documento (ej: 'acuerdo_confidencialidad.pdf' es un acuerdo de confidencialidad, "
        . "'notificacion_aceptacion.pdf' es una notificación de aceptación), considéralo como ese tipo de documento. "
        . "El texto después de cada encabezado fue extraído mediante OCR y puede contener errores de transcripción, "
        . "pero el documento SÍ fue proporcionado si aparece su encabezado. "
        . "FORMATO DE RESPUESTA: Usa exclusivamente sintaxis DokuWiki (NO markdown, NO HTML). "
        . "Reglas de formato DokuWiki: "
        . "- Negrilla: **texto** "
        . "- Cursiva: //texto// "
        . "- Títulos: ====== Título 1 ====== (6 signos = para h1, 5 para h2, 4 para h3, 3 para h4, 2 para h5) "
        . "- Listas: usar 2 espacios antes de * para cada item (  * item) "
        . "- Listas numeradas: usar 2 espacios antes de - para cada item (  - item) "
        . "- Tablas: las líneas de tabla DEBEN empezar pegadas al margen izquierdo SIN espacios antes. "
        . "Cabeceras: ^ Encabezado 1 ^ Encabezado 2 ^ y filas: | Celda 1 | Celda 2 | "
        . "NUNCA pongas espacios antes de ^ o | al inicio de línea "
        . "- Línea horizontal: ---- "
        . "- Tachado: <del>texto</del> "
        . "- NO uses # para títulos, NO uses * sin 2 espacios previos para listas, "
        . "NO uses | sin ^ para cabeceras de tablas, NO uses ```bloques de código```.";

    $body = json_encode([
        'model'    => $modelo,
        'messages' => [
            ['role' => 'system', 'content' => $systemPrompt],
            ['role' => 'user',   'content' => "$contexto: $texto"]
        ]
    ], JSON_UNESCAPED_UNICODE | JSON_INVALID_UTF8_SUBSTITUTE);

    $ch = curl_init('https://api.groq.com/openai/v1/chat/completions');
    curl_setopt_array($ch, [
        CURLOPT_POST           => true,
        CURLOPT_POSTFIELDS     => $body,
        CURLOPT_RETURNTRANSFER => true,
        CURLOPT_TIMEOUT        => 180,
        CURLOPT_CONNECTTIMEOUT => 15,
        CURLOPT_HTTPHEADER     => [
            'Content-Type: application/json',
            'Authorization: Bearer ' . $apiKey
        ]
    ]);

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

    if ($curlError) {
        _siaLog("Groq Chat curl error: $curlError", 'ERROR');
        return ['error' => "Error de conexión con Groq: $curlError", 'resumen' => '', 'tokens_in' => 0, 'tokens_out' => 0];
    }

    $data = json_decode($response, true);

    if ($httpCode !== 200 || !isset($data['choices'][0]['message']['content'])) {
        $detail = isset($data['error']['message']) ? $data['error']['message'] : "HTTP $httpCode";
        _siaLog("Groq Chat error: $detail (HTTP $httpCode)", 'ERROR');
        return ['error' => "Error Groq: $detail", 'resumen' => '', 'tokens_in' => 0, 'tokens_out' => 0];
    }

    $resumen = $data['choices'][0]['message']['content'];

    // Post-procesamiento: limpiar espacios al inicio de tablas DokuWiki
    $resumen = preg_replace('/^\s+(\^)/m', '$1', $resumen);
    $resumen = preg_replace('/^\s+(\|)/m', '$1', $resumen);

    $tokensIn  = isset($data['usage']['prompt_tokens'])     ? $data['usage']['prompt_tokens']     : 0;
    $tokensOut = isset($data['usage']['completion_tokens']) ? $data['usage']['completion_tokens'] : 0;

    _siaLog("Groq Chat OK: modelo=$modelo tokens_in=$tokensIn tokens_out=$tokensOut");
    return ['error' => null, 'resumen' => $resumen, 'tokens_in' => $tokensIn, 'tokens_out' => $tokensOut];
}

/**
 * Llama a Groq Vision API para OCR de una imagen.
 */
function _llamarGroqVision($apiKey, $modelo, $imageBase64, $numPagina) {
    $body = json_encode([
        'model'      => $modelo,
        'messages'   => [
            [
                'role'    => 'user',
                'content' => [
                    [
                        'type' => 'text',
                        'text' => 'Extrae TODO el texto de esta imagen de documento escaneado. '
                                . 'Transcribe el texto exactamente como aparece, manteniendo la estructura. '
                                . 'Si hay tablas, represéntalas con separadores. '
                                . 'Solo devuelve el texto extraído, sin comentarios ni explicaciones.'
                    ],
                    [
                        'type'      => 'image_url',
                        'image_url' => ['url' => "data:image/png;base64,$imageBase64"]
                    ]
                ]
            ]
        ],
        'max_tokens' => 4096
    ], JSON_UNESCAPED_UNICODE | JSON_INVALID_UTF8_SUBSTITUTE);

    $ch = curl_init('https://api.groq.com/openai/v1/chat/completions');
    curl_setopt_array($ch, [
        CURLOPT_POST           => true,
        CURLOPT_POSTFIELDS     => $body,
        CURLOPT_RETURNTRANSFER => true,
        CURLOPT_TIMEOUT        => 60,
        CURLOPT_CONNECTTIMEOUT => 15,
        CURLOPT_HTTPHEADER     => [
            'Content-Type: application/json',
            'Authorization: Bearer ' . $apiKey
        ]
    ]);

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

    if ($httpCode !== 200) return '';

    $data = json_decode($response, true);
    return isset($data['choices'][0]['message']['content']) ? $data['choices'][0]['message']['content'] : '';
}


// #####################################################################
//  BASE DE DATOS
// #####################################################################

function _initDB($db) {
    $db->exec("CREATE TABLE IF NOT EXISTS ia_consumo (
        id INTEGER PRIMARY KEY AUTOINCREMENT,
        usado_al_mes INTEGER DEFAULT 0,
        ultimo_uso TEXT,
        mes_actual INTEGER,
        anio_actual INTEGER
    )");

    $db->exec("CREATE TABLE IF NOT EXISTS ia_historico (
        id INTEGER PRIMARY KEY AUTOINCREMENT,
        ruta_actividad TEXT,
        resumen_ia TEXT,
        fecha_resumen TEXT DEFAULT (datetime('now','localtime')),
        usuario TEXT
    )");

    $db->exec("CREATE TABLE IF NOT EXISTS ia_contexto (
        id INTEGER PRIMARY KEY AUTOINCREMENT,
        tipo_proyecto TEXT NOT NULL,
        actividad TEXT NOT NULL,
        contexto_ia TEXT NOT NULL DEFAULT 'Necesito me generes un resumen corto, claro y completo del siguiente documento',
        fecha_creacion TEXT DEFAULT (datetime('now','localtime'))
    )");

    // Auto-poblar contextos si la tabla está vacía (para nuevas instalaciones)
    $count = $db->query("SELECT COUNT(*) FROM ia_contexto")->fetchColumn();
    if ((int)$count === 0) {
        _seedContextos($db);
    }
}

/**
 * Puebla ia_contexto con los 33 prompts predeterminados.
 * Los datos vienen de ia_contextos_seed.php (generado desde MySQL systeng3_sieia.contexto_consumo).
 * Se ejecuta automáticamente en la primera inicialización de cada sistema.
 */
function _seedContextos($db) {
    $seedFile = __DIR__ . '/ia_contextos_seed.php';
    if (!file_exists($seedFile)) {
        _siaLog("Seed file no encontrado: $seedFile", 'WARN');
        return;
    }

    $defaults = require $seedFile;
    if (!is_array($defaults) || empty($defaults)) {
        _siaLog("Seed file vacío o inválido", 'WARN');
        return;
    }

    $stmt = $db->prepare("INSERT INTO ia_contexto (tipo_proyecto, actividad, contexto_ia) VALUES (?, ?, ?)");
    $db->beginTransaction();
    $ok = 0;
    foreach ($defaults as $row) {
        try {
            $stmt->execute([$row['tipo_proyecto'], $row['actividad'], $row['contexto_ia']]);
            $ok++;
        } catch (\Throwable $e) {
            _siaLog("Error seed contexto [{$row['actividad']}]: " . $e->getMessage(), 'ERROR');
        }
    }
    $db->commit();
    _siaLog("ia_contexto auto-poblada: $ok de " . count($defaults) . " registros insertados");
}

function _validarCupo($db, $tope) {
    $mesActual  = (int)date('n');
    $anioActual = (int)date('Y');

    $stmt = $db->query("SELECT * FROM ia_consumo LIMIT 1");
    $reg  = $stmt->fetch(\PDO::FETCH_ASSOC);

    if (!$reg) {
        $ins = $db->prepare("INSERT INTO ia_consumo (usado_al_mes, ultimo_uso, mes_actual, anio_actual) "
                           . "VALUES (0, datetime('now','localtime'), ?, ?)");
        $ins->execute([$mesActual, $anioActual]);
        return ['ok' => true, 'mensaje' => 'ok'];
    }

    // Resetear si cambio mes o anio
    if ((int)$reg['mes_actual'] !== $mesActual || (int)$reg['anio_actual'] !== $anioActual) {
        $upd = $db->prepare("UPDATE ia_consumo SET usado_al_mes = 0, mes_actual = ?, "
                           . "anio_actual = ?, ultimo_uso = datetime('now','localtime')");
        $upd->execute([$mesActual, $anioActual]);
        return ['ok' => true, 'mensaje' => 'ok'];
    }

    if ((int)$reg['usado_al_mes'] >= $tope) {
        return ['ok' => false, 'mensaje' => "Límite mensual alcanzado ($tope consultas). Intente el próximo mes."];
    }

    return ['ok' => true, 'mensaje' => 'ok'];
}

function _actualizarCupo($db) {
    $db->exec("UPDATE ia_consumo SET usado_al_mes = usado_al_mes + 1, "
            . "ultimo_uso = datetime('now','localtime')");
}

function _guardarResumen($db, $resumen, $rutaActividad) {
    $usuario = isset($_SERVER['REMOTE_USER']) ? $_SERVER['REMOTE_USER'] : '';
    $stmt = $db->prepare("INSERT INTO ia_historico (ruta_actividad, resumen_ia, usuario) VALUES (?, ?, ?)");
    $stmt->execute([$rutaActividad, $resumen, $usuario]);
}

function _consultarHistorico($db, $rutaActividad) {
    $stmt = $db->prepare("SELECT resumen_ia, fecha_resumen FROM ia_historico "
                        . "WHERE ruta_actividad = ? ORDER BY fecha_resumen DESC");
    $stmt->execute([$rutaActividad]);
    return $stmt->fetchAll(\PDO::FETCH_ASSOC);
}

function _consultarContexto($db, $submodulo, $titulo, $default) {
    $act = trim($submodulo);
    $tipo = trim($titulo);

    // 1. Busqueda exacta
    $stmt = $db->prepare("SELECT contexto_ia FROM ia_contexto WHERE actividad = ? AND tipo_proyecto = ?");
    $stmt->execute([$act, $tipo]);
    $reg = $stmt->fetch(\PDO::FETCH_ASSOC);

    // 2. Fallback: tipo_proyecto base (ej: "Revisoría Fiscal - P.E.I." → buscar "Revisoría Fiscal")
    if (!$reg && strpos($tipo, ' - ') !== false) {
        $tipoBase = trim(explode(' - ', $tipo)[0]);
        $stmt = $db->prepare("SELECT contexto_ia FROM ia_contexto WHERE actividad = ? AND tipo_proyecto = ?");
        $stmt->execute([$act, $tipoBase]);
        $reg = $stmt->fetch(\PDO::FETCH_ASSOC);
        if ($reg) _siaLog("Contexto encontrado con tipo base [$tipoBase] para actividad [$act]");
    }

    // 3. Fallback: solo por actividad (ignorar tipo_proyecto)
    if (!$reg) {
        $stmt = $db->prepare("SELECT contexto_ia FROM ia_contexto WHERE actividad = ? ORDER BY id LIMIT 1");
        $stmt->execute([$act]);
        $reg = $stmt->fetch(\PDO::FETCH_ASSOC);
        if ($reg) _siaLog("Contexto encontrado solo por actividad [$act] (sin tipo_proyecto exacto)");
    }

    if (!$reg) {
        _siaLog("CONTEXTO NO ENCONTRADO - submodulo=[$act] titulo=[$tipo]", 'WARN');
    }

    return $reg ? $reg['contexto_ia'] : $default;
}


// #####################################################################
//  UTILIDADES
// #####################################################################

/**
 * Preprocesa texto antes de enviarlo a la IA.
 * Detecta documentos conocidos (Lista ONU) y los reduce.
 */
function _preprocesarTexto($texto, $filename = '') {
    if (strpos($texto, 'United Nations Security Council Consolidated List') !== false) {
        return _extraerNombresONU($texto);
    }
    return $texto;
}

function _extraerNombresONU($texto) {
    preg_match_all('/Name:\s*1:\s*(.+?)\s+2:\s*(.+?)\s+3:\s*(.+?)\s+4:\s*(.+?)(?:\n|$)/', $texto, $matches, PREG_SET_ORDER);

    $nombres = [];
    foreach ($matches as $m) {
        $partes = [];
        for ($i = 1; $i <= 4; $i++) {
            $p = trim($m[$i]);
            if (strtolower($p) !== 'na' && !empty($p)) {
                $partes[] = $p;
            }
        }
        if (!empty($partes)) $nombres[] = implode(' ', $partes);
    }

    $total = count($nombres);
    return "[FUENTE CONSULTADA: Lista Consolidada de Sanciones del Consejo de Seguridad de la ONU]\n"
         . "Se verifico la Lista Consolidada de Sanciones de la ONU que contiene $total "
         . "personas y entidades sancionadas por el Consejo de Seguridad de las Naciones Unidas.\n"
         . "Las personas investigadas en los demas documentos adjuntos fueron verificadas contra esta lista.\n";
}

/**
 * Busca un binario por nombre. Intenta which/command -v, y si falla
 * busca en paths comunes de Linux (cPanel, CloudLinux, etc).
 * En PHP-FPM el PATH suele ser minimo y which no encuentra nada.
 */
function _findBinary($name) {
    static $cache = [];
    if (isset($cache[$name])) return $cache[$name];

    // 1. Intentar which y command -v
    $path = trim(_safeShellExec("which $name 2>/dev/null") ?: '');
    if (empty($path)) {
        $path = trim(_safeShellExec("command -v $name 2>/dev/null") ?: '');
    }

    // 2. Buscar en paths comunes si which fallo
    if (empty($path)) {
        $commonPaths = [
            "/usr/bin/$name",
            "/usr/local/bin/$name",
            "/bin/$name",
            "/usr/sbin/$name",
            "/opt/cpanel/3rdparty/bin/$name",
            "/opt/alt/python3/bin/$name",
        ];
        foreach ($commonPaths as $p) {
            if (@is_file($p) && @is_executable($p)) {
                $path = $p;
                break;
            }
        }
    }

    // 3. Verificar que funciona
    if (!empty($path)) {
        $test = trim(_safeShellExec(escapeshellarg($path) . ' -v 2>&1') ?: '');
        if (empty($test)) {
            // Algunos binarios no tienen -v, verificar con --version o --help
            $test = trim(_safeShellExec(escapeshellarg($path) . ' --version 2>&1') ?: '');
        }
        _siaLog("_findBinary($name): encontrado en $path");
    } else {
        _siaLog("_findBinary($name): NO encontrado");
    }

    $cache[$name] = $path;
    return $path;
}

/**
 * Wrapper seguro para ejecucion de comandos shell.
 * Intenta shell_exec → exec → proc_open, usa el primero disponible.
 * En hostings compartidos shell_exec suele estar bloqueado pero exec no.
 */
function _safeShellExec($cmd) {
    static $method = null;
    if ($method === null) {
        $disabled = array_map('trim', explode(',', strtolower(ini_get('disable_functions'))));
        if (function_exists('shell_exec') && !in_array('shell_exec', $disabled)) {
            $method = 'shell_exec';
        } elseif (function_exists('exec') && !in_array('exec', $disabled)) {
            $method = 'exec';
        } elseif (function_exists('proc_open') && !in_array('proc_open', $disabled)) {
            $method = 'proc_open';
        } else {
            $method = 'none';
        }
        _siaLog("_safeShellExec: metodo seleccionado = $method");
    }

    switch ($method) {
        case 'shell_exec':
            return @\shell_exec($cmd);

        case 'exec':
            $output = [];
            @\exec($cmd, $output);
            return implode("\n", $output);

        case 'proc_open':
            $desc = [1 => ['pipe', 'w'], 2 => ['pipe', 'w']];
            $proc = @\proc_open($cmd, $desc, $pipes);
            if (!is_resource($proc)) return '';
            $stdout = stream_get_contents($pipes[1]);
            fclose($pipes[1]);
            fclose($pipes[2]);
            \proc_close($proc);
            return $stdout;

        default:
            return '';
    }
}

/**
 * Responde JSON y termina ejecucion.
 */
function _responder($data, $code = 200) {
    http_response_code($code);
    echo json_encode($data, JSON_UNESCAPED_UNICODE);
    exit;
}
