<?php
/**
 * LSGROUP - SAAS AGGREGATOR PRO v2 (CORRIGÉ)
 *
 * Corrections appliquées :
 *  [C1] require ? JSON  : supprime le risque d'exécution de code arbitraire (RCE)
 *  [C2] flock()         : verrou exclusif pour éviter les race conditions
 *  [C3] Écriture atomique via fichier .tmp + rename()
 *  [C4] chown/chgrp avec log d'échec
 *  [C5] Vérification explicite du répertoire de base avant glob()
 *  [C6] Cache = $currentState uniquement (pas de croissance infinie)
 *  [C7] normalizeClientName() lève une exception si résultat vide
 *  [C8] Validation/sanitisation de toutes les valeurs issues des fichiers sources
 *  [C9] chmod repositionné correctement dans le foreach
 * [C10] Rotation automatique du fichier de log à 5 Mo
 */

declare(strict_types=1);

// ================= CONFIG =================
$baseClientsDir = '/var/www/clients/';
$cacheFile      = __DIR__ . '/cache/hash_cache.json';
$backupDir      = __DIR__ . '/backup/';
$logFile        = __DIR__ . '/logs/saas.log';

// [C5] Vérification du répertoire de base AVANT tout traitement
if (!is_dir($baseClientsDir) || !is_readable($baseClientsDir)) {
    $msg = "[SAAS AGGREGATOR] Répertoire de base inaccessible : {$baseClientsDir}";
    error_log($msg);
    fwrite(STDERR, $msg . "\n");
    exit(1);
}

@mkdir(__DIR__ . '/cache', 0755, true);
@mkdir($backupDir,         0755, true);
@mkdir(__DIR__ . '/logs',  0755, true);


// ================= NORMALISATION =================
// [C7] Lève une exception si le nom normalisé est vide (ex: dossier "---")
function normalizeClientName(string $name): string
{
    $name = strtoupper($name);
    $name = str_replace([' ', '-', "'"], '_', $name);
    $name = preg_replace('/[^A-Z0-9_]/', '', $name);

    if ($name === '') {
        throw new \InvalidArgumentException(
            "Nom de client invalide après normalisation (valeur d'origine transmise séparément)"
        );
    }

    return $name;
}


// ================= SANITISATION =================
// [C8] Fonctions de validation pour chaque type de champ
function sanitizeIdentifier(string $value, int $maxLen = 64): string
{
    return substr(preg_replace('/[^a-zA-Z0-9_\-]/', '', $value), 0, $maxLen);
}

// Sanitisation spéciale pour DB_USER : autorise @ en plus
function sanitizeDbUser(string $value, int $maxLen = 64): string
{
    return substr(preg_replace('/[^a-zA-Z0-9_\-@]/', '', $value), 0, $maxLen);
}

function sanitizeHost(string $value): string
{
    // Accepte IP ou hostname valide
    $value = trim($value);
    if (filter_var($value, FILTER_VALIDATE_IP)) {
        return $value;
    }
    // Hostname : lettres, chiffres, tirets, points
    $hostname = preg_replace('/[^a-zA-Z0-9.\-]/', '', $value);
    return $hostname !== '' ? $hostname : '127.0.0.1';
}

function sanitizePort(mixed $value, int $default = 8728): int
{
    $port = (int) $value;
    return ($port >= 1 && $port <= 65535) ? $port : $default;
}

function sanitizeString(mixed $value, int $maxLen = 255): string
{
    return substr((string) $value, 0, $maxLen);
}


// ================= LOG AVEC ROTATION =================
// [C10] Rotation automatique à 5 Mo
function writeLog(string $logFile, string $message): void
{
    if (file_exists($logFile) && filesize($logFile) > 5 * 1024 * 1024) {
        rename($logFile, $logFile . '.' . date('Ymd_His'));
    }
    file_put_contents($logFile, $message, FILE_APPEND | LOCK_EX);
}


// ================= ÉCRITURE ATOMIQUE AVEC VERROU =================
// [C2] flock() — un seul processus écrit à la fois
// [C3] rename() — le fichier final est toujours complet ou absent, jamais à moitié écrit
function writeFileAtomic(string $targetPath, string $content): bool
{
    $tmpPath = $targetPath . '.tmp.' . getmypid();

    $fp = fopen($tmpPath, 'w');
    if ($fp === false) {
        error_log("[SAAS] Impossible de créer le fichier temporaire : {$tmpPath}");
        return false;
    }

    if (!flock($fp, LOCK_EX)) {
        fclose($fp);
        @unlink($tmpPath);
        error_log("[SAAS] Impossible d'obtenir le verrou sur : {$tmpPath}");
        return false;
    }

    $written = fwrite($fp, $content);
    fflush($fp);
    flock($fp, LOCK_UN);
    fclose($fp);

    if ($written === false) {
        @unlink($tmpPath);
        error_log("[SAAS] Échec de l'écriture dans : {$tmpPath}");
        return false;
    }

    // rename() est atomique sur le même filesystem
    if (!rename($tmpPath, $targetPath)) {
        @unlink($tmpPath);
        error_log("[SAAS] Échec du rename() vers : {$targetPath}");
        return false;
    }

    return true;
}


