<?php
require_once dirname(__DIR__) . '/bootstrap.php';

$auth->requireRole(ROLE_APP_ADMIN);

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

/** Suppression récursive d'un répertoire (incluant fichiers cachés / .git). */
function modules_rrmdir(string $dir): bool
{
    if (!is_dir($dir)) return false;
    $items = scandir($dir);
    foreach ($items as $item) {
        if ($item === '.' || $item === '..') continue;
        $path = $dir . '/' . $item;
        if (is_dir($path) && !is_link($path)) {
            modules_rrmdir($path);
        } else {
            @unlink($path);
        }
    }
    return @rmdir($dir);
}

/**
 * Lit un descripteur module.php isolément (mise à jour de module : le fichier
 * n'est pas encore celui chargé par le bootstrap). Retourne null si le fichier
 * est illisible, ne retourne pas un tableau, ou lève.
 */
function modules_read_descriptor(string $file): ?array
{
    if (!is_file($file)) return null;
    try {
        $desc = require $file;                 // volontairement pas require_once
    } catch (Throwable $e) {
        return null;
    }
    return is_array($desc) ? $desc : null;
}

/**
 * Ajoute récursivement l'arbre $dir à $zip sous le préfixe $prefix.
 * Calqué sur backupAddDir() (admin/backup.php). Les artefacts git sont exclus,
 * comme à l'export de module : l'archive produite reste un ZIP de module propre,
 * directement ré-uploadable pour revenir à la version précédente.
 */
function modules_zip_dir(ZipArchive $zip, string $dir, string $prefix): bool
{
    if (!is_dir($dir)) return false;
    $it = new RecursiveIteratorIterator(
        new RecursiveDirectoryIterator($dir, FilesystemIterator::SKIP_DOTS),
        RecursiveIteratorIterator::SELF_FIRST
    );
    foreach ($it as $file) {
        $rel = str_replace('\\', '/', substr($file->getPathname(), strlen($dir) + 1));
        if ($rel === '') continue;
        if (preg_match('#(^|/)\.git#', $rel)) continue;
        if ($file->isDir()) {
            $zip->addEmptyDir($prefix . '/' . $rel);
        } elseif ($file->isFile()) {
            if (!$zip->addFile($file->getPathname(), $prefix . '/' . $rel)) return false;
        }
    }
    return true;
}

/**
 * Purge les sauvegardes module_<name>_*.zip au-delà des $keep plus récentes.
 * Calqué sur pruneBackups() (admin/update.php).
 */
function modules_prune_backups(string $backupDir, string $name, int $keep): int
{
    $files = glob($backupDir . '/module_' . $name . '_*.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;
}

/**
 * Active un module : hook on_enable + setActive + provisioning.
 * Pré-requis : dépendances déjà vérifiées par l'appelant.
 * @return array{ok: bool, error: ?string, created: int, roles: int}
 */
function modules_activate_one(Module $moduleRegistry, Database $db, array $desc): array
{
    $name = $desc['name'];

    if (!empty($desc['on_enable']) && is_file($desc['on_enable'])) {
        try {
            $cb = require $desc['on_enable'];
            if (is_callable($cb)) {
                $err = null;
                if (!$cb($db, $err)) {
                    return ['ok' => false, 'error' => $err ?: t('module.enable_failed'), 'created' => 0, 'roles' => 0];
                }
            }
        } catch (Throwable $e) {
            return ['ok' => false, 'error' => t('module.enable_failed') . ' : ' . $e->getMessage(), 'created' => 0, 'roles' => 0];
        }
    }

    $moduleRegistry->setActive($name, true);

    // Provisioning applicatif : créer/synchroniser les utilisateurs déclarés
    // par le module (descripteur provision_users), idempotent.
    $prov = Provisioner::syncModule($moduleRegistry, new User(), $name);
    return ['ok' => true, 'error' => null, 'created' => $prov['created'], 'roles' => $prov['roles']];
}

if ($_SERVER['REQUEST_METHOD'] === 'POST') {
    $token = $_POST['csrf_token'] ?? '';
    if (!hash_equals(Session::get('csrf_token', ''), $token)) {
        Session::setFlash('error', t('msg.error'));
        header('Location: /admin/modules.php');
        exit;
    }

    $action     = $_POST['action']      ?? '';

    /* ── Ajout d'un module via ZIP (un dossier de module à la racine) ──────── */
    if ($action === 'add_module') {
        $modulesDir = WEBROOT_PATH . '/modules';
        $f = $_FILES['module_zip'] ?? null;

        if (!$f || ($f['error'] ?? UPLOAD_ERR_NO_FILE) !== UPLOAD_ERR_OK || !is_uploaded_file($f['tmp_name'])) {
            Session::setFlash('error', t('module.upload_error'));
            header('Location: /admin/modules.php'); exit;
        }
        if ((int)$f['size'] > 50 * 1024 * 1024) {
            Session::setFlash('error', t('module.upload_error'));
            header('Location: /admin/modules.php'); exit;
        }

        $mime = (new finfo(FILEINFO_MIME_TYPE))->file($f['tmp_name']);
        if (!in_array($mime, ['application/zip', 'application/x-zip-compressed', 'application/octet-stream'], true)
            || !class_exists('ZipArchive')) {
            Session::setFlash('error', t('module.upload_invalid_zip'));
            header('Location: /admin/modules.php'); exit;
        }

        $zip = new ZipArchive();
        if ($zip->open($f['tmp_name']) !== true) {
            Session::setFlash('error', t('module.upload_invalid_zip'));
            header('Location: /admin/modules.php'); exit;
        }

        // Analyse : un unique dossier racine + présence de <dir>/module.php
        $top = null; $bad = false; $hasModule = false; $files = [];
        for ($i = 0; $i < $zip->numFiles; $i++) {
            $entry = $zip->getNameIndex($i);
            if ($entry === false) continue;
            $norm = str_replace('\\', '/', $entry);
            if (str_contains($norm, '__MACOSX/')) continue;
            if (str_contains($norm, '..')) { $bad = true; break; }      // zip-slip
            $parts = explode('/', $norm);
            if (count($parts) < 2 && !str_ends_with($norm, '/')) { $bad = true; break; } // fichier à la racine
            $seg = $parts[0];
            if ($seg === '') continue;
            if ($top === null) $top = $seg;
            elseif ($top !== $seg) { $bad = true; break; }              // >1 dossier racine
            if ($norm === $top . '/module.php') $hasModule = true;
            if (!str_ends_with($norm, '/')) $files[] = $norm;
        }

        if ($bad || $top === null) {
            $zip->close();
            Session::setFlash('error', t('module.upload_bad_structure'));
            header('Location: /admin/modules.php'); exit;
        }
        if (!preg_match('/^[A-Za-z0-9_]+$/', $top)) {
            $zip->close();
            Session::setFlash('error', t('module.upload_bad_structure'));
            header('Location: /admin/modules.php'); exit;
        }
        if (!$hasModule) {
            $zip->close();
            Session::setFlash('error', t('module.upload_no_descriptor'));
            header('Location: /admin/modules.php'); exit;
        }
        // Dossier déjà présent : mise à jour du module en place, sauf si ce
        // dossier n'est pas un module (pas de module.php) — nom occupé, on refuse.
        $target   = $modulesDir . '/' . $top;
        $isUpdate = is_dir($target);
        if ($isUpdate && !is_file($target . '/module.php')) {
            $zip->close();
            Session::setFlash('error', t('module.upload_exists', ['name' => $top]));
            header('Location: /admin/modules.php'); exit;
        }

        // Extraction fichier par fichier dans un staging (même FS → rename fiable)
        $staging = $modulesDir . '/.upload_' . bin2hex(random_bytes(4));
        $ok = true;
        foreach ($files as $entry) {
            $dest = $staging . '/' . $entry;
            $destDir = dirname($dest);
            if (!is_dir($destDir) && !mkdir($destDir, 0755, true)) { $ok = false; break; }
            $content = $zip->getFromName($entry);
            if ($content === false || file_put_contents($dest, $content) === false) { $ok = false; break; }
        }
        $zip->close();

        if (!$ok || !is_file($staging . '/' . $top . '/module.php')) {
            modules_rrmdir($staging);
            Session::setFlash('error', t('module.upload_extract_failed'));
            header('Location: /admin/modules.php'); exit;
        }

        // Le descripteur doit déclarer le nom du dossier : sinon le bootstrap
        // enregistrerait le module sous un autre nom (module fantôme, rôles et
        // config de l'ancien nom orphelins).
        $newDesc = modules_read_descriptor($staging . '/' . $top . '/module.php');
        if ($newDesc === null) {
            modules_rrmdir($staging);
            Session::setFlash('error', t('module.upload_no_descriptor'));
            header('Location: /admin/modules.php'); exit;
        }
        if (($newDesc['name'] ?? '') !== $top) {
            modules_rrmdir($staging);
            Session::setFlash('error', t('module.update_name_mismatch', [
                'name' => $top,
                'decl' => (string)($newDesc['name'] ?? '?'),
            ]));
            header('Location: /admin/modules.php'); exit;
        }

        /* ── Ajout : simple publication du staging ──────────────────────── */
        if (!$isUpdate) {
            if (!@rename($staging . '/' . $top, $target)) {
                modules_rrmdir($staging);
                Session::setFlash('error', t('module.upload_extract_failed'));
                header('Location: /admin/modules.php'); exit;
            }
            modules_rrmdir($staging);

            Session::setFlash('success', t('module.upload_success', ['name' => $top]));
            header('Location: /admin/modules.php'); exit;
        }

        /* ── Mise à jour : remplacement des FICHIERS uniquement ─────────────
         * Les données du module vivent hors de son dossier (tables de
         * securite/database.sqlite, config securite/modules/<name>.json) : on
         * ne touche donc JAMAIS aux lignes `modules`/`roles`/`user_roles`, ni
         * à la config JSON, ni à l'état actif — contrairement à remove_module.
         */
        $fromVersion = (string)($moduleRegistry->get($top)['version'] ?? '?');
        $toVersion   = (string)($newDesc['version'] ?? '?');

        // 1. Maintenance ON le temps du swap (l'app_admin garde l'accès, cf. bootstrap).
        $settings->set('maintenance_mode', '1');

        // 2. Sauvegarde de la version installée. L'archive est préfixée par le
        //    nom du module : elle est donc elle-même ré-uploadable par ce
        //    formulaire pour revenir en arrière.
        $backupDir = SECURITE_PATH . '/backups';
        if (!is_dir($backupDir)) @mkdir($backupDir, 0750, true);
        $backupName = 'module_' . $top . '_' . date('Ymd_His') . '.zip';
        $backupPath = $backupDir . '/' . $backupName;
        $backupOk   = false;
        $backup     = new ZipArchive();
        if ($backup->open($backupPath, ZipArchive::CREATE | ZipArchive::OVERWRITE) === true) {
            $backupOk = modules_zip_dir($backup, $target, $top);
            $backup->close();
            $backupOk = $backupOk && is_file($backupPath);
        }
        if (!$backupOk) {
            @unlink($backupPath);
            $settings->set('maintenance_mode', '0');
            modules_rrmdir($staging);
            Session::setFlash('error', t('module.update_backup_failed'));
            header('Location: /admin/modules.php'); exit;
        }

        // 3. Swap : ancien dossier écarté, nouveau publié, rollback si le second
        //    rename échoue (le premier a réussi → la version installée est encore là).
        $old = $modulesDir . '/.old_' . bin2hex(random_bytes(4));
        if (!@rename($target, $old)) {
            $settings->set('maintenance_mode', '0');
            modules_rrmdir($staging);
            Session::setFlash('error', t('module.update_failed', ['name' => $top]));
            header('Location: /admin/modules.php'); exit;
        }
        if (!@rename($staging . '/' . $top, $target)) {
            if (@rename($old, $target)) {
                $settings->set('maintenance_mode', '0');
                modules_rrmdir($staging);
                Session::setFlash('error', t('module.update_rollback', ['name' => $top]));
            } else {
                // Le module n'est plus en place et n'a pas pu être restauré :
                // maintenance laissée ACTIVE (même politique qu'admin/update.php).
                modules_rrmdir($staging);
                Session::setFlash('error', t('module.update_rollback_failed', [
                    'name'   => $top,
                    'backup' => $backupName,
                ]));
            }
            header('Location: /admin/modules.php'); exit;
        }
        modules_rrmdir($old);
        modules_rrmdir($staging);

        // 4. Métadonnées en base : label/description/libellés de rôles de la
        //    nouvelle version. Descripteur relu DEPUIS LA CIBLE pour que les
        //    chemins construits sur __DIR__ (on_enable, autoload_path) soient bons.
        $liveDesc = modules_read_descriptor($target . '/module.php') ?? $newDesc;
        $moduleRegistry->syncDescriptor($liveDesc);

        // 5. Module actif : on rejoue on_enable, point d'accroche naturel des
        //    migrations de schéma d'une nouvelle version (hooks idempotents).
        //    Non bloquant : un échec ne désactive pas le module.
        $hookWarning = null;
        if ($moduleRegistry->isActive($top) && !empty($liveDesc['on_enable']) && is_file($liveDesc['on_enable'])) {
            try {
                $cb = require $liveDesc['on_enable'];
                if (is_callable($cb)) {
                    $err = null;
                    if (!$cb($db, $err)) $hookWarning = $err ?: t('module.enable_failed');
                }
            } catch (Throwable $e) {
                $hookWarning = $e->getMessage();
            }
        }

        modules_prune_backups($backupDir, $top, 3);
        $settings->set('maintenance_mode', '0');

        Logger::getInstance()->info(
            'Module updated: ' . $top . ' ' . $fromVersion . ' -> ' . $toVersion,
            ['backup' => $backupName]
        );

        Session::setFlash('success', t('module.update_success', [
            'name'   => $top,
            'from'   => $fromVersion,
            'to'     => $toVersion,
            'backup' => $backupName,
        ]));
        if ($hookWarning !== null) {
            Session::setFlash('warning', t('module.update_on_enable_warn', [
                'name'  => $top,
                'error' => $hookWarning,
            ]));
        }
        header('Location: /admin/modules.php'); exit;
    }

    $moduleName = $_POST['module_name'] ?? '';

    if (!$moduleRegistry->isRegistered($moduleName)) {
        Session::setFlash('error', t('msg.not_found'));
        header('Location: /admin/modules.php');
        exit;
    }

    /* ── Activer un module et toutes ses dépendances (transitives) ─────────── */
    if ($action === 'enable_required') {
        $all = $moduleRegistry->getAll();

        // Clôture transitive des dépendances du module cible (lui inclus).
        // Une dépendance non enregistrée reste dans la liste : elle sera
        // rapportée en « dépendance manquante » par le bilan final.
        $targets = [];
        $queue   = [$moduleName];
        while (!empty($queue)) {
            $n = array_shift($queue);
            if (isset($targets[$n])) continue;
            $targets[$n] = true;
            foreach (($all[$n]['depends_on'] ?? []) as $dep) {
                $queue[] = $dep;
            }
        }

        $enabled = [];   // noms activés lors de cette requête
        $failed  = [];   // nom => raison
        $totCreated = 0;
        $totRoles   = 0;

        // Multi-passes : on répète tant qu'une passe active au moins un module.
        // Un module dont les dépendances ne sont pas encore actives est simplement
        // sauté et retenté à la passe suivante (gère oracle → cmdb → cnp sans tri).
        // Un module en échec (on_enable) n'est jamais retenté.
        $progress = true;
        while ($progress) {
            $progress = false;
            foreach ($all as $name => $desc) {
                if (!isset($targets[$name])) continue;
                if ($moduleRegistry->isActive($name) || isset($failed[$name])) continue;

                $depsOk = true;
                foreach (($desc['depends_on'] ?? []) as $dep) {
                    if (!$moduleRegistry->isActive($dep)) { $depsOk = false; break; }
                }
                if (!$depsOk) continue;

                $res = modules_activate_one($moduleRegistry, $db, $desc);
                if ($res['ok']) {
                    $enabled[]   = $name;
                    $totCreated += $res['created'];
                    $totRoles   += $res['roles'];
                    $progress    = true;
                } else {
                    $failed[$name] = $res['error'];
                }
            }
        }

        // Modules ciblés restés inactifs sans échec direct : dépendances
        // insatisfaisables (en échec, non enregistrées, ou cycle).
        foreach ($all as $name => $desc) {
            if (!isset($targets[$name])) continue;
            if ($moduleRegistry->isActive($name) || isset($failed[$name])) continue;
            $missing = [];
            foreach (($desc['depends_on'] ?? []) as $dep) {
                if (!$moduleRegistry->isActive($dep)) $missing[] = $dep;
            }
            $failed[$name] = t('module.deps_missing', [
                'module' => $name,
                'deps'   => implode(', ', $missing),
            ]);
        }

        if (!empty($enabled)) {
            $msg = t('module.enable_required_summary', ['n' => count($enabled), 'list' => implode(', ', $enabled)]);
            if ($totCreated > 0 || $totRoles > 0) {
                $msg .= ' ' . t('module.provision_summary', ['created' => $totCreated, 'roles' => $totRoles]);
            }
            Session::setFlash('success', $msg);
        } elseif (empty($failed)) {
            Session::setFlash('info', t('module.enable_required_none'));
        }
        if (!empty($failed)) {
            $parts = [];
            foreach ($failed as $name => $reason) {
                $parts[] = $name . ' — ' . $reason;
            }
            Session::setFlash('error', t('module.enable_required_failed', ['n' => count($failed)]) . ' ' . implode(' ; ', $parts));
        }

        header('Location: /admin/modules.php');
        exit;
    }

    if ($action === 'enable') {
        // Vérifier les dépendances déclarées dans le descriptor (depends_on)
        $desc    = $moduleRegistry->get($moduleName);
        $deps    = $desc['depends_on'] ?? [];
        $missing = [];
        foreach ($deps as $dep) {
            if (!$moduleRegistry->isActive($dep)) $missing[] = $dep;
        }
        if (!empty($missing)) {
            Session::setFlash('error', t('module.deps_missing', [
                'module' => $moduleName,
                'deps'   => implode(', ', $missing),
            ]));
            header('Location: /admin/modules.php');
            exit;
        }

        // Hook d'activation : si le module définit on_enable (chemin de fichier PHP
        // qui retourne une callable), on l'exécute et on annule l'activation en cas
        // d'échec. Si une URL de configuration (config_url) est déclarée, on
        // redirige l'admin dessus pour qu'il puisse configurer le module sans avoir
        // à connaître l'URL directe.
        $configUrl = $desc['config_url'] ?? null;
        $failureRedirect = $configUrl ?: '/admin/modules.php';

        $res = modules_activate_one($moduleRegistry, $db, $desc);
        if (!$res['ok']) {
            Session::setFlash('error', $res['error']);
            header('Location: ' . $failureRedirect);
            exit;
        }
        if ($res['created'] > 0 || $res['roles'] > 0) {
            Session::setFlash('success', t('msg.saved') . ' ' .
                t('module.provision_summary', ['created' => $res['created'], 'roles' => $res['roles']]));
        } else {
            Session::setFlash('success', t('msg.saved'));
        }

    } elseif ($action === 'disable') {
        // Empêcher la désactivation si un autre module actif en dépend
        $dependents = [];
        foreach ($moduleRegistry->getAll() as $other) {
            if (!($other['active'] ?? 0)) continue;
            $otherDeps = $other['depends_on'] ?? [];
            if (in_array($moduleName, $otherDeps, true)) {
                $dependents[] = $other['name'];
            }
        }
        if (!empty($dependents)) {
            Session::setFlash('error', t('module.deps_dependents', [
                'module'     => $moduleName,
                'dependents' => implode(', ', $dependents),
            ]));
            header('Location: /admin/modules.php');
            exit;
        }

        // Warn if users still have an admin role for this module
        $affected = (int)$db->fetchScalar(
            "SELECT COUNT(DISTINCT u.id)
             FROM users u
             JOIN user_roles ur ON ur.user_id = u.id
             JOIN roles r       ON r.id = ur.role_id
             WHERE u.active = 1
               AND r.module = ?
               AND r.name LIKE '%_admin'",
            [$moduleName]
        );
        if ($affected > 0) {
            Session::setFlash(
                'warning',
                t('module.warn_users_linked', ['n' => $affected, 'role' => $moduleName . '_admin'])
            );
        }

        $moduleRegistry->setActive($moduleName, false);
        Session::setFlash('success', t('msg.saved'));

    } elseif ($action === 'remove_module') {
        // Suppression : seulement si le module est désactivé.
        if ($moduleRegistry->isActive($moduleName)) {
            Session::setFlash('error', t('module.cannot_delete_active'));
            header('Location: /admin/modules.php'); exit;
        }
        if (!preg_match('/^[A-Za-z0-9_]+$/', $moduleName)) {
            Session::setFlash('error', t('msg.error'));
            header('Location: /admin/modules.php'); exit;
        }

        $dir = WEBROOT_PATH . '/modules/' . $moduleName;
        $dirOk = !is_dir($dir) || modules_rrmdir($dir);

        // Nettoyage base : modules + rôles (user_roles en cascade) + config.
        $db->execute("DELETE FROM modules WHERE name = ?", [$moduleName]);
        $db->execute("DELETE FROM roles WHERE module = ?", [$moduleName]);
        @unlink(SECURITE_PATH . '/modules/' . $moduleName . '.json');

        Session::setFlash(
            $dirOk ? 'success' : 'warning',
            $dirOk ? t('module.delete_success') : t('module.delete_failed', ['name' => $moduleName])
        );
    }

    header('Location: /admin/modules.php');
    exit;
}

$modules = $moduleRegistry->getAll();

View::display(__DIR__ . '/views/modules.php', [
    'modules' => $modules,
]);
