<?php
/**
 * Application Update — upload a ZIP package to update the application.
 *
 * Cycle de mise à jour (clean replace) :
 *  1. mode maintenance ON ;
 *  2. sauvegarde complète de la version courante → securite/backups/pre_update_<ts>.zip ;
 *  3. effacement de l'ancienne version (tout ROOT_PATH sauf securite/ et log/) ;
 *  4. extraction de la nouvelle version (securite/* toujours protégé) ;
 *  5. purge des sauvegardes au-delà des 3 plus récentes ;
 *  6. mode maintenance OFF (si succès ; sinon laissé ON pour récupération).
 *
 * Règles de sécurité :
 *  - securite/database.sqlite et securite/master.key ne sont JAMAIS écrasés ;
 *  - securite/ et log/ sont toujours préservés ;
 *  - les métadonnées git (.git, .gitmodules) sont préservées SI le module
 *    Developer est présent (poste de dev), supprimées sinon (instance packagée).
 */
require_once dirname(__DIR__) . '/bootstrap.php';

$auth->requireRole(ROLE_APP_ADMIN);

if (!Session::has('csrf_token')) {
    Session::set('csrf_token', bin2hex(random_bytes(32)));
}
$csrfToken = Session::get('csrf_token');

$error   = null;
$success = null;
$info    = null;

/* ── Protected paths — never overwritten by the update ──────────────── */
$protectedPaths = [
    'securite/database.sqlite',
    'securite/master.key',
];

/* Prefixes: any file starting with these is skipped */
$protectedPrefixes = [
    'securite/wallets/',
];

/* Top-level entries preserved during the clean-replace wipe (never deleted). */
$preservedTopLevel = ['securite', 'log'];

/* Conserver les métadonnées git si le module Developer est présent sur disque
   (actif ou non) — typiquement un poste de dev déployé par git. */
$keepGit = is_file(WEBROOT_PATH . '/modules/developer/module.php');

/* Répertoire des sauvegardes + nombre conservé. */
$backupDir   = ROOT_PATH . '/securite/backups';
$backupsKeep = 3;

/**
 * Check if a path inside the archive should be protected.
 */
function isProtectedPath(string $relativePath, array $paths, array $prefixes): bool
{
    if (in_array($relativePath, $paths, true)) return true;
    foreach ($prefixes as $pfx) {
        if (str_starts_with($relativePath, $pfx)) return true;
    }
    return false;
}

/**
 * Suppression récursive d'un dossier. Si $keepGit, les métadonnées git
 * (.git à tout niveau, .gitmodules) sont préservées — les dossiers qui les
 * contiennent survivent (rmdir échoue sur non-vide).
 */
function update_rrmdir(string $dir, bool $keepGit = false): void
{
    if (is_link($dir) || is_file($dir)) { @unlink($dir); return; }
    if (!is_dir($dir)) return;
    $it = new RecursiveIteratorIterator(
        new RecursiveDirectoryIterator($dir, FilesystemIterator::SKIP_DOTS),
        RecursiveIteratorIterator::CHILD_FIRST
    );
    foreach ($it as $item) {
        $p = $item->getPathname();
        if ($keepGit && ($item->getFilename() === '.gitmodules'
                         || str_contains($p, '/.git/')
                         || str_ends_with($p, '/.git'))) {
            continue;
        }
        if ($item->isLink() || $item->isFile()) {
            @unlink($p);
        } else {
            @rmdir($p);
        }
    }
    @rmdir($dir);
}

/**
 * Nom de sauvegarde valide (anti-traversal) : pre_update_YYYYMMDD_HHMMSS.zip.
 */
function isValidBackupName(string $name): bool
{
    return (bool)preg_match('/^pre_update_\d{8}_\d{6}\.zip$/', $name);
}

/**
 * Purge les sauvegardes pre_update_*.zip au-delà des $keep plus récentes.
 * Retourne le nombre supprimé.
 */
function pruneBackups(string $backupDir, int $keep): int
{
    $files = glob($backupDir . '/pre_update_*.zip') ?: [];
    usort($files, fn($a, $b) => filemtime($b) <=> filemtime($a)); // plus récent d'abord
    $deleted = 0;
    foreach (array_slice($files, $keep) as $f) {
        if (@unlink($f)) $deleted++;
    }
    return $deleted;
}

/**
 * Applique une archive ZIP (upload ou sauvegarde) en clean replace.
 * Retourne ['error'?, 'success'?, 'info'?].
 */