// ================= BLACKLIST =================
$excludedArray = array_flip([
    'ANEW','MESCLIENTS','MESCLIENTS_FEDAPAY','MESCLIENTS_FEDAPAY_VRAIE',
    'MESCLIENTS01','MESCLIENTS02','AD','ADMIN01','ADMINFEDAY','ADMINLS',
    'AGOUTIWIFI','ALI2','BJ2025','BLACKWIFI','BONHEUR','COMPTEPAYE',
    'DEMO','DJOEHZONE','DMS','DMSDOC2','ERRORS','EXPERT','FEDAPAYS',
    'FEDAPAYVRAIE','FEDAPAYVRAIE2','GODWIN','GODWINS','GRACEPLUS','GRATIAS',
    'INSCRIPTION','INSCRIT','LINE5','LSTEST','MAO','MIKHMOMV3_MASTER',
    'NEXA','NOUR','OTAKU','PAKI','PHONEBOOK','RALPHY','SDT',
    'SERVICE','SIDI','STRATEGE','TAC2','TESTLS02','TESTLS2',
    'VPN','WEBHOOKCLIENT','WIFIAH','WIFIRAREHV843Q9J'
]);

// ================= CHARGEMENT CACHE =================
// [C1] Le cache est désormais un JSON pur — aucune exécution de code
$cache = [];
if (file_exists($cacheFile) && is_readable($cacheFile)) {
    $decoded = json_decode(file_get_contents($cacheFile), true);
    $cache   = is_array($decoded) ? $decoded : [];
}


// ================= SCAN =================
$dirs         = glob($baseClientsDir . '*', GLOB_ONLYDIR) ?: [];
$currentState = [];   // [C6] Sera le nouveau cache complet à la fin
$totalConfigs = 0;
$totalClients = 0;

