<?php
/**
 * @intranet-file common/plugin_store.php
 * @intranet-file-version 2026.03.13.1
 * @intranet-file-date 2026-03-13
 * @intranet-file-owner CSIF Intranet
 */
/**
 * common/plugin_store.php
 *
 * Unificado con el esquema real:
 * - intranet_plugins.enabled
 * - intranet_plugins.source_url
 * - intranet_apps.is_enabled
 */

function plugin_store_base_url(): string
{
    foreach (['/etc/intranet/intranet-config.php', '/etc/csif/intranet-config.php'] as $cfg) {
        if (!is_file($cfg)) {
            continue;
        }
        require_once $cfg;
        if (defined('PLUGIN_STORE_BASE_URL') && PLUGIN_STORE_BASE_URL) {
            return rtrim((string) PLUGIN_STORE_BASE_URL, '/') . '/';
        }
        break;
    }
    return 'https://sede.csifsevilla.es/plugin-store/';
}

function plugin_shell_exec_available(): bool
{
    return function_exists('shell_exec') && stripos((string) ini_get('disable_functions'), 'shell_exec') === false;
}

function plugin_powershell_quote(string $value): string
{
    return "'" . str_replace("'", "''", $value) . "'";
}

function plugin_http_download_via_php_curl(string $url, string $destPath, int $timeout, array &$errors): bool
{
    if (!function_exists('curl_init')) {
        return false;
    }

    $fh = @fopen($destPath, 'wb');
    if (!$fh) {
        $errors[] = "No se pudo abrir el fichero temporal: $destPath";
        return false;
    }

    $ch = curl_init($url);
    curl_setopt_array($ch, [
        CURLOPT_FILE => $fh,
        CURLOPT_FOLLOWLOCATION => true,
        CURLOPT_MAXREDIRS => 5,
        CURLOPT_TIMEOUT => $timeout,
        CURLOPT_USERAGENT => 'CSIF-Intranet-PluginManager/1.1.1',
        CURLOPT_SSL_VERIFYPEER => true,
        CURLOPT_SSL_VERIFYHOST => 2,
    ]);

    $ok = curl_exec($ch);
    $http = (int) curl_getinfo($ch, CURLINFO_HTTP_CODE);
    $err = curl_error($ch);
    curl_close($ch);
    fclose($fh);

    if ($ok === false || $http >= 400) {
        @unlink($destPath);
        $errors[] = 'No se pudo descargar mediante PHP cURL: ' . ($err ?: ('HTTP ' . $http));
        return false;
    }

    return is_file($destPath) && filesize($destPath) > 0;
}

function plugin_http_download_via_fopen(string $url, string $destPath, int $timeout, array &$errors): bool
{
    $scheme = strtolower((string) parse_url($url, PHP_URL_SCHEME));
    if (!ini_get('allow_url_fopen')) {
        return false;
    }
    if ($scheme === 'https' && !extension_loaded('openssl')) {
        return false;
    }

    $ctx = stream_context_create([
        'http' => [
            'timeout' => $timeout,
            'user_agent' => 'CSIF-Intranet-PluginManager/1.1.1',
            'follow_location' => 1,
        ],
        'ssl' => [
            'verify_peer' => true,
            'verify_peer_name' => true,
        ],
    ]);

    $raw = @file_get_contents($url, false, $ctx);
    if ($raw === false || $raw === '') {
        return false;
    }

    if (@file_put_contents($destPath, $raw) === false) {
        $errors[] = "No se pudo escribir el fichero temporal: $destPath";
        return false;
    }

    return true;
}

function plugin_http_download_via_curl_binary(string $url, string $destPath, int $timeout, array &$errors): bool
{
    if (!plugin_shell_exec_available()) {
        return false;
    }

    $version = @shell_exec('curl.exe --version 2>NUL');
    if (!is_string($version) || trim($version) === '') {
        return false;
    }

    $cmd = 'curl.exe --fail --silent --show-error --location --max-time ' . (int) $timeout
        . ' --output ' . escapeshellarg($destPath)
        . ' ' . escapeshellarg($url);
    exec($cmd, $out, $rc);

    if ($rc !== 0 || !is_file($destPath) || filesize($destPath) <= 0) {
        @unlink($destPath);
        $errors[] = 'No se pudo descargar mediante curl.exe.';
        return false;
    }

    return true;
}

function plugin_http_download_via_powershell(string $url, string $destPath, int $timeout, array &$errors): bool
{
    if (DIRECTORY_SEPARATOR !== '\\' || !plugin_shell_exec_available()) {
        return false;
    }

    $probe = @shell_exec('powershell -NoProfile -Command "$PSVersionTable.PSVersion.ToString()"');
    if (!is_string($probe) || trim($probe) === '') {
        return false;
    }

    $psCommand = "\$ProgressPreference = 'SilentlyContinue';"
        . ' [Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12;'
        . ' Invoke-WebRequest -UseBasicParsing -TimeoutSec ' . (int) $timeout
        . ' -Uri ' . plugin_powershell_quote($url)
        . ' -OutFile ' . plugin_powershell_quote($destPath) . ';';

    $cmd = 'powershell -NoProfile -Command ' . escapeshellarg($psCommand);
    exec($cmd, $out, $rc);

    if ($rc !== 0 || !is_file($destPath) || filesize($destPath) <= 0) {
        @unlink($destPath);
        $errors[] = 'No se pudo descargar mediante PowerShell.';
        return false;
    }

    return true;
}

function plugin_http_download(string $url, string $destPath, int $timeout, array &$errors): bool
{
    if (plugin_http_download_via_php_curl($url, $destPath, $timeout, $errors)) {
        return true;
    }
    if (plugin_http_download_via_fopen($url, $destPath, $timeout, $errors)) {
        return true;
    }
    if (plugin_http_download_via_curl_binary($url, $destPath, $timeout, $errors)) {
        return true;
    }
    if (plugin_http_download_via_powershell($url, $destPath, $timeout, $errors)) {
        return true;
    }

    return false;
}

function plugin_store_fetch_index(array &$errors): ?array
{
    $url = rtrim(plugin_store_base_url(), '/') . '/index.php?format=json';

    $ctx = stream_context_create([
        'http' => [
            'timeout' => 12,
            'user_agent' => 'CSIF-Intranet-PluginManager/1.1.1',
        ],
        'ssl' => [
            'verify_peer' => true,
            'verify_peer_name' => true,
        ],
    ]);

    $raw = @file_get_contents($url, false, $ctx);
    if ($raw === false || trim((string) $raw) === '') {
        $errors[] = "No se pudo leer el catálogo del store: $url";
        return null;
    }

    $data = json_decode($raw, true);
    if (json_last_error() !== JSON_ERROR_NONE || !is_array($data)) {
        $errors[] = "El catálogo del store no es JSON válido: $url";
        return null;
    }

    return $data;
}

function plugin_sha256_matches_catalog(string $expected, string $actual): bool
{
    $expected = strtolower(trim($expected));
    $actual = strtolower(trim($actual));

    if (!preg_match('/^[0-9a-f]{64}$/', $actual)) {
        return false;
    }
    if (!preg_match('/^[0-9a-f]{62,64}$/', $expected)) {
        return false;
    }
    if (hash_equals($expected, $actual)) {
        return true;
    }

    $missingChars = strlen($actual) - strlen($expected);
    if ($missingChars < 1 || $missingChars > 2) {
        return false;
    }

    // Compatibilidad con catalogos publicados con 1 o 2 caracteres iniciales truncados.
    return hash_equals($expected, substr($actual, $missingChars));
}

function plugin_download_to_tmp(string $url, string $sha256_expected, array &$errors, bool $allow_sha_mismatch = false): ?string
{
    $tmp = tempnam(sys_get_temp_dir(), 'plugin_zip_');
    if (!$tmp) {
        $errors[] = "No se pudo crear fichero temporal";
        return null;
    }

    $ctx = stream_context_create([
        'http' => [
            'timeout' => 30,
            'user_agent' => 'CSIF-Intranet-PluginManager/1.1.1',
            'follow_location' => 1,
            'max_redirects' => 5,
        ],
        'ssl' => [
            'verify_peer' => true,
            'verify_peer_name' => true,
        ],
    ]);

    $raw = @file_get_contents($url, false, $ctx);
    if ($raw === false || strlen((string) $raw) < 10) {
        @unlink($tmp);
        $errors[] = "No se pudo descargar el ZIP: $url";
        return null;
    }

    file_put_contents($tmp, $raw);

    if ($sha256_expected !== '') {
        $hash = strtolower((string) hash_file('sha256', $tmp));
        $expected = strtolower(trim($sha256_expected));
        $hashMatches = plugin_sha256_matches_catalog($expected, $hash);

        if (!$hashMatches && $allow_sha_mismatch) {
            $errors[] = "Aviso: SHA256 remoto inconsistente para $url. Se continua solo para comparacion de contenido.";
        }

        if (!$hashMatches && !$allow_sha_mismatch) {
            @unlink($tmp);
            $errors[] = "SHA256 no coincide para $url";
            return null;
        }
    }

    return $tmp;
}

function plugin_safe_join(string $base, string $child): string
{
    $base = rtrim($base, '/\\');
    $child = ltrim($child, '/\\');
    return $base . DIRECTORY_SEPARATOR . $child;
}

function plugin_rrmdir(string $dir): void
{
    if (!is_dir($dir)) {
        return;
    }

    $it = new RecursiveIteratorIterator(
        new RecursiveDirectoryIterator($dir, FilesystemIterator::SKIP_DOTS),
        RecursiveIteratorIterator::CHILD_FIRST
    );

    foreach ($it as $file) {
        if ($file->isDir()) {
            @rmdir($file->getPathname());
        } else {
            @unlink($file->getPathname());
        }
    }

    @rmdir($dir);
}



function plugin_system_unzip(string $zipPath, string $destDir, array &$errors): bool
{
    $bin = trim((string) @shell_exec('command -v unzip 2>/dev/null'));
    if ($bin === '') {
        $errors[] = 'No está disponible unzip en el sistema.';
        return false;
    }
    @mkdir($destDir, 0775, true);
    $cmd = $bin . ' -qq ' . escapeshellarg($zipPath) . ' -d ' . escapeshellarg($destDir) . ' 2>&1';
    exec($cmd, $out, $rc);
    if ($rc !== 0) {
        $errors[] = 'No se pudo descomprimir el ZIP mediante unzip.';
        return false;
    }
    return true;
}

function plugin_system_zip_dir(string $sourceDir, string $zipPath, array &$errors): bool
{
    $bin = trim((string) @shell_exec('command -v zip 2>/dev/null'));
    if ($bin === '') {
        $errors[] = 'No está disponible zip en el sistema.';
        return false;
    }
    @mkdir(dirname($zipPath), 0775, true);
    if (is_file($zipPath)) @unlink($zipPath);
    $cmd = 'cd ' . escapeshellarg($sourceDir) . ' && ' . $bin . ' -qr ' . escapeshellarg($zipPath) . ' . 2>/dev/null';
    exec($cmd, $out, $rc);
    if ($rc !== 0 || !is_file($zipPath)) {
        $errors[] = 'No se pudo crear el ZIP mediante zip.';
        return false;
    }
    return true;
}

function plugin_extract_zip(string $zipPath, string $destDir, array &$errors): bool
{
    if (!class_exists('ZipArchive')) {
        return plugin_system_unzip($zipPath, $destDir, $errors);
    }

    $zip = new ZipArchive();
    if ($zip->open($zipPath) !== true) {
        $errors[] = "No se pudo abrir ZIP";
        return false;
    }

    @mkdir($destDir, 0775, true);
    $destReal = realpath($destDir) ?: $destDir;

    for ($i = 0; $i < $zip->numFiles; $i++) {
        $name = $zip->getNameIndex($i);
        if ($name === false) {
            continue;
        }

        $name = str_replace('\\', '/', $name);

        if (strpos($name, "\0") !== false) {
            continue;
        }

        if (preg_match('~(^|/)\.\.(?:/|$)~', $name)) {
            $errors[] = "ZIP contiene ruta insegura: $name";
            $zip->close();
            return false;
        }

        $target = plugin_safe_join($destReal, $name);

        if (substr($name, -1) === '/') {
            @mkdir($target, 0775, true);
            continue;
        }

        @mkdir(dirname($target), 0775, true);
        $stream = $zip->getStream($name);
        if (!$stream) {
            continue;
        }

        $out = fopen($target, 'wb');
        if ($out) {
            while (!feof($stream)) {
                fwrite($out, fread($stream, 8192));
            }
            fclose($out);
        }

        fclose($stream);
    }

    $zip->close();
    return true;
}

function plugin_read_manifest(string $pluginDir, array &$errors): ?array
{
    $path = rtrim($pluginDir, '/\\') . DIRECTORY_SEPARATOR . 'plugin.json';

    if (!is_file($path)) {
        $errors[] = "No existe plugin.json en $pluginDir";
        return null;
    }

    $raw = (string) file_get_contents($path);
    $data = json_decode($raw, true);

    if (json_last_error() !== JSON_ERROR_NONE || !is_array($data)) {
        $errors[] = "plugin.json inválido en $pluginDir";
        return null;
    }

    return $data;
}

function plugin_exec_sql_file(PDO $pdo, string $sqlFile, array &$errors): bool
{
    if (!is_file($sqlFile)) {
        return true;
    }

    $sql = (string) file_get_contents($sqlFile);

    $sql = preg_replace('#/\*.*?\*/#s', '', $sql);

    $clean = [];
    foreach (preg_split("/\r\n|\n|\r/", $sql) as $ln) {
        $trim = ltrim($ln);
        if (substr($trim, 0, 2) === '--') {
            continue;
        }
        $clean[] = $ln;
    }
    $sql = implode("\n", $clean);

    $stmts = array_filter(array_map('trim', explode(';', $sql)));

    try {
        foreach ($stmts as $st) {
            if ($st === '') {
                continue;
            }
            $pdo->exec($st);
        }
        return true;
    } catch (Throwable $e) {
        $errors[] = "SQL error: " . $e->getMessage();
        return false;
    }
}

function plugin_upsert_installed(PDO $pdo, string $id, string $name, string $version, int $enabled, string $sourceUrl = '', string $sha256 = ''): void
{
    $st = $pdo->prepare("SELECT id FROM intranet_plugins WHERE id=? LIMIT 1");
    $st->execute([$id]);
    $exists = (bool) $st->fetch(PDO::FETCH_ASSOC);

    if ($exists) {
        $up = $pdo->prepare("
            UPDATE intranet_plugins
            SET
                name=?,
                version=?,
                enabled=?,
                source_url=?,
                sha256=?,
                updated_at=NOW()
            WHERE id=?
        ");
        $up->execute([
            $name,
            $version,
            $enabled ? 1 : 0,
            $sourceUrl !== '' ? $sourceUrl : null,
            $sha256 !== '' ? $sha256 : null,
            $id
        ]);
        return;
    }

    $ins = $pdo->prepare("
        INSERT INTO intranet_plugins
        (id, name, version, enabled, source_url, sha256, installed_at, updated_at)
        VALUES (?, ?, ?, ?, ?, ?, NOW(), NOW())
    ");
    $ins->execute([
        $id,
        $name,
        $version,
        $enabled ? 1 : 0,
        $sourceUrl !== '' ? $sourceUrl : null,
        $sha256 !== '' ? $sha256 : null
    ]);
}

function plugin_installed_all(PDO $pdo): array
{
    $out = [];
    $rs = $pdo->query("SELECT * FROM intranet_plugins ORDER BY name, id");
    while ($row = $rs->fetch(PDO::FETCH_ASSOC)) {
        $out[(string) $row['id']] = $row;
    }
    return $out;
}

function plugin_delete_installed(PDO $pdo, string $id): void
{
    $st = $pdo->prepare("DELETE FROM intranet_plugins WHERE id=?");
    $st->execute([$id]);
}

function plugin_store_join_url(string $base, string $path): string
{
    $base = rtrim($base, '/');

    if (preg_match('~^https?://~i', $path)) {
        return $path;
    }

    if (strpos($path, '/plugin-store/') === 0) {
        $u = parse_url($base);
        $origin = ($u['scheme'] ?? 'https') . '://' . ($u['host'] ?? '');
        if (isset($u['port'])) {
            $origin .= ':' . $u['port'];
        }
        return $origin . $path;
    }

    if (strpos($path, '/') === 0) {
        $u = parse_url($base);
        $origin = ($u['scheme'] ?? 'https') . '://' . ($u['host'] ?? '');
        if (isset($u['port'])) {
            $origin .= ':' . $u['port'];
        }
        return $origin . $path;
    }

    return $base . '/' . ltrim($path, '/');
}

function plugin_manifest_app_id(array $manifest): string
{
    if (!empty($manifest['app_id'])) {
        return (string) $manifest['app_id'];
    }
    if (!empty($manifest['app']['app_id'])) {
        return (string) $manifest['app']['app_id'];
    }
    if (!empty($manifest['id'])) {
        return (string) $manifest['id'];
    }
    return '';
}

function plugin_manifest_app_folder(array $manifest): string
{
    if (!empty($manifest['app_folder'])) {
        return (string) $manifest['app_folder'];
    }
    if (!empty($manifest['app']['folder'])) {
        return (string) $manifest['app']['folder'];
    }
    return '';
}

function plugin_manifest_app_url(array $manifest): string
{
    $url = '';
    if (!empty($manifest['url'])) {
        $url = (string) $manifest['url'];
    } elseif (!empty($manifest['app']['url'])) {
        $url = (string) $manifest['app']['url'];
    } else {
        $folder = plugin_manifest_app_folder($manifest);
        if ($folder !== '') {
            $url = 'apps/' . $folder . '/index.php';
        }
    }

    $url = trim(str_replace('\\', '/', $url));
    if ($url === '') {
        return '';
    }

    if (preg_match('~^(?:https?:)?//~i', $url)) {
        return $url;
    }

    return ltrim($url, '/');
}

function plugin_manifest_roles_json(array $manifest): string
{
    $roles = $manifest['roles_json'] ?? ['administradores'];
    if (function_exists('intranet_roles_to_json')) {
        return intranet_roles_to_json($roles, ['administradores']);
    }
    if (!is_array($roles)) {
        $roles = ['administradores'];
    }

    return json_encode($roles, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
}

function plugin_app_register(PDO $pdo, array $manifest, int $enabled = 1): void
{
    $appId = plugin_manifest_app_id($manifest);
    $url = plugin_manifest_app_url($manifest);

    if ($appId === '' || $url === '') {
        throw new RuntimeException('Manifest sin app_id o url');
    }

    $name = (string) ($manifest['name'] ?? $appId);
    $groupName = (string) ($manifest['default_group_name'] ?? ($manifest['app']['default_group_name'] ?? 'Mis apps'));
    if (function_exists('catalogo_normalize_group_name')) {
        $groupName = catalogo_normalize_group_name($groupName, 'apps');
    }
    if (function_exists('catalogo_ensure_group')) {
        $groupName = catalogo_ensure_group($pdo, 'apps', $groupName);
    }
    $description = (string) ($manifest['description'] ?? '');
    $icon = (string) ($manifest['icon'] ?? '');
    $image = isset($manifest['image']) ? (string) $manifest['image'] : null;
    $newTab = !empty($manifest['new_tab']) ? 1 : 0;
    $rolesJson = plugin_manifest_roles_json($manifest);

    $st = $pdo->prepare("SELECT id FROM intranet_apps WHERE app_id=? LIMIT 1");
    $st->execute([$appId]);
    $row = $st->fetch(PDO::FETCH_ASSOC);

    if ($row) {
        $up = $pdo->prepare("
            UPDATE intranet_apps
            SET
                name=?,
                url=?,
                group_name=?,
                description=?,
                icon=?,
                image=?,
                new_tab=?,
                roles_json=?,
                is_enabled=?,
                updated_at=NOW()
            WHERE app_id=?
        ");
        $up->execute([
            $name,
            $url,
            $groupName,
            $description !== '' ? $description : null,
            $icon !== '' ? $icon : null,
            $image !== '' ? $image : null,
            $newTab,
            $rolesJson,
            $enabled ? 1 : 0,
            $appId
        ]);
        return;
    }

    $pos = 0;
    try {
        $p = $pdo->prepare("SELECT COALESCE(MAX(position),0)+1 FROM intranet_apps WHERE group_name=?");
        $p->execute([$groupName]);
        $pos = (int) ($p->fetchColumn() ?: 0);
    } catch (Throwable $e) {
        $pos = 0;
    }

    $ins = $pdo->prepare("
        INSERT INTO intranet_apps
        (app_id, name, url, group_name, description, icon, image, new_tab, roles_json, position, is_enabled, created_at, updated_at)
        VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, NOW(), NOW())
    ");
    $ins->execute([
        $appId,
        $name,
        $url,
        $groupName,
        $description !== '' ? $description : null,
        $icon !== '' ? $icon : null,
        $image !== '' ? $image : null,
        $newTab,
        $rolesJson,
        $pos,
        $enabled ? 1 : 0
    ]);
}

function plugin_app_unregister(PDO $pdo, array $manifest): void
{
    $appId = plugin_manifest_app_id($manifest);
    if ($appId === '') {
        return;
    }

    $st = $pdo->prepare("DELETE FROM intranet_apps WHERE app_id=?");
    $st->execute([$appId]);
}

function core_updates_base_dir(): string
{
    $dir = realpath(__DIR__ . '/../updates');
    if ($dir === false) {
        $dir = __DIR__ . '/../updates';
    }
    @mkdir($dir, 0775, true);
    @mkdir($dir . '/core', 0775, true);
    @mkdir($dir . '/core/packages', 0775, true);
    @mkdir($dir . '/core/backups', 0775, true);
    @mkdir($dir . '/core/builds', 0775, true);
    return $dir . '/core';
}

function core_update_default_preserve(): array
{
    return [
        'common/config.php',
        'config/installed.lock',
        'styles/css/theme-vars.css',
        'styles/images/',
        'apps/',
        'plugins/',
        'uploads/',
        'files/',
        'storage/',
        'logs/',
        'tmp/',
        'vendor/',
        'common/vendor/',
        '.git/',
    ];
}

function core_update_is_preserved(string $relativePath, array $preserve): bool
{
    $relativePath = str_replace('\\', '/', ltrim($relativePath, '/'));
    foreach ($preserve as $rule) {
        $rule = str_replace('\\', '/', ltrim((string) $rule, '/'));
        if ($rule === '') {
            continue;
        }
        if (substr($rule, -1) === '/') {
            if (strpos($relativePath, $rule) === 0) {
                return true;
            }
        } elseif ($relativePath === $rule) {
            return true;
        }
    }
    return false;
}

function core_update_read_manifest_from_dir(string $dir, array &$errors): ?array
{
    $path = rtrim($dir, '/\\') . '/core-update.json';
    if (!is_file($path)) {
        $errors[] = 'El paquete no contiene core-update.json';
        return null;
    }
    $raw = (string) file_get_contents($path);
    $data = json_decode($raw, true);
    if (json_last_error() !== JSON_ERROR_NONE || !is_array($data)) {
        $errors[] = 'core-update.json inválido';
        return null;
    }
    if ((string) ($data['type'] ?? '') !== 'core-update') {
        $errors[] = 'El paquete no es de tipo core-update';
        return null;
    }
    return $data;
}

function core_update_extract_to_tmp(string $zipPath, array &$errors): ?string
{
    $tmp = sys_get_temp_dir() . '/core_update_' . bin2hex(random_bytes(8));
    if (!@mkdir($tmp, 0775, true) && !is_dir($tmp)) {
        $errors[] = 'No se pudo crear directorio temporal para actualización';
        return null;
    }
    if (!plugin_extract_zip($zipPath, $tmp, $errors)) {
        plugin_rrmdir($tmp);
        return null;
    }
    return $tmp;
}

function core_update_list_local_packages(array &$errors): array
{
    $dir = core_updates_base_dir() . '/packages';
    @mkdir($dir, 0775, true);
    $items = scandir($dir);
    if (!is_array($items)) {
        return [];
    }
    $out = [];
    foreach ($items as $it) {
        if ($it === '.' || $it === '..' || !preg_match('/\.zip$/i', $it)) {
            continue;
        }
        $path = $dir . '/' . $it;
        $tmpErrors = [];
        $tmp = core_update_extract_to_tmp($path, $tmpErrors);
        if (!$tmp) {
            $errors[] = 'No se pudo leer paquete local ' . $it;
            continue;
        }
        $manifest = core_update_read_manifest_from_dir($tmp, $tmpErrors);
        if ($manifest) {
            $manifest['_file'] = $it;
            $manifest['_path'] = $path;
            $manifest['_size'] = @filesize($path) ?: 0;
            $manifest['_mtime'] = @filemtime($path) ?: 0;
            $out[] = $manifest;
        }
        plugin_rrmdir($tmp);
    }
    usort($out, static function ($a, $b) {
        return ($b['_mtime'] ?? 0) <=> ($a['_mtime'] ?? 0);
    });
    return $out;
}

function core_update_fetch_index(array &$errors): array
{
    $store = plugin_store_fetch_index($errors);
    if (!is_array($store)) {
        return [];
    }
    $list = $store['core_updates'] ?? [];
    if (!is_array($list)) {
        return [];
    }
    $out = [];
    foreach ($list as $item) {
        if (is_array($item) && (string) ($item['id'] ?? '') !== '') {
            $out[] = $item;
        }
    }
    return $out;
}

function core_update_current_version(): string
{
    $versionFile = __DIR__ . '/../config/version.php';
    if (!is_file($versionFile)) {
        return '0.0.0';
    }

    $meta = @require $versionFile;
    if (is_array($meta) && !empty($meta['version'])) {
        return (string) $meta['version'];
    }

    if (defined('INTRANET_VERSION')) {
        return (string) INTRANET_VERSION;
    }

    return '0.0.0';
}

function core_update_stage_version_file(string $payloadRoot, string $version): void
{
    $versionFile = rtrim($payloadRoot, '/\\') . '/config/version.php';
    $buildDate = date('Y-m-d');
    $meta = [
        'version' => $version,
        'name' => 'Intranet CSIF',
        'build_date' => $buildDate,
        'versioning' => [
            'manifest' => 'config/version_manifest.json',
            'scheme' => 'YYYY.MM.DD.REV',
            'scope' => 'first-party source files',
        ],
    ];

    if (is_file($versionFile)) {
        $loaded = @require $versionFile;
        if (is_array($loaded)) {
            $meta = array_replace_recursive($meta, $loaded);
        }
    }

    $meta['version'] = $version;
    $meta['build_date'] = $buildDate;

    @mkdir(dirname($versionFile), 0775, true);

    $php = "<?php\n"
        . "/**\n"
        . " * @intranet-file config/version.php\n"
        . " * @intranet-file-version {$version}\n"
        . " * @intranet-file-date {$buildDate}\n"
        . " * @intranet-file-owner CSIF Intranet\n"
        . " */\n"
        . 'return ' . var_export($meta, true) . ";\n";

    file_put_contents($versionFile, $php);
}

function core_update_refresh_payload_entry(array &$entries, string $payloadRoot, string $relativePath): void
{
    $relativePath = str_replace('\\', '/', ltrim($relativePath, '/'));
    $absolutePath = rtrim($payloadRoot, '/\\') . '/' . $relativePath;
    if (!is_file($absolutePath)) {
        return;
    }

    $entry = [
        'path' => $relativePath,
        'size' => @filesize($absolutePath) ?: 0,
        'sha256' => hash_file('sha256', $absolutePath),
    ];

    foreach ($entries as $idx => $item) {
        if ((string) ($item['path'] ?? '') === $relativePath) {
            $entries[$idx] = $entry;
            return;
        }
    }

    $entries[] = $entry;
}



function core_update_build_default_excludes(): array
{
    return [
        'common/config.php',
        'config/installed.lock',
        'styles/css/theme-vars.css',
        'styles/images/',
        'apps/',
        'plugins/',
        'uploads/',
        'files/',
        'storage/',
        'logs/',
        'tmp/',
        'vendor/',
        'common/vendor/',
        'updates/',
        'docs/',
        '.git/',
        '.gitignore',
        '.env',
        '.env.',
        '*.zip',
        'composer.lock',
        'node_modules/',
    ];
}

function core_update_build_is_excluded(string $relativePath, array $rules): bool
{
    $relativePath = str_replace('\\', '/', ltrim($relativePath, '/'));
    foreach ($rules as $rule) {
        $rule = str_replace('\\', '/', ltrim((string) $rule, '/'));
        if ($rule === '') {
            continue;
        }
        if (substr($rule, -1) === '/') {
            if (strpos($relativePath, $rule) === 0) {
                return true;
            }
            continue;
        }
        if (strpos($rule, '*') !== false) {
            $pattern = '#^' . str_replace('\*', '.*', preg_quote($rule, '#')) . '$#i';
            if (preg_match($pattern, $relativePath)) {
                return true;
            }
            continue;
        }
        if ($relativePath === $rule || strpos($relativePath, $rule) === 0 && substr($rule, -1) === '.') {
            return true;
        }
    }
    return false;
}

function core_update_payload_manifest(array $entries): array
{
    $out = [];
    foreach ($entries as $entry) {
        $out[] = [
            'path' => $entry['path'],
            'size' => $entry['size'],
            'sha256' => $entry['sha256'],
        ];
    }
    return $out;
}

function core_update_generate_package(array $meta, string $generatedBy, array &$errors): ?array
{
    $publicRoot = realpath(__DIR__ . '/..') ?: (__DIR__ . '/..');
    $version = trim((string) ($meta['version'] ?? ''));
    if ($version === '') {
        $errors[] = 'Debes indicar la versión destino del paquete.';
        return null;
    }
    $description = trim((string) ($meta['description'] ?? ''));
    $includeDocs = !empty($meta['include_docs']);
    $includeGuides = !empty($meta['include_guides']);
    $includeSql = !empty($meta['include_sql']);
    $sqlContent = (string) ($meta['sql_content'] ?? '');

    $buildBase = core_updates_base_dir() . '/builds';
    @mkdir($buildBase, 0775, true);
    $slug = preg_replace('/[^A-Za-z0-9._-]/', '-', $version);
    $zipName = 'intranet-core-update-' . $slug . '.zip';
    $zipPath = $buildBase . '/' . $zipName;

    $stage = sys_get_temp_dir() . '/core_build_' . bin2hex(random_bytes(8));
    $root = $stage . '/package';
    $payloadRoot = $root . '/payload';
    @mkdir($payloadRoot, 0775, true);

    $excludes = core_update_build_default_excludes();
    if ($includeDocs) {
        $excludes = array_values(array_filter($excludes, static fn($v) => (string) $v !== 'docs/'));
    }

    $copied = [];
    try {
        $it = new RecursiveIteratorIterator(
            new RecursiveDirectoryIterator($publicRoot, FilesystemIterator::SKIP_DOTS),
            RecursiveIteratorIterator::SELF_FIRST
        );
        foreach ($it as $file) {
            $src = $file->getPathname();
            $rel = str_replace('\\', '/', substr($src, strlen(rtrim($publicRoot, '/\\')) + 1));
            if ($rel === '' || core_update_build_is_excluded($rel, $excludes)) {
                continue;
            }
            $dst = $payloadRoot . '/' . $rel;
            if ($file->isDir()) {
                @mkdir($dst, 0775, true);
                continue;
            }
            @mkdir(dirname($dst), 0775, true);
            if (!@copy($src, $dst)) {
                throw new RuntimeException('No se pudo copiar al paquete: ' . $rel);
            }
            $copied[] = [
                'path' => $rel,
                'size' => @filesize($src) ?: 0,
                'sha256' => hash_file('sha256', $src),
            ];
        }

        core_update_stage_version_file($payloadRoot, $version);
        core_update_refresh_payload_entry($copied, $payloadRoot, 'config/version.php');

        $manifest = [
            'type' => 'core-update',
            'id' => 'intranet-core',
            'name' => 'Actualización del núcleo de la intranet',
            'version' => $version,
            'description' => $description !== '' ? $description : ('Paquete generado automáticamente desde ' . core_update_current_version()),
            'generated_at' => date('c'),
            'generated_by' => $generatedBy,
            'payload_root' => 'payload',
            'preserve' => core_update_default_preserve(),
            'sql_migrations' => $includeSql && trim($sqlContent) !== '' ? ['migrations/001_manual.sql'] : [],
        ];
        file_put_contents($root . '/core-update.json', json_encode($manifest, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES));
        file_put_contents($root . '/payload-manifest.json', json_encode(core_update_payload_manifest($copied), JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES));

        if ($includeSql && trim($sqlContent) !== '') {
            @mkdir($root . '/migrations', 0775, true);
            file_put_contents($root . '/migrations/001_manual.sql', $sqlContent);
        }

        if ($includeGuides) {
            $md = "# Generación automática de core-update

Versión: {$version}

Generado por: {$generatedBy}

Fecha: " . date('Y-m-d H:i:s') . "

Ficheros incluidos: " . count($copied) . "
";
            file_put_contents($root . '/GUIA-ACTUALIZACIONES-CORE.md', $md);
            $html = '<!doctype html><html lang="es"><meta charset="utf-8"><title>Guía core-update</title><body><h1>Core update ' . htmlspecialchars($version, ENT_QUOTES) . '</h1><p>Generado automáticamente.</p><ul><li>Versión: ' . htmlspecialchars($version, ENT_QUOTES) . '</li><li>Generado por: ' . htmlspecialchars($generatedBy, ENT_QUOTES) . '</li><li>Fecha: ' . date('Y-m-d H:i:s') . '</li><li>Ficheros: ' . count($copied) . '</li></ul></body></html>';
            file_put_contents($root . '/GUIA-ACTUALIZACIONES-CORE.html', $html);
        }

        if (class_exists('ZipArchive')) {
            $zip = new ZipArchive();
            if ($zip->open($zipPath, ZipArchive::CREATE | ZipArchive::OVERWRITE) !== true) {
                throw new RuntimeException('No se pudo crear el ZIP del core-update.');
            }
            $it2 = new RecursiveIteratorIterator(
                new RecursiveDirectoryIterator($root, FilesystemIterator::SKIP_DOTS),
                RecursiveIteratorIterator::SELF_FIRST
            );
            foreach ($it2 as $file) {
                if ($file->isDir()) continue;
                $src = $file->getPathname();
                $rel = str_replace('\\', '/', substr($src, strlen(rtrim($root, '/\\')) + 1));
                $zip->addFile($src, $rel);
            }
            $zip->close();
        } else {
            if (!plugin_system_zip_dir($root, $zipPath, $errors)) {
                throw new RuntimeException('No se pudo crear el ZIP del core-update.');
            }
        }

        $buildLogPath = $buildBase . '/build-log.json';
        $buildLog = [];
        if (is_file($buildLogPath)) {
            $raw = json_decode((string) file_get_contents($buildLogPath), true);
            if (is_array($raw)) $buildLog = $raw;
        }
        $buildLog[] = [
            'generated_at' => date('Y-m-d H:i:s'),
            'generated_by' => $generatedBy,
            'version_from' => core_update_current_version(),
            'version_to' => $version,
            'zip' => $zipName,
            'file_count' => count($copied),
            'include_docs' => $includeDocs,
            'include_guides' => $includeGuides,
            'include_sql' => $includeSql && trim($sqlContent) !== '',
        ];
        file_put_contents($buildLogPath, json_encode($buildLog, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE));

        return [
            'zip' => $zipName,
            'path' => $zipPath,
            'version' => $version,
            'file_count' => count($copied),
            'size' => @filesize($zipPath) ?: 0,
        ];
    } catch (Throwable $e) {
        $errors[] = $e->getMessage();
        return null;
    } finally {
        plugin_rrmdir($stage);
    }
}

function core_update_list_builds(array &$errors): array
{
    $dir = core_updates_base_dir() . '/builds';
    @mkdir($dir, 0775, true);
    $items = scandir($dir);
    if (!is_array($items)) return [];
    $out = [];
    foreach ($items as $it) {
        if ($it === '.' || $it === '..' || !preg_match('/\.zip$/i', $it)) continue;
        $path = $dir . '/' . $it;
        $tmpErrors = [];
        $tmp = core_update_extract_to_tmp($path, $tmpErrors);
        $manifest = $tmp ? core_update_read_manifest_from_dir($tmp, $tmpErrors) : null;
        if ($tmp) plugin_rrmdir($tmp);
        $out[] = [
            'file' => $it,
            'path' => $path,
            'size' => @filesize($path) ?: 0,
            'mtime' => @filemtime($path) ?: 0,
            'version' => (string) ($manifest['version'] ?? ''),
            'description' => (string) ($manifest['description'] ?? ''),
        ];
    }
    usort($out, static fn($a,$b)=>($b['mtime']??0)<=>($a['mtime']??0));
    return $out;
}

function core_update_save_uploaded_zip(array $file, array &$errors): ?string
{
    if (($file['error'] ?? UPLOAD_ERR_NO_FILE) !== UPLOAD_ERR_OK) {
        $errors[] = 'Error al subir el ZIP de actualización.';
        return null;
    }
    $name = basename((string) ($file['name'] ?? 'update.zip'));
    $name = preg_replace('/[^A-Za-z0-9._-]/', '_', $name);
    if (!preg_match('/\.zip$/i', $name)) {
        $errors[] = 'El paquete debe ser un ZIP.';
        return null;
    }
    $dest = core_updates_base_dir() . '/packages/' . date('Ymd_His') . '_' . $name;
    if (!move_uploaded_file((string) $file['tmp_name'], $dest)) {
        $errors[] = 'No se pudo guardar el ZIP subido.';
        return null;
    }
    return $dest;
}

function core_update_create_backup_zip(string $publicRoot, string $payloadRoot, array $preserve, array &$errors): ?string
{
    $backupDir = core_updates_base_dir() . '/backups';
    @mkdir($backupDir, 0775, true);
    $backupPath = $backupDir . '/core-backup-' . date('Ymd_His') . '-' . bin2hex(random_bytes(4)) . '.zip';
    $stage = sys_get_temp_dir() . '/core_backup_' . bin2hex(random_bytes(6));
    @mkdir($stage, 0775, true);

    try {
        $it = new RecursiveIteratorIterator(
            new RecursiveDirectoryIterator($payloadRoot, FilesystemIterator::SKIP_DOTS),
            RecursiveIteratorIterator::SELF_FIRST
        );

        foreach ($it as $file) {
            if ($file->isDir()) {
                continue;
            }
            $src = $file->getPathname();
            $rel = str_replace('\\', '/', substr($src, strlen(rtrim($payloadRoot, '/\\')) + 1));
            if (core_update_is_preserved($rel, $preserve)) {
                continue;
            }
            $target = rtrim($publicRoot, '/\\') . '/' . $rel;
            if (is_file($target)) {
                $dst = $stage . '/' . $rel;
                @mkdir(dirname($dst), 0775, true);
                @copy($target, $dst);
            }
        }

        if (class_exists('ZipArchive')) {
            $zip = new ZipArchive();
            if ($zip->open($backupPath, ZipArchive::CREATE | ZipArchive::OVERWRITE) !== true) {
                $errors[] = 'No se pudo crear el backup de la actualización.';
                return null;
            }
            $it2 = new RecursiveIteratorIterator(
                new RecursiveDirectoryIterator($stage, FilesystemIterator::SKIP_DOTS),
                RecursiveIteratorIterator::SELF_FIRST
            );
            foreach ($it2 as $file) {
                if ($file->isDir()) continue;
                $src = $file->getPathname();
                $rel = str_replace('\\', '/', substr($src, strlen(rtrim($stage, '/\\')) + 1));
                $zip->addFile($src, $rel);
            }
            $zip->close();
        } else {
            if (!plugin_system_zip_dir($stage, $backupPath, $errors)) {
                return null;
            }
        }

        return $backupPath;
    } finally {
        plugin_rrmdir($stage);
    }
}


function core_update_resolve_preserve(array $manifest, array $options = []): array
{
    $preserve = array_values(array_unique(array_merge(
        core_update_default_preserve(),
        is_array($manifest['preserve'] ?? null) ? $manifest['preserve'] : []
    )));

    $toggleMap = [
        'preserve_theme' => 'styles/css/theme-vars.css',
        'preserve_images' => 'styles/images/',
        'preserve_apps' => 'apps/',
        'preserve_plugins' => 'plugins/',
    ];

    foreach ($toggleMap as $opt => $rule) {
        $enabled = array_key_exists($opt, $options) ? (bool) $options[$opt] : true;
        if ($enabled) {
            if (!in_array($rule, $preserve, true)) {
                $preserve[] = $rule;
            }
            continue;
        }
        $preserve = array_values(array_filter($preserve, static fn($v) => (string) $v !== $rule));
    }

    return array_values(array_unique($preserve));
}

function core_update_apply_sql_payload(PDO $pdo, string $tmpRoot, array $manifest, array &$errors): int
{
    $files = [];
    $direct = [
        rtrim($tmpRoot, '/\\') . '/update.sql',
        rtrim($tmpRoot, '/\\') . '/update.sql',
        rtrim($tmpRoot, '/\\') . '/migrations.sql',
    ];
    foreach ($direct as $path) {
        if (is_file($path)) {
            $files[] = $path;
        }
    }

    $migDir = rtrim($tmpRoot, '/\\') . '/migrations';
    if (is_dir($migDir)) {
        $items = glob($migDir . '/*.sql') ?: [];
        sort($items, SORT_NATURAL);
        foreach ($items as $path) {
            if (is_file($path)) {
                $files[] = $path;
            }
        }
    }

    $applied = 0;
    foreach ($files as $sqlFile) {
        if (!plugin_exec_sql_file($pdo, $sqlFile, $errors)) {
            throw new RuntimeException('Fallo aplicando migración SQL: ' . basename($sqlFile));
        }
        $applied++;
    }

    return $applied;
}

function core_update_apply_from_zip(PDO $pdo, string $zipPath, string $appliedBy, array &$errors, array $options = []): ?array
{
    $tmp = core_update_extract_to_tmp($zipPath, $errors);
    if (!$tmp) {
        return null;
    }
    try {
        $manifest = core_update_read_manifest_from_dir($tmp, $errors);
        if (!$manifest) {
            return null;
        }

        $publicRoot = realpath(__DIR__ . '/..') ?: (__DIR__ . '/..');
        $versionFrom = core_update_current_version();

        $payloadRootName = trim((string) ($manifest['payload_root'] ?? 'payload'));
        $payloadRoot = rtrim($tmp, '/\\') . '/' . ltrim($payloadRootName, '/\\');
        if (!is_dir($payloadRoot)) {
            throw new RuntimeException('El paquete no contiene el directorio payload.');
        }

        $preserve = core_update_resolve_preserve($manifest, $options);

        $backupPath = core_update_create_backup_zip($publicRoot, $payloadRoot, $preserve, $errors);
        if (!$backupPath) {
            throw new RuntimeException('No se pudo crear el backup previo.');
        }

        $copied = [];
        $it = new RecursiveIteratorIterator(
            new RecursiveDirectoryIterator($payloadRoot, FilesystemIterator::SKIP_DOTS),
            RecursiveIteratorIterator::SELF_FIRST
        );

        foreach ($it as $file) {
            $src = $file->getPathname();
            $rel = str_replace('\\', '/', substr($src, strlen(rtrim($payloadRoot, '/\\')) + 1));
            if ($rel === '' || core_update_is_preserved($rel, $preserve)) {
                continue;
            }
            $dst = rtrim($publicRoot, '/\\') . '/' . $rel;
            if ($file->isDir()) {
                @mkdir($dst, 0775, true);
                continue;
            }
            @mkdir(dirname($dst), 0775, true);
            if (!@copy($src, $dst)) {
                throw new RuntimeException('No se pudo copiar: ' . $rel);
            }
            $copied[] = $rel;
        }

        $sqlApplied = 0;
        if (!empty($options['apply_sql'])) {
            $sqlApplied = core_update_apply_sql_payload($pdo, $tmp, $manifest, $errors);
        }

        $logPath = core_updates_base_dir() . '/install-log.json';
        $log = [];
        if (is_file($logPath)) {
            $raw = json_decode((string) file_get_contents($logPath), true);
            if (is_array($raw)) {
                $log = $raw;
            }
        }
        $log[] = [
            'applied_at' => date('Y-m-d H:i:s'),
            'applied_by' => $appliedBy,
            'version_from' => $versionFrom,
            'version_to' => (string) ($manifest['version'] ?? ''),
            'name' => (string) ($manifest['name'] ?? 'Actualización del núcleo'),
            'backup_zip' => basename($backupPath),
            'source_zip' => basename($zipPath),
            'copied_count' => count($copied),
            'sql_applied' => $sqlApplied,
            'options' => $options,
        ];
        file_put_contents($logPath, json_encode($log, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE));

        return [
            'manifest' => $manifest,
            'backup_zip' => basename($backupPath),
            'copied_count' => count($copied),
            'sql_applied' => $sqlApplied,
            'options' => $options,
        ];
    } finally {
        plugin_rrmdir($tmp);
    }
}


function core_update_latest_version(array $updates): string
{
    $latest = '';
    foreach ($updates as $item) {
        $version = (string) ($item['version'] ?? '');
        if ($version === '') {
            continue;
        }
        if ($latest === '' || version_compare($version, $latest, '>')) {
            $latest = $version;
        }
    }
    return $latest;
}

function core_update_ensure_app_registered(PDO $pdo): void
{
    $appId = 'updates';
    $st = $pdo->prepare("SELECT id, name, url, group_name, description, icon, new_tab, roles_json, is_enabled FROM intranet_apps WHERE app_id=? LIMIT 1");
    $st->execute([$appId]);
    $row = $st->fetch(PDO::FETCH_ASSOC);

    $name = 'Actualizaciones';
    $url = 'base/updates/index.php';
    $group = 'Administración';
    $description = 'Estado y actualización del núcleo de la intranet';
    $icon = '<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 12a9 9 0 1 1-2.64-6.36"/><polyline points="21 3 21 9 15 9"/><polyline points="3 15 3 21 9 21"/><path d="M3 12a9 9 0 0 0 15.36 6.36"/></svg>';
    $roles = json_encode(['administradores'], JSON_UNESCAPED_UNICODE);

    if ($row) {
        $alreadyCurrent =
            (string)($row['name'] ?? '') === $name
            && (string)($row['url'] ?? '') === $url
            && (string)($row['group_name'] ?? '') === $group
            && (string)($row['description'] ?? '') === $description
            && (string)($row['icon'] ?? '') === $icon
            && (int)($row['new_tab'] ?? 0) === 0
            && (string)($row['roles_json'] ?? '') === $roles
            && (int)($row['is_enabled'] ?? 0) === 1;

        if ($alreadyCurrent) {
            return;
        }

        $up = $pdo->prepare("UPDATE intranet_apps SET name=?, url=?, group_name=?, description=?, icon=?, new_tab=0, roles_json=?, is_enabled=1, updated_at=NOW() WHERE app_id=?");
        $up->execute([$name, $url, $group, $description, $icon, $roles, $appId]);
        return;
    }

    $pos = 1;
    try {
        $p = $pdo->prepare("SELECT COALESCE(MAX(position),0)+1 FROM intranet_apps WHERE group_name=?");
        $p->execute([$group]);
        $pos = (int) ($p->fetchColumn() ?: 1);
    } catch (Throwable $e) {
        $pos = 1;
    }

    $ins = $pdo->prepare("INSERT INTO intranet_apps (app_id, name, url, group_name, description, icon, image, new_tab, roles_json, position, is_enabled, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, NULL, 0, ?, ?, 1, NOW(), NOW())");
    $ins->execute([$appId, $name, $url, $group, $description, $icon, $roles, $pos]);
}