function applyUpdateFromZip(
    string $zipPath,
    $settings,
    array $protectedPaths,
    array $protectedPrefixes,
    array $preservedTopLevel,
    bool $keepGit,
    string $backupDir,
    int $backupsKeep
): array {
    $zip = new ZipArchive();
    if ($zip->open($zipPath) !== true) {
        return ['error' => t('update.invalid_zip')];
    }

    /* ── Scan ──────────────────────────────────────────────────────── */
    $skippedFiles = [];
    $extractFiles = [];
    for ($i = 0; $i < $zip->numFiles; $i++) {
        $entryName = $zip->getNameIndex($i);
        if ($entryName === false) continue;
        if (str_ends_with($entryName, '/')) continue;
        if (str_contains($entryName, '__MACOSX/')) continue;
        $normalized = str_replace('\\', '/', $entryName);
        if (str_contains($normalized, '..')) {
            $skippedFiles[] = $entryName . ' (path traversal)';
            continue;
        }
        if (isProtectedPath($normalized, $protectedPaths, $protectedPrefixes)) {
            $skippedFiles[] = $entryName;
            continue;
        }
        $extractFiles[] = $entryName;
    }
    if (empty($extractFiles)) {
        $zip->close();
        return ['error' => t('update.empty_archive')];
    }

    /* ── 1. Maintenance ON ─────────────────────────────────────────── */
    $settings->set('maintenance_mode', '1');

    /* ── 2. Backup complet (hors securite/, log/, .git) ────────────── */
    if (!is_dir($backupDir)) {
        mkdir($backupDir, 0750, true);
    }
    $backupName = 'pre_update_' . date('Ymd_His') . '.zip';
    $backupPath = $backupDir . '/' . $backupName;
    $backupSkip = array_merge($preservedTopLevel, ['.git']);
    $backupOk   = false;
    $backup     = new ZipArchive();
    if ($backup->open($backupPath, ZipArchive::CREATE | ZipArchive::OVERWRITE) === true) {
        $it = new RecursiveIteratorIterator(
            new RecursiveDirectoryIterator(ROOT_PATH, FilesystemIterator::SKIP_DOTS),
            RecursiveIteratorIterator::SELF_FIRST
        );
        foreach ($it as $file) {
            $rel = str_replace('\\', '/', substr($file->getPathname(), strlen(ROOT_PATH) + 1));
            $top = explode('/', $rel)[0];
            if (in_array($top, $backupSkip, true)) continue;
            if ($file->isDir()) {
                $backup->addEmptyDir($rel);
            } elseif ($file->isFile()) {
                $backup->addFile($file->getPathname(), $rel);
            }
        }
        $backup->close();
        $backupOk = is_file($backupPath);
    }
    if (!$backupOk) {
        $settings->set('maintenance_mode', '0');
        $zip->close();
        return ['error' => t('update.backup_failed')];
    }

    /* ── 3. Effacement de l'ancienne version ───────────────────────── */
    $gitTop  = ['.git', '.gitmodules'];
    $deleted = 0;
    foreach (scandir(ROOT_PATH) ?: [] as $entry) {
        if ($entry === '.' || $entry === '..') continue;
        if (in_array($entry, $preservedTopLevel, true)) continue;
        if ($keepGit && in_array($entry, $gitTop, true)) continue;
        update_rrmdir(ROOT_PATH . '/' . $entry, $keepGit);
        $deleted++;
    }

    /* ── 4. Extraction de la nouvelle version ──────────────────────── */
    $extracted = 0;
    $errors    = [];
    foreach ($extractFiles as $entry) {
        $destPath = ROOT_PATH . '/' . $entry;
        $destDir  = dirname($destPath);
        if (!is_dir($destDir) && !mkdir($destDir, 0755, true)) {
            $errors[] = $entry . ' (mkdir failed)';
            continue;
        }
        $content = $zip->getFromName($entry);
        if ($content === false) {
            $errors[] = $entry . ' (read failed)';
            continue;
        }
        if (file_put_contents($destPath, $content) === false) {
            $errors[] = $entry . ' (write failed)';
            continue;
        }
        $extracted++;
    }
    $zip->close();

    /* ── 5. Purge des anciennes sauvegardes ────────────────────────── */
    $pruned = pruneBackups($backupDir, $backupsKeep);

    /* ── 6. Bilan + bascule maintenance ────────────────────────────── */
    $res = [];
    if (!empty($errors)) {
        // Extraction incomplète → maintenance laissée ACTIVE.
        $res['error'] = sprintf(t('update.partial_errors'), count($errors))
                      . "\n" . implode("\n", $errors)
                      . "\n\n" . sprintf(t('update.maintenance_failed'), $backupName);
    } else {
        $settings->set('maintenance_mode', '0');
        $msg = sprintf(t('update.success'), $extracted)
             . ' ' . sprintf(t('update.deleted'), $deleted)
             . ' ' . sprintf(t('update.full_backup_created'), $backupName);
        if ($pruned > 0) {
            $msg .= ' ' . sprintf(t('update.pruned'), $pruned);
        }
        $res['success'] = $msg;
    }
    if (!empty($skippedFiles)) {
        $res['info'] = sprintf(t('update.skipped_files'), count($skippedFiles))
                     . "\n" . implode("\n", $skippedFiles);
    }
    return $res;
}

/* ── Dispatch des actions POST ──────────────────────────────────────── */
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
    $action   = $_POST['action'] ?? '';
    $csrfOk   = hash_equals(Session::get('csrf_token', ''), $_POST['csrf_token'] ?? '');

    if (!$csrfOk) {
        $error = t('msg.error');
    } elseif ($action === 'clear_maintenance') {
        $settings->set('maintenance_mode', '0');
        $success = t('update.maintenance_cleared');
    } elseif ($action === 'delete_backup') {
        $name = (string)($_POST['backup'] ?? '');
        if (!isValidBackupName($name) || !is_file($backupDir . '/' . $name)) {
            $error = t('update.backup_not_found');
        } elseif (@unlink($backupDir . '/' . $name)) {
            $success = sprintf(t('update.backup_deleted'), $name);
        } else {
            $error = t('update.delete_failed');
        }
    } elseif ($action === 'restore_backup') {
        $name = (string)($_POST['backup'] ?? '');
        if (!isValidBackupName($name) || !is_file($backupDir . '/' . $name)) {
            $error = t('update.backup_not_found');
        } else {
            $res = applyUpdateFromZip(
                $backupDir . '/' . $name, $settings,
                $protectedPaths, $protectedPrefixes, $preservedTopLevel,
                $keepGit, $backupDir, $backupsKeep
            );
            $error = $res['error']   ?? null;
            $info  = $res['info']    ?? null;
            if (isset($res['success'])) {
                $success = sprintf(t('update.restore_success'), $name) . ' ' . $res['success'];
            }
        }
    } else {
        /* Upload d'une archive de mise à jour */
        if (!isset($_FILES['update_zip']) || $_FILES['update_zip']['error'] !== UPLOAD_ERR_OK) {
            $error = t('update.upload_error');
        } else {
            $tmpFile = $_FILES['update_zip']['tmp_name'];
            $finfo   = new finfo(FILEINFO_MIME_TYPE);
            $mime    = $finfo->file($tmpFile);
            if (!in_array($mime, ['application/zip', 'application/x-zip-compressed', 'application/octet-stream'], true)) {
                $error = t('update.invalid_zip');
            } elseif (!class_exists('ZipArchive')) {
                $error = t('update.zip_ext_missing');
            } else {
                $res = applyUpdateFromZip(
                    $tmpFile, $settings,
                    $protectedPaths, $protectedPrefixes, $preservedTopLevel,
                    $keepGit, $backupDir, $backupsKeep
                );
                $error   = $res['error']   ?? null;
                $success = $res['success'] ?? null;
                $info    = $res['info']    ?? null;
            }
        }
    }
}

/* ── Liste des sauvegardes récentes (pour l'interface) ──────────────── */
$backups = [];
foreach (glob($backupDir . '/pre_update_*.zip') ?: [] as $f) {
    $backups[] = [
        'name' => basename($f),
        'size' => (int)@filesize($f),
        'time' => (int)@filemtime($f),
    ];
}
usort($backups, fn($a, $b) => $b['time'] <=> $a['time']);

View::display(__DIR__ . '/views/update.php', [
    'error'       => $error,
    'success'     => $success,
    'info'        => $info,
    'csrfToken'   => $csrfToken,
    'maintenance' => (bool)$settings->get('maintenance_mode', false),
    'backups'     => $backups,
    'keepGit'     => $keepGit,
]);