foreach ($dirs as $path) {

    $folderRaw = basename($path);

    // Ignorer dossiers cachés
    if ($folderRaw[0] === '.') continue;

    // [C7] Normalisation avec gestion d'exception
    try {
        $folderName = normalizeClientName($folderRaw);
    } catch (\InvalidArgumentException $e) {
        writeLog($logFile, sprintf(
            "[%s] ??  Dossier ignoré (nom invalide après normalisation) : %s\n",
            date('Y-m-d H:i:s'),
            $folderRaw
        ));
        continue;
    }

    // Blacklist
    if (isset($excludedArray[$folderName])) continue;

    $mikrotikDir = $path . '/admin/MIKROTIK/';
    $secureDir   = $path . '/secure/';

    if (!is_dir($mikrotikDir) || !is_dir($secureDir)) continue;

    $jsonFiles = glob($mikrotikDir . '*.json') ?: [];
    if (!$jsonFiles) continue;

    // ?? Charger l'ancien secure_allconfig.json de CE client (lecture JSON pure) ??
    // [C1] On lit un .json au lieu de require un .php
    $outputFile  = $secureDir . 'secure_allconfig.json';
    $guardFile   = $secureDir . 'secure_allconfig.php';   // garde d'accès HTTP
    $oldConfigs  = [];

    if (file_exists($outputFile) && is_readable($outputFile)) {
        $decoded    = json_decode(file_get_contents($outputFile), true);
        $oldConfigs = is_array($decoded) ? $decoded : [];
    }

    // ?? Collecter les configs de tous les MikroTik de ce client ??
    $clientConfigs = [];

    foreach ($jsonFiles as $file) {

        $mikrotikId    = basename($file, '.json');
        $mikrotikIdSafe = sanitizeIdentifier($mikrotikId);

        // [C1] Source de credentials : fichier .php lu via require (fichier de confiance,
        //      écrit par l'administrateur, pas par le script lui-même)
        $phpFile = $secureDir . $mikrotikId . '.php';
        if (!is_file($phpFile) || !is_readable($phpFile)) continue;

        // Clé cache basée sur dossier+fichier (stable même si MIKROTIK_NAME change)
        $cacheKey = $folderName . '_' . $mikrotikIdSafe;
        $hash     = md5($file . filemtime($file)) . ':' . md5($phpFile . filemtime($phpFile));

        $currentState[$cacheKey] = $hash;

        // INCRÉMENTAL : hash inchangé ? réutiliser l'ancienne config
        if (isset($cache[$cacheKey]) && $cache[$cacheKey] === $hash && isset($oldConfigs[$cacheKey])) {
            // Récupérer la clé finale (MIKROTIK_NAME) depuis l'ancienne config
            $oldEntry   = $oldConfigs[$cacheKey];
            $finalKey   = sanitizeString((string)($oldEntry['MIKROTIK_NAME'] ?? $mikrotikIdSafe), 64);
            $clientConfigs[$finalKey] = $oldEntry;
            continue;
        }

        // RECHARGEMENT : fichier nouveau ou modifié
        $techData = require $phpFile;
        if (!is_array($techData)) continue;

        // Clé finale = MIKROTIK_NAME (unique par client, garanti)
        $finalKey = sanitizeString((string)($techData['MIKROTIK_NAME'] ?? $mikrotikIdSafe), 64);

        // Mettre à jour le cache avec la cacheKey stable
        $cache[$cacheKey] = $hash;

        // [C8] Toutes les valeurs sont validées/sanitisées avant stockage
        $clientConfigs[$finalKey] = [
            'DB_HOST'      => sanitizeHost((string)($techData['DB_HOST']      ?? '127.0.0.1')),
            'DB_NAME'      => sanitizeIdentifier((string)($techData['DB_NAME']      ?? '')),
            'DB_USER'      => sanitizeDbUser((string)($techData['DB_USER']      ?? '')),
            'DB_PASS'      => sanitizeString((string)($techData['DB_PASS']      ?? ''), 128),
            'API_HOST'     => sanitizeHost((string)($techData['API_HOST']     ?? '')),
            'API_USER'     => sanitizeString((string)($techData['API_USER']    ?? ''), 64),
            'API_PASS'     => sanitizeString((string)($techData['API_PASS']    ?? ''), 128),
            'API_PORT'     => sanitizePort($techData['API_PORT'] ?? 8728),
            'MIKROTIK_NAME'=> sanitizeString((string)($techData['MIKROTIK_NAME'] ?? $mikrotikIdSafe), 64),
            'SITE_NAME'    => sanitizeString((string)($techData['SITE_NAME']   ?? 'LOME'), 64),
            'DOMAIN'       => sanitizeString((string)($techData['DOMAIN']      ?? ''), 255),
            'quit_url'     => sanitizeString((string)($techData['quit_url']    ?? ''), 255),
        ];

    }

    if (!$clientConfigs) continue;

    // ?? Écrire secure_allconfig.json (données pures, pas de PHP exécutable) ??
    // [C2][C3] Écriture atomique avec verrou
    $jsonContent = json_encode($clientConfigs, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
    if ($jsonContent === false) {
        writeLog($logFile, sprintf(
            "[%s] ? json_encode échoué pour %s\n",
            date('Y-m-d H:i:s'),
            $folderName
        ));
        continue;
    }

    if (!writeFileAtomic($outputFile, $jsonContent)) {
        writeLog($logFile, sprintf(
            "[%s] ? Écriture échouée pour %s\n",
            date('Y-m-d H:i:s'),
            $folderName
        ));
        continue;
    }

    // ?? Écrire/maintenir le fichier garde PHP (bloque l'accès HTTP direct au JSON) ??
    // Ce fichier ne contient aucune donnée — il sert uniquement de garde HTTP
    if (!file_exists($guardFile)) {
        $guardContent = "<?php\n"
            . "// Accès direct interdit\n"
            . "http_response_code(403);\n"
            . "exit('Accès interdit');\n";
        writeFileAtomic($guardFile, $guardContent);
    }

    // [C9] Permissions placées correctement dans le foreach, après l'écriture
    // [C4] chown/chgrp avec log d'échec explicite
    foreach ([$outputFile, $guardFile] as $managedFile) {
        if (!file_exists($managedFile)) continue;

        chmod($managedFile, 0640);

        if (!@chown($managedFile, 'www-data')) {
            writeLog($logFile, sprintf(
                "[%s] ??  chown échoué sur %s (droits insuffisants ?)\n",
                date('Y-m-d H:i:s'),
                $managedFile
            ));
        }

        if (!@chgrp($managedFile, 'www-data')) {
            writeLog($logFile, sprintf(
                "[%s] ??  chgrp échoué sur %s (droits insuffisants ?)\n",
                date('Y-m-d H:i:s'),
                $managedFile
            ));
        }
    }

    $totalConfigs += count($clientConfigs);
    $totalClients++;

    echo "? {$folderName} ? " . count($clientConfigs) . " MikroTik(s) écrits dans {$outputFile}\n";
}


// ================= SAUVEGARDE CACHE =================
// [C6] On écrit uniquement $currentState — les clés obsolètes disparaissent naturellement,
//      plus besoin de boucle de nettoyage séparée
$encoded = json_encode($currentState, JSON_PRETTY_PRINT);
if ($encoded !== false) {
    writeFileAtomic($cacheFile, $encoded);
} else {
    error_log('[SAAS AGGREGATOR] Échec json_encode du cache.');
}


// ================= LOG FINAL =================
// [C10] writeLog() gère la rotation automatique
$logLine = sprintf(
    "[%s] %d clients traités | %d configs totales | %d entrées en cache\n",
    date('Y-m-d H:i:s'),
    $totalClients,
    $totalConfigs,
    count($currentState)
);
writeLog($logFile, $logLine);

echo "\n? SAAS OK — {$totalClients} clients | {$totalConfigs} configs\n";