<?php
/**
 * Fichier  : includes/ldap-helper.php
 * Auteur   : KREMER Régis
 * Version  : 2.6.111
 * Date     : 2026-07-22
 * Description : Authentification LDAP / Active Directory sécurisée, stockage chiffré,
 *               diagnostics, mapping groupes/permissions et synchronisation des comptes.
 *
 * Correctif :
 *   v2.6.108 (2026-07-21) — Compatibilité PHP 8.4 : paramètre nullable explicite pour empêcher les avertissements de corrompre les réponses JSON et la connexion.
 *   v2.6.111 (2026-07-22) — Surcharge locale des rôles, magasins et permissions pour les comptes LDAP, sans casser la synchronisation d’identité.
 *   v2.6.113 (2026-07-22) — Rôle Magasinier disponible dans les mappings LDAP et les instantanés d’autorisation.
 */

defined('APP_ROOT') || exit('Bootstrap MageStock requis.');

if (!defined('MAGESTOCK_LDAP_SETTINGS_FILE')) define('MAGESTOCK_LDAP_SETTINGS_FILE', DATA_DIR . '/ldap_settings.json');
if (!defined('MAGESTOCK_LDAP_AUDIT_FILE')) define('MAGESTOCK_LDAP_AUDIT_FILE', DATA_DIR . '/ldap_audit.json');
if (!defined('MAGESTOCK_LDAP_SECRET_DIR')) define('MAGESTOCK_LDAP_SECRET_DIR', DATA_DIR . '/.secrets');
if (!defined('MAGESTOCK_LDAP_MASTER_KEY_FILE')) define('MAGESTOCK_LDAP_MASTER_KEY_FILE', MAGESTOCK_LDAP_SECRET_DIR . '/master.key');
if (!defined('MAGESTOCK_LDAP_BIND_SECRET_FILE')) define('MAGESTOCK_LDAP_BIND_SECRET_FILE', MAGESTOCK_LDAP_SECRET_DIR . '/ldap-bind.enc');
if (!defined('MAGESTOCK_LDAP_CA_FILE')) define('MAGESTOCK_LDAP_CA_FILE', MAGESTOCK_LDAP_SECRET_DIR . '/ldap-ca.pem');
if (!defined('MAGESTOCK_LDAP_LOCK_FILE')) define('MAGESTOCK_LDAP_LOCK_FILE', MAGESTOCK_LDAP_SECRET_DIR . '/ldap.lock');

class MageStockLdapException extends RuntimeException {
    public string $publicMessage;
    public string $reasonCode;
    public function __construct(string $internalMessage, string $reasonCode = 'ldap_error', string $publicMessage = 'Identifiants incorrects ou service d’authentification indisponible.') {
        parent::__construct($internalMessage);
        $this->reasonCode = $reasonCode;
        $this->publicMessage = $publicMessage;
    }
}


function magestockLdapLower(string $value): string {
    return function_exists('mb_strtolower') ? mb_strtolower($value, 'UTF-8') : strtolower($value);
}
function magestockLdapCut(string $value, int $length): string {
    return function_exists('mb_substr') ? mb_substr($value, 0, $length, 'UTF-8') : substr($value, 0, $length);
}

function magestockLdapDefaults(): array {
    return [
        'schemaVersion' => 1,
        'enabled' => false,
        'mode' => 'local', // local | hybrid | ldap_priority
        'directoryType' => 'active_directory',
        'name' => 'Annuaire principal',
        'servers' => [
            ['host'=>'', 'port'=>636, 'encryption'=>'ldaps', 'timeout'=>5],
            ['host'=>'', 'port'=>636, 'encryption'=>'ldaps', 'timeout'=>5],
        ],
        'verifyCertificate' => true,
        'referrals' => false,
        'baseDn' => '',
        'bindDn' => '',
        'allowAnonymousBind' => false,
        'userBaseDn' => '',
        'userFilter' => '(&(objectCategory=person)(objectClass=user)(sAMAccountName={login}))',
        'loginAttribute' => 'sAMAccountName',
        'stableIdAttribute' => 'objectGUID',
        'displayNameAttribute' => 'displayName',
        'firstNameAttribute' => 'givenName',
        'lastNameAttribute' => 'sn',
        'emailAttribute' => 'mail',
        'memberOfAttribute' => 'memberOf',
        'disabledAttribute' => 'userAccountControl',
        'disabledValues' => ['2'],
        'groupBaseDn' => '',
        'groupFilter' => '(&(objectClass=group)(member={dn}))',
        'nestedGroups' => false,
        'denyUnmappedUsers' => true,
        'defaultRole' => 'user',
        'defaultAllowedWarehouses' => [],
        'defaultPermissions' => [],
        'groupMappings' => [],
        'syncOnLogin' => true,
        'disableMissingUsers' => false,
        'localFallbackAdminEnabled' => true,
        'lastSuccessfulTestAt' => '',
        'lastSuccessfulTestFingerprint' => '',
        'lastSuccessfulTestUser' => '',
        'lastErrorAt' => '',
        'lastErrorCode' => '',
        'updatedAt' => '',
        'updatedBy' => null,
    ];
}

function magestockLdapEnsureStorage(): void {
    if (!is_dir(MAGESTOCK_LDAP_SECRET_DIR)) @mkdir(MAGESTOCK_LDAP_SECRET_DIR, 0700, true);
    if (is_dir(MAGESTOCK_LDAP_SECRET_DIR)) @chmod(MAGESTOCK_LDAP_SECRET_DIR, 0700);
    if (!is_file(MAGESTOCK_LDAP_SETTINGS_FILE)) {
        magestockLdapAtomicWrite(MAGESTOCK_LDAP_SETTINGS_FILE, json_encode([magestockLdapDefaults()], JSON_PRETTY_PRINT|JSON_UNESCAPED_UNICODE|JSON_UNESCAPED_SLASHES));
    }
    if (!is_file(MAGESTOCK_LDAP_AUDIT_FILE)) {
        magestockLdapAtomicWrite(MAGESTOCK_LDAP_AUDIT_FILE, '[]');
    }
}

function magestockLdapAtomicWrite(string $file, string $contents, int $mode = 0600): bool {
    $dir = dirname($file);
    if (!is_dir($dir) && !@mkdir($dir, 0700, true)) return false;
    $tmp = $file . '.tmp.' . bin2hex(random_bytes(6));
    if (@file_put_contents($tmp, $contents, LOCK_EX) === false) return false;
    @chmod($tmp, $mode);
    if (!@rename($tmp, $file)) { @unlink($tmp); return false; }
    @chmod($file, $mode);
    return true;
}

function magestockLdapNormalizeHost(string $host): string {
    $host = trim($host);
    $host = preg_replace('#^ldaps?://#i', '', $host) ?? $host;
    $host = preg_replace('#/.*$#', '', $host) ?? $host;
    if ($host === '') return '';
    if (strlen($host) > 253 || !preg_match('/^[a-z0-9._:-]+$/i', $host)) {
        throw new MageStockLdapException('Nom de serveur LDAP invalide.', 'invalid_host', 'Le serveur LDAP indiqué est invalide.');
    }
    return $host;
}

function magestockLdapNormalizeFilter(string $filter, string $requiredPlaceholder = ''): string {
    $filter = trim($filter);
    if ($filter === '' || strlen($filter) > 2048 || $filter[0] !== '(' || substr($filter, -1) !== ')') {
        throw new MageStockLdapException('Filtre LDAP invalide.', 'invalid_filter', 'Le filtre LDAP est invalide.');
    }
    if ($requiredPlaceholder !== '' && !str_contains($filter, $requiredPlaceholder)) {
        throw new MageStockLdapException('Placeholder requis absent du filtre LDAP.', 'missing_placeholder', 'Le filtre LDAP doit contenir ' . $requiredPlaceholder . '.');
    }
    return $filter;
}

function magestockLdapNormalizeAttribute(string $value, string $fallback = ''): string {
    $value = trim($value);
    if ($value === '') return $fallback;
    if (!preg_match('/^[a-z][a-z0-9;._-]{0,127}$/i', $value)) {
        throw new MageStockLdapException('Attribut LDAP invalide: ' . $value, 'invalid_attribute', 'Un attribut LDAP configuré est invalide.');
    }
    return $value;
}

function magestockLdapNormalizeConfig(array $input, bool $strict = true): array {
    $current = magestockLdapReadConfig();
    $cfg = array_merge($current, $input);
    $cfg['schemaVersion'] = 1;
    $cfg['enabled'] = (bool)($cfg['enabled'] ?? false);
    $cfg['mode'] = in_array($cfg['mode'] ?? '', ['local','hybrid','ldap_priority'], true) ? $cfg['mode'] : 'local';
    $cfg['directoryType'] = in_array($cfg['directoryType'] ?? '', ['active_directory','openldap','freeipa','generic'], true) ? $cfg['directoryType'] : 'generic';
    $cfg['name'] = magestockLdapCut(trim((string)($cfg['name'] ?? 'Annuaire principal')), 120);
    $cfg['verifyCertificate'] = (bool)($cfg['verifyCertificate'] ?? true);
    $cfg['referrals'] = (bool)($cfg['referrals'] ?? false);
    $cfg['allowAnonymousBind'] = (bool)($cfg['allowAnonymousBind'] ?? false);
    $cfg['nestedGroups'] = (bool)($cfg['nestedGroups'] ?? false);
    $cfg['denyUnmappedUsers'] = (bool)($cfg['denyUnmappedUsers'] ?? true);
    $cfg['syncOnLogin'] = (bool)($cfg['syncOnLogin'] ?? true);
    $cfg['disableMissingUsers'] = (bool)($cfg['disableMissingUsers'] ?? false);
    $cfg['localFallbackAdminEnabled'] = (bool)($cfg['localFallbackAdminEnabled'] ?? true);
    $cfg['defaultRole'] = magestockNormalizeRole($cfg['defaultRole'] ?? 'user');
    $cfg['baseDn'] = trim((string)($cfg['baseDn'] ?? ''));
    $cfg['bindDn'] = trim((string)($cfg['bindDn'] ?? ''));
    $cfg['userBaseDn'] = trim((string)($cfg['userBaseDn'] ?? ''));
    $cfg['groupBaseDn'] = trim((string)($cfg['groupBaseDn'] ?? ''));
    foreach (['baseDn','bindDn','userBaseDn','groupBaseDn'] as $dnKey) {
        if (strlen($cfg[$dnKey]) > 2048 || str_contains($cfg[$dnKey], "\0")) throw new MageStockLdapException('DN LDAP invalide.', 'invalid_dn', 'Un DN LDAP configuré est invalide.');
    }

    $servers = is_array($cfg['servers'] ?? null) ? array_values($cfg['servers']) : [];
    $normalizedServers = [];
    for ($i=0; $i<2; $i++) {
        $server = is_array($servers[$i] ?? null) ? $servers[$i] : [];
        $host = magestockLdapNormalizeHost((string)($server['host'] ?? ''));
        $encryption = in_array($server['encryption'] ?? '', ['ldaps','starttls'], true) ? $server['encryption'] : 'ldaps';
        $port = (int)($server['port'] ?? ($encryption === 'ldaps' ? 636 : 389));
        if ($port < 1 || $port > 65535) $port = $encryption === 'ldaps' ? 636 : 389;
        $timeout = max(2, min(20, (int)($server['timeout'] ?? 5)));
        $normalizedServers[] = compact('host','port','encryption','timeout');
    }
    $cfg['servers'] = $normalizedServers;

    $cfg['userFilter'] = magestockLdapNormalizeFilter((string)($cfg['userFilter'] ?? ''), '{login}');
    $cfg['groupFilter'] = trim((string)($cfg['groupFilter'] ?? ''));
    if ($cfg['groupFilter'] !== '') $cfg['groupFilter'] = magestockLdapNormalizeFilter($cfg['groupFilter']);

    foreach ([
        'loginAttribute'=>'uid','stableIdAttribute'=>'entryUUID','displayNameAttribute'=>'cn',
        'firstNameAttribute'=>'givenName','lastNameAttribute'=>'sn','emailAttribute'=>'mail',
        'memberOfAttribute'=>'memberOf','disabledAttribute'=>''
    ] as $key=>$fallback) {
        $cfg[$key] = magestockLdapNormalizeAttribute((string)($cfg[$key] ?? ''), $fallback);
    }

    $cfg['disabledValues'] = array_values(array_filter(array_map(fn($v)=>magestockLdapCut(trim((string)$v),120), is_array($cfg['disabledValues'] ?? null) ? $cfg['disabledValues'] : []), fn($v)=>$v!==''));
    $cfg['defaultAllowedWarehouses'] = array_values(array_unique(array_filter(array_map('intval', is_array($cfg['defaultAllowedWarehouses'] ?? null) ? $cfg['defaultAllowedWarehouses'] : []), fn($v)=>$v>0)));
    $cfg['defaultPermissions'] = magestockLdapNormalizePermissions($cfg['defaultPermissions'] ?? []);

    $mappings = [];
    foreach (is_array($cfg['groupMappings'] ?? null) ? $cfg['groupMappings'] : [] as $mapping) {
        if (!is_array($mapping)) continue;
        $group = magestockLdapCut(trim((string)($mapping['group'] ?? '')), 512);
        if ($group === '') continue;
        $mappings[] = [
            'group'=>$group,
            'role'=>magestockNormalizeRole($mapping['role'] ?? 'user'),
            'allowedWarehouses'=>array_values(array_unique(array_filter(array_map('intval', is_array($mapping['allowedWarehouses'] ?? null) ? $mapping['allowedWarehouses'] : []), fn($v)=>$v>0))),
            'permissions'=>magestockLdapNormalizePermissions($mapping['permissions'] ?? []),
        ];
        if (count($mappings) >= 100) break;
    }
    $cfg['groupMappings'] = $mappings;

    if ($strict && $cfg['enabled'] && $cfg['mode'] !== 'local') {
        if (($cfg['servers'][0]['host'] ?? '') === '') throw new MageStockLdapException('Serveur principal LDAP manquant.', 'missing_server', 'Le serveur LDAP principal est obligatoire.');
        if ($cfg['baseDn'] === '') throw new MageStockLdapException('Base DN manquante.', 'missing_base_dn', 'La Base DN est obligatoire.');
        if (!$cfg['allowAnonymousBind'] && $cfg['bindDn'] === '') throw new MageStockLdapException('Bind DN manquant.', 'missing_bind_dn', 'Le compte technique Bind DN est obligatoire.');
    }
    return $cfg;
}

function magestockLdapNormalizePermissions($raw): array {
    $out = [];
    if (!is_array($raw)) return $out;
    foreach ($raw as $key=>$value) {
        $key = trim((string)$key);
        if ($key === '' || strlen($key) > 120 || !preg_match('/^[a-z0-9.*_-]+$/i', $key)) continue;
        $out[$key] = (bool)$value;
    }
    return $out;
}

/** Source d’autorisation d’un compte LDAP : annuaire ou surcharge locale MageStock. */
function magestockLdapAuthorizationSource(array $user): string {
    if (($user['authSource'] ?? 'local') !== 'ldap') return 'local';
    return (($user['authorizationSource'] ?? 'directory') === 'local') ? 'local' : 'directory';
}

/** Normalise un instantané des autorisations calculées depuis l’annuaire. */
function magestockLdapDirectoryAccessSnapshot(array $access): array {
    $role = magestockNormalizeRole($access['role'] ?? 'user');
    $allowed = array_values(array_unique(array_filter(array_map('intval', is_array($access['allowedWarehouses'] ?? null) ? $access['allowedWarehouses'] : []), fn($id)=>$id>0)));
    $permissions = magestockLdapNormalizePermissions($access['permissions'] ?? []);
    if ($role === 'admin') $permissions = ['*'=>true];
    return ['role'=>$role, 'allowedWarehouses'=>$allowed, 'permissions'=>$permissions];
}

function magestockLdapReadConfig(): array {
    magestockLdapEnsureStorage();
    $raw = @file_get_contents(MAGESTOCK_LDAP_SETTINGS_FILE);
    $decoded = json_decode($raw ?: '', true);
    $saved = is_array($decoded) && isset($decoded[0]) && is_array($decoded[0]) ? $decoded[0] : [];
    return array_merge(magestockLdapDefaults(), $saved);
}

function magestockLdapConfigFingerprint(array $cfg): string {
    $copy = $cfg;
    foreach (['enabled','mode','localFallbackAdminEnabled','lastSuccessfulTestAt','lastSuccessfulTestFingerprint','lastSuccessfulTestUser','lastErrorAt','lastErrorCode','updatedAt','updatedBy'] as $key) unset($copy[$key]);
    return hash('sha256', json_encode($copy, JSON_UNESCAPED_UNICODE|JSON_UNESCAPED_SLASHES));
}

function magestockLdapSaveConfig(array $input, ?array $actor = null, string $confirmation = ''): array {
    magestockLdapEnsureStorage();
    $old = magestockLdapReadConfig();
    // Ces métadonnées sont exclusivement contrôlées par le serveur et ne peuvent pas être injectées par l’API.
    foreach (['lastSuccessfulTestAt','lastSuccessfulTestFingerprint','lastSuccessfulTestUser','lastErrorAt','lastErrorCode','updatedAt','updatedBy'] as $serverKey) unset($input[$serverKey]);
    $cfg = magestockLdapNormalizeConfig($input, true);
    $oldFingerprint = magestockLdapConfigFingerprint($old);
    $newFingerprint = magestockLdapConfigFingerprint($cfg);
    if ($oldFingerprint !== $newFingerprint) {
        // Un test complet peut être exécuté sur un brouillon sans interrompre la configuration active.
        // Dans ce cas, le fingerprint testé est déjà enregistré dans la configuration courante.
        if (($old['lastSuccessfulTestFingerprint'] ?? '') === $newFingerprint) {
            $cfg['lastSuccessfulTestAt'] = (string)($old['lastSuccessfulTestAt'] ?? '');
            $cfg['lastSuccessfulTestFingerprint'] = $newFingerprint;
            $cfg['lastSuccessfulTestUser'] = (string)($old['lastSuccessfulTestUser'] ?? '');
        } else {
            $cfg['lastSuccessfulTestAt'] = '';
            $cfg['lastSuccessfulTestFingerprint'] = '';
            $cfg['lastSuccessfulTestUser'] = '';
        }
    }
    if ($cfg['enabled'] && $cfg['mode'] !== 'local') {
        if (!magestockLdapBindSecretConfigured() && !$cfg['allowAnonymousBind']) {
            throw new MageStockLdapException('Secret de bind absent.', 'missing_bind_secret', 'Configurez le mot de passe du compte technique avant d’activer LDAP.');
        }
        if ($cfg['mode'] === 'ldap_priority') {
            if ($confirmation !== 'LDAP PRIORITAIRE') throw new MageStockLdapException('Confirmation LDAP prioritaire absente.', 'confirmation_required', 'Saisissez LDAP PRIORITAIRE pour activer ce mode.');
            if (!$cfg['localFallbackAdminEnabled'] || !magestockLdapHasEmergencyLocalAdmin()) {
                throw new MageStockLdapException('Aucun administrateur local de secours.', 'missing_emergency_admin', 'Un administrateur local actif avec mot de passe doit rester disponible.');
            }
        }
        $fp = magestockLdapConfigFingerprint($cfg);
        $testedAt = strtotime((string)($cfg['lastSuccessfulTestAt'] ?? '')) ?: 0;
        if (($cfg['lastSuccessfulTestFingerprint'] ?? '') !== $fp || $testedAt < time() - 86400) {
            throw new MageStockLdapException('Configuration LDAP non testée ou test expiré.', 'test_required', 'Exécutez un test d’authentification complet réussi avant d’activer LDAP.');
        }
    }
    $cfg['updatedAt'] = date('c');
    $cfg['updatedBy'] = $actor['id'] ?? null;
    if (!magestockLdapAtomicWrite(MAGESTOCK_LDAP_SETTINGS_FILE, json_encode([$cfg], JSON_PRETTY_PRINT|JSON_UNESCAPED_UNICODE|JSON_UNESCAPED_SLASHES))) {
        throw new MageStockLdapException('Impossible d’écrire la configuration LDAP.', 'settings_write_failed', 'Impossible d’enregistrer la configuration LDAP.');
    }
    magestockLdapAudit('config_saved', ['mode'=>$cfg['mode'],'enabled'=>$cfg['enabled']], $actor);
    return $cfg;
}

function magestockLdapMasterKey(): string {
    magestockLdapEnsureStorage();
    $env = trim((string)(getenv('MAGESTOCK_SECRET_KEY') ?: ''));
    if ($env !== '') {
        if (preg_match('/^[a-f0-9]{64}$/i', $env)) return hex2bin($env);
        $b64 = base64_decode($env, true);
        if (is_string($b64) && strlen($b64) >= 32) return hash('sha256', $b64, true);
        return hash('sha256', $env, true);
    }
    if (!is_file(MAGESTOCK_LDAP_MASTER_KEY_FILE)) {
        $key = random_bytes(32);
        if (!magestockLdapAtomicWrite(MAGESTOCK_LDAP_MASTER_KEY_FILE, base64_encode($key), 0600)) {
            throw new MageStockLdapException('Impossible de créer la clé maître.', 'master_key_failed', 'Impossible de sécuriser les secrets LDAP sur ce serveur.');
        }
        return $key;
    }
    $raw = base64_decode(trim((string)@file_get_contents(MAGESTOCK_LDAP_MASTER_KEY_FILE)), true);
    if (!is_string($raw) || strlen($raw) !== 32) throw new MageStockLdapException('Clé maître LDAP invalide.', 'master_key_invalid', 'La clé locale de chiffrement LDAP est invalide.');
    return $raw;
}

function magestockLdapEncrypt(string $plaintext): string {
    if (!function_exists('openssl_encrypt')) throw new MageStockLdapException('OpenSSL indisponible.', 'openssl_missing', 'L’extension OpenSSL est requise.');
    $iv = random_bytes(12); $tag = '';
    $ciphertext = openssl_encrypt($plaintext, 'aes-256-gcm', magestockLdapMasterKey(), OPENSSL_RAW_DATA, $iv, $tag, 'MageStock-LDAP-v1', 16);
    if (!is_string($ciphertext)) throw new MageStockLdapException('Chiffrement du secret impossible.', 'encrypt_failed', 'Impossible de chiffrer le secret LDAP.');
    return json_encode(['v'=>1,'alg'=>'AES-256-GCM','iv'=>base64_encode($iv),'tag'=>base64_encode($tag),'ct'=>base64_encode($ciphertext)], JSON_UNESCAPED_SLASHES);
}

function magestockLdapDecrypt(string $payload): string {
    $data = json_decode($payload, true);
    if (!is_array($data) || (int)($data['v'] ?? 0) !== 1) throw new MageStockLdapException('Secret chiffré invalide.', 'secret_invalid');
    $iv = base64_decode((string)($data['iv'] ?? ''), true);
    $tag = base64_decode((string)($data['tag'] ?? ''), true);
    $ct = base64_decode((string)($data['ct'] ?? ''), true);
    if (!is_string($iv)||!is_string($tag)||!is_string($ct)) throw new MageStockLdapException('Secret chiffré corrompu.', 'secret_corrupt');
    $plain = openssl_decrypt($ct, 'aes-256-gcm', magestockLdapMasterKey(), OPENSSL_RAW_DATA, $iv, $tag, 'MageStock-LDAP-v1');
    if (!is_string($plain)) throw new MageStockLdapException('Déchiffrement du secret impossible.', 'decrypt_failed');
    return $plain;
}

function magestockLdapSetBindSecret(string $secret, ?array $actor = null): void {
    if (strlen($secret) < 1 || strlen($secret) > 4096 || str_contains($secret, "\0")) throw new MageStockLdapException('Secret bind invalide.', 'invalid_secret', 'Le mot de passe du compte technique est invalide.');
    if (!magestockLdapAtomicWrite(MAGESTOCK_LDAP_BIND_SECRET_FILE, magestockLdapEncrypt($secret), 0600)) throw new MageStockLdapException('Écriture du secret bind impossible.', 'secret_write_failed');
    magestockLdapAudit('bind_secret_updated', [], $actor);
}
function magestockLdapGetBindSecret(): string {
    if (!is_file(MAGESTOCK_LDAP_BIND_SECRET_FILE)) return '';
    return magestockLdapDecrypt((string)@file_get_contents(MAGESTOCK_LDAP_BIND_SECRET_FILE));
}
function magestockLdapDeleteBindSecret(?array $actor = null): void { if (is_file(MAGESTOCK_LDAP_BIND_SECRET_FILE)) @unlink(MAGESTOCK_LDAP_BIND_SECRET_FILE); magestockLdapAudit('bind_secret_deleted', [], $actor); }
function magestockLdapBindSecretConfigured(): bool { return is_file(MAGESTOCK_LDAP_BIND_SECRET_FILE) && filesize(MAGESTOCK_LDAP_BIND_SECRET_FILE) > 20; }

function magestockLdapSetCaCertificate(string $pem, ?array $actor = null): void {
    $pem = trim($pem);
    if (!str_contains($pem, '-----BEGIN CERTIFICATE-----') || !str_contains($pem, '-----END CERTIFICATE-----') || strlen($pem) > 262144) {
        throw new MageStockLdapException('Certificat CA invalide.', 'invalid_ca', 'Le certificat d’autorité PEM est invalide.');
    }
    if (!magestockLdapAtomicWrite(MAGESTOCK_LDAP_CA_FILE, $pem . "\n", 0600)) throw new MageStockLdapException('Écriture CA impossible.', 'ca_write_failed');
    magestockLdapAudit('ca_certificate_updated', [], $actor);
}
function magestockLdapDeleteCaCertificate(?array $actor = null): void { if (is_file(MAGESTOCK_LDAP_CA_FILE)) @unlink(MAGESTOCK_LDAP_CA_FILE); magestockLdapAudit('ca_certificate_deleted', [], $actor); }
function magestockLdapCaConfigured(): bool { return is_file(MAGESTOCK_LDAP_CA_FILE) && filesize(MAGESTOCK_LDAP_CA_FILE) > 40; }

function magestockLdapPublicConfig(array $cfg): array {
    $cfg['secretConfigured'] = magestockLdapBindSecretConfigured();
    $cfg['caCertificateConfigured'] = magestockLdapCaConfigured();
    $cfg['extensionLoaded'] = extension_loaded('ldap');
    $cfg['opensslLoaded'] = extension_loaded('openssl');
    $cfg['masterKeySource'] = getenv('MAGESTOCK_SECRET_KEY') ? 'environment' : 'local_protected_file';
    $cfg['canActivate'] = $cfg['extensionLoaded'] && $cfg['opensslLoaded'] && (($cfg['allowAnonymousBind'] ?? false) || $cfg['secretConfigured']);
    unset($cfg['updatedBy']);
    return $cfg;
}

function magestockLdapAudit(string $event, array $context = [], ?array $actor = null): void {
    magestockLdapEnsureStorage();
    $safe = [];
    foreach ($context as $key=>$value) {
        if (preg_match('/pass|secret|token|credential/i', (string)$key)) continue;
        if (is_scalar($value) || $value === null) $safe[$key] = is_string($value) ? magestockLdapCut($value,300) : $value;
    }
    $entries = json_decode((string)@file_get_contents(MAGESTOCK_LDAP_AUDIT_FILE), true);
    if (!is_array($entries)) $entries = [];
    $entries[] = [
        'id'=>bin2hex(random_bytes(8)), 'date'=>date('c'), 'event'=>$event,
        'actorId'=>$actor['id'] ?? null, 'actorName'=>$actor['username'] ?? ($actor['name'] ?? ''),
        'ip'=>substr((string)($_SERVER['REMOTE_ADDR'] ?? 'cli'),0,64), 'context'=>$safe,
    ];
    if (count($entries)>250) $entries = array_slice($entries,-250);
    magestockLdapAtomicWrite(MAGESTOCK_LDAP_AUDIT_FILE, json_encode($entries, JSON_PRETTY_PRINT|JSON_UNESCAPED_UNICODE|JSON_UNESCAPED_SLASHES));
}

function magestockLdapEscape(string $value, int $flags = 0): string {
    if (function_exists('ldap_escape')) return ldap_escape($value, '', $flags ?: (defined('LDAP_ESCAPE_FILTER') ? LDAP_ESCAPE_FILTER : 1));
    return strtr($value, ['\\'=>'\\5c','*'=>'\\2a','('=>'\\28',')'=>'\\29',"\0"=>'\\00']);
}
function magestockLdapDnEscape(string $value): string {
    if (function_exists('ldap_escape') && defined('LDAP_ESCAPE_DN')) return ldap_escape($value, '', LDAP_ESCAPE_DN);
    return addcslashes($value, ',=+<>#;"\\');
}

function magestockLdapSetTlsOptions(array $cfg): void {
    if (!extension_loaded('ldap')) return;
    $require = ($cfg['verifyCertificate'] ?? true) ? (defined('LDAP_OPT_X_TLS_DEMAND') ? LDAP_OPT_X_TLS_DEMAND : 2) : (defined('LDAP_OPT_X_TLS_NEVER') ? LDAP_OPT_X_TLS_NEVER : 0);
    if (defined('LDAP_OPT_X_TLS_REQUIRE_CERT')) @ldap_set_option(null, LDAP_OPT_X_TLS_REQUIRE_CERT, $require);
    if (magestockLdapCaConfigured() && defined('LDAP_OPT_X_TLS_CACERTFILE')) @ldap_set_option(null, LDAP_OPT_X_TLS_CACERTFILE, MAGESTOCK_LDAP_CA_FILE);
    if (defined('LDAP_OPT_X_TLS_NEWCTX')) @ldap_set_option(null, LDAP_OPT_X_TLS_NEWCTX, 0);
}

function magestockLdapConnect(array $cfg, ?array &$meta = null) {
    if (!extension_loaded('ldap')) throw new MageStockLdapException('Extension PHP LDAP absente.', 'ldap_extension_missing', 'L’extension PHP LDAP n’est pas installée sur ce serveur.');
    magestockLdapSetTlsOptions($cfg);
    $errors = [];
    foreach ($cfg['servers'] as $index=>$server) {
        if (($server['host'] ?? '') === '') continue;
        $scheme = $server['encryption'] === 'ldaps' ? 'ldaps' : 'ldap';
        $uriHost = str_contains((string)$server['host'], ':') && !str_starts_with((string)$server['host'], '[') ? '[' . $server['host'] . ']' : $server['host'];
        $uri = $scheme . '://' . $uriHost . ':' . (int)$server['port'];
        $start = microtime(true);
        try {
            $conn = @ldap_connect($uri);
            if (!$conn) throw new RuntimeException('ldap_connect a échoué');
            @ldap_set_option($conn, LDAP_OPT_PROTOCOL_VERSION, 3);
            @ldap_set_option($conn, LDAP_OPT_REFERRALS, ($cfg['referrals'] ?? false) ? 1 : 0);
            if (defined('LDAP_OPT_NETWORK_TIMEOUT')) @ldap_set_option($conn, LDAP_OPT_NETWORK_TIMEOUT, (int)$server['timeout']);
            if ($server['encryption'] === 'starttls' && !@ldap_start_tls($conn)) {
                $msg = function_exists('ldap_error') ? @ldap_error($conn) : 'StartTLS refusé';
                throw new RuntimeException('StartTLS: ' . $msg);
            }
            $meta = ['serverIndex'=>$index,'host'=>$server['host'],'port'=>$server['port'],'encryption'=>$server['encryption'],'durationMs'=>(int)round((microtime(true)-$start)*1000)];
            return $conn;
        } catch (Throwable $e) {
            $errors[] = $server['host'] . ': ' . $e->getMessage();
        }
    }
    throw new MageStockLdapException('Connexion LDAP impossible: ' . implode(' | ', $errors), 'connection_failed');
}

function magestockLdapServiceBind($conn, array $cfg): void {
    if ($cfg['allowAnonymousBind'] ?? false) {
        if (!@ldap_bind($conn)) throw new MageStockLdapException('Bind LDAP anonyme refusé.', 'anonymous_bind_failed');
        return;
    }
    $secret = magestockLdapGetBindSecret();
    if ($secret === '') throw new MageStockLdapException('Secret de bind absent.', 'missing_bind_secret');
    if (!@ldap_bind($conn, (string)$cfg['bindDn'], $secret)) {
        $err = function_exists('ldap_error') ? @ldap_error($conn) : 'bind failed';
        throw new MageStockLdapException('Bind technique LDAP refusé: ' . $err, 'service_bind_failed');
    }
}

function magestockLdapBoundConnection(array $cfg, string $bindDn, string $password, bool $anonymous = false, ?array &$meta = null) {
    if (!extension_loaded('ldap')) throw new MageStockLdapException('Extension PHP LDAP absente.', 'ldap_extension_missing', 'L’extension PHP LDAP n’est pas installée sur ce serveur.');
    $errors = [];
    foreach ($cfg['servers'] as $serverIndex => $server) {
        if (($server['host'] ?? '') === '') continue;
        $single = $cfg;
        $single['servers'] = [$server, ['host'=>'','port'=>636,'encryption'=>'ldaps','timeout'=>5]];
        $conn = null; $localMeta = null;
        try {
            $conn = magestockLdapConnect($single, $localMeta);
            $bound = $anonymous ? @ldap_bind($conn) : @ldap_bind($conn, $bindDn, $password);
            if (!$bound) {
                $message = function_exists('ldap_error') ? (string)@ldap_error($conn) : 'bind failed';
                throw new RuntimeException($message);
            }
            $localMeta['serverIndex'] = $serverIndex;
            $meta = $localMeta;
            return $conn;
        } catch (MageStockLdapException $e) {
            if ($e->reasonCode === 'ldap_extension_missing' || $e->reasonCode === 'missing_bind_secret') throw $e;
            $errors[] = ($server['host'] ?? 'serveur') . ': ' . $e->getMessage();
        } catch (Throwable $e) {
            $errors[] = ($server['host'] ?? 'serveur') . ': ' . $e->getMessage();
        } finally {
            if ($conn && (!$meta || ($meta['host'] ?? '') !== ($server['host'] ?? ''))) @ldap_unbind($conn);
        }
    }
    throw new MageStockLdapException('Échec du bind sur tous les serveurs LDAP: ' . implode(' | ', $errors), 'bind_all_servers_failed');
}

function magestockLdapServiceConnection(array $cfg, ?array &$meta = null) {
    if ($cfg['allowAnonymousBind'] ?? false) return magestockLdapBoundConnection($cfg, '', '', true, $meta);
    $secret = magestockLdapGetBindSecret();
    if ($secret === '') throw new MageStockLdapException('Secret de bind absent.', 'missing_bind_secret');
    return magestockLdapBoundConnection($cfg, (string)$cfg['bindDn'], $secret, false, $meta);
}

function magestockLdapUserConnection(array $cfg, string $dn, string $password, ?array &$meta = null) {
    if ($dn === '' || $password === '') throw new MageStockLdapException('Identifiants utilisateur LDAP incomplets.', 'invalid_credentials');
    try {
        return magestockLdapBoundConnection($cfg, $dn, $password, false, $meta);
    } catch (MageStockLdapException $e) {
        throw new MageStockLdapException($e->getMessage(), 'invalid_credentials', 'Identifiants incorrects ou compte désactivé.');
    }
}

function magestockLdapEntryValue(array $entry, string $attribute, string $fallback = ''): string {
    $key = strtolower($attribute);
    if (!array_key_exists($key, $entry)) return $fallback;
    $raw = $entry[$key];
    if (is_array($raw)) $raw = $raw[0] ?? $fallback;
    if (!is_scalar($raw)) return $fallback;
    $value = (string)$raw;
    if (!preg_match('//u', $value)) return bin2hex($value);
    return $value;
}
function magestockLdapEntryValues(array $entry, string $attribute): array {
    $key = strtolower($attribute); $raw = $entry[$key] ?? [];
    if (!is_array($raw)) return $raw === '' ? [] : [(string)$raw];
    $count = isset($raw['count']) ? (int)$raw['count'] : count($raw);
    $values = [];
    for ($i=0;$i<$count;$i++) if (isset($raw[$i]) && is_scalar($raw[$i])) $values[] = (string)$raw[$i];
    return array_values(array_unique(array_filter($values, fn($v)=>$v!=='')));
}

function magestockLdapSearchUser($conn, array $cfg, string $login): array {
    $escaped = magestockLdapEscape($login, defined('LDAP_ESCAPE_FILTER') ? LDAP_ESCAPE_FILTER : 1);
    $filter = str_replace('{login}', $escaped, $cfg['userFilter']);
    $base = $cfg['userBaseDn'] !== '' ? $cfg['userBaseDn'] : $cfg['baseDn'];
    $attrs = array_values(array_unique(array_filter([
        $cfg['loginAttribute'],$cfg['stableIdAttribute'],$cfg['displayNameAttribute'],$cfg['firstNameAttribute'],$cfg['lastNameAttribute'],
        $cfg['emailAttribute'],$cfg['memberOfAttribute'],$cfg['disabledAttribute'],'dn','objectClass'
    ])));
    $search = @ldap_search($conn, $base, $filter, $attrs, 0, 2, 8);
    if (!$search) throw new MageStockLdapException('Recherche utilisateur LDAP échouée: ' . @ldap_error($conn), 'user_search_failed');
    $entries = @ldap_get_entries($conn, $search);
    $count = is_array($entries) ? (int)($entries['count'] ?? 0) : 0;
    if ($count === 0) throw new MageStockLdapException('Utilisateur LDAP introuvable.', 'user_not_found');
    if ($count > 1) throw new MageStockLdapException('Recherche LDAP non unique.', 'user_not_unique');
    $entry = $entries[0];
    $entry['dn'] = (string)($entry['dn'] ?? '');
    return $entry;
}

function magestockLdapIsDisabled(array $entry, array $cfg): bool {
    $attr = (string)($cfg['disabledAttribute'] ?? '');
    if ($attr === '') return false;
    $value = magestockLdapEntryValue($entry, $attr, '');
    if ($cfg['directoryType'] === 'active_directory' && strtolower($attr) === 'useraccountcontrol') {
        return (((int)$value) & 2) === 2;
    }
    $normalized = strtolower(trim($value));
    $attrLower = strtolower($attr);
    if ($attrLower === 'pwdaccountlockedtime') return $normalized !== '' && $normalized !== '000001010000z';
    if ($attrLower === 'nsaccountlock') return in_array($normalized, ['true','yes','1','locked'], true);
    foreach ($cfg['disabledValues'] as $disabled) if ($normalized === strtolower(trim((string)$disabled))) return true;
    return false;
}

function magestockLdapReadGroups($conn, array $entry, array $cfg, string $login): array {
    $groups = magestockLdapEntryValues($entry, $cfg['memberOfAttribute']);
    $dn = (string)($entry['dn'] ?? '');
    if ($cfg['nestedGroups'] && $cfg['directoryType'] === 'active_directory' && $dn !== '' && $cfg['groupBaseDn'] !== '') {
        $filter = '(&(objectClass=group)(member:1.2.840.113556.1.4.1941:=' . magestockLdapEscape($dn, defined('LDAP_ESCAPE_FILTER')?LDAP_ESCAPE_FILTER:1) . '))';
        $search = @ldap_search($conn, $cfg['groupBaseDn'], $filter, ['distinguishedName','cn'], 0, 500, 8);
        if ($search) {
            $entries = @ldap_get_entries($conn, $search);
            for ($i=0;$i<(int)($entries['count'] ?? 0);$i++) {
                $gdn = (string)($entries[$i]['dn'] ?? magestockLdapEntryValue($entries[$i], 'distinguishedName', ''));
                if ($gdn !== '') $groups[] = $gdn;
            }
        }
    } elseif ($cfg['groupFilter'] !== '' && $cfg['groupBaseDn'] !== '' && $dn !== '') {
        $filter = str_replace(
            ['{dn}','{login}'],
            [magestockLdapEscape($dn, defined('LDAP_ESCAPE_FILTER')?LDAP_ESCAPE_FILTER:1), magestockLdapEscape($login, defined('LDAP_ESCAPE_FILTER')?LDAP_ESCAPE_FILTER:1)],
            $cfg['groupFilter']
        );
        $search = @ldap_search($conn, $cfg['groupBaseDn'], $filter, ['cn','distinguishedName'], 0, 500, 8);
        if ($search) {
            $entries = @ldap_get_entries($conn, $search);
            for ($i=0;$i<(int)($entries['count'] ?? 0);$i++) {
                $gdn = (string)($entries[$i]['dn'] ?? magestockLdapEntryValue($entries[$i], 'distinguishedName', ''));
                if ($gdn !== '') $groups[] = $gdn;
            }
        }
    }
    return array_values(array_unique($groups));
}

function magestockLdapGroupMatches(string $rule, string $group): bool {
    $rule = magestockLdapLower(trim($rule)); $groupN = magestockLdapLower(trim($group));
    if ($rule === '' || $groupN === '') return false;
    if ($rule === $groupN) return true;
    if (preg_match('/(?:^|,)cn=([^,]+)/i', $group, $m) && $rule === magestockLdapLower(trim($m[1]))) return true;
    if (str_contains($rule, '*')) {
        $cn = isset($m[1]) ? magestockLdapLower(trim($m[1])) : '';
        if (function_exists('fnmatch')) {
            $flags = defined('FNM_CASEFOLD') ? FNM_CASEFOLD : 0;
            return fnmatch($rule, $groupN, $flags) || ($cn !== '' && fnmatch($rule, $cn, $flags));
        }
        $pattern = '/^' . str_replace('\*', '.*', preg_quote($rule, '/')) . '$/i';
        return (bool)preg_match($pattern, $groupN) || ($cn !== '' && (bool)preg_match($pattern, $cn));
    }
    return false;
}

function magestockLdapResolveAccess(array $groups, array $cfg): array {
    $matched = []; $role = $cfg['defaultRole']; $allowed = $cfg['defaultAllowedWarehouses']; $permissions = $cfg['defaultPermissions'];
    foreach ($cfg['groupMappings'] as $mapping) {
        foreach ($groups as $group) {
            if (!magestockLdapGroupMatches($mapping['group'], $group)) continue;
            $matched[] = $mapping['group'];
            if ($mapping['role'] === 'admin') $role = 'admin';
            elseif ($mapping['role'] === 'magasinier' && $role !== 'admin') $role = 'magasinier';
            $allowed = array_values(array_unique(array_merge($allowed, $mapping['allowedWarehouses'])));
            $permissions = array_merge($permissions, $mapping['permissions']);
            break;
        }
    }
    if ($cfg['denyUnmappedUsers'] && count($matched) === 0) throw new MageStockLdapException('Utilisateur sans groupe LDAP autorisé.', 'unmapped_user', 'Votre compte ne possède aucun groupe autorisé dans MageStock.');
    if ($role === 'admin') $permissions = ['*'=>true];
    return ['role'=>$role,'allowedWarehouses'=>$allowed,'permissions'=>$permissions,'matchedMappings'=>$matched];
}

function magestockLdapInspectUser(string $login, ?array $cfg = null): array {
    $cfg = $cfg ? magestockLdapNormalizeConfig($cfg, true) : magestockLdapNormalizeConfig(magestockLdapReadConfig(), true);
    $meta = null; $conn = magestockLdapServiceConnection($cfg, $meta);
    try {
        $entry = magestockLdapSearchUser($conn, $cfg, $login);
        $groups = magestockLdapReadGroups($conn, $entry, $cfg, $login);
        $access = magestockLdapResolveAccess($groups, $cfg);
        $profile = magestockLdapBuildProfile($entry, $groups, $access, $cfg, $login);
        return ['connection'=>$meta,'profile'=>$profile,'access'=>$access,'disabled'=>magestockLdapIsDisabled($entry,$cfg)];
    } finally { if (is_object($conn) || is_resource($conn)) @ldap_unbind($conn); }
}

function magestockLdapBuildProfile(array $entry, array $groups, array $access, array $cfg, string $login): array {
    $first = magestockLdapEntryValue($entry, $cfg['firstNameAttribute'], '');
    $last = magestockLdapEntryValue($entry, $cfg['lastNameAttribute'], '');
    $display = magestockLdapEntryValue($entry, $cfg['displayNameAttribute'], trim($first . ' ' . $last));
    if ($display === '') $display = $login;
    $stable = magestockLdapEntryValue($entry, $cfg['stableIdAttribute'], '');
    if ($stable === '') $stable = hash('sha256', strtolower((string)($entry['dn'] ?? $login)));
    return [
        'username'=>magestockLdapLower(magestockLdapEntryValue($entry, $cfg['loginAttribute'], $login)),
        'name'=>$display, 'firstName'=>$first, 'lastName'=>$last,
        'email'=>magestockLdapEntryValue($entry, $cfg['emailAttribute'], ''),
        'ldapId'=>$stable, 'ldapDn'=>(string)($entry['dn'] ?? ''), 'ldapGroups'=>$groups,
        'role'=>$access['role'], 'allowedWarehouses'=>$access['allowedWarehouses'], 'permissions'=>$access['permissions'],
    ];
}

function magestockLdapAuthenticate(string $login, string $password, ?array $cfg = null): array {
    if ($password === '' || strlen($password)>4096) throw new MageStockLdapException('Mot de passe LDAP vide/invalide.', 'invalid_password');
    $cfg = $cfg ? magestockLdapNormalizeConfig($cfg, true) : magestockLdapNormalizeConfig(magestockLdapReadConfig(), true);
    $meta = null; $service = magestockLdapServiceConnection($cfg, $meta);
    try {
        $entry = magestockLdapSearchUser($service, $cfg, $login);
        if (magestockLdapIsDisabled($entry, $cfg)) throw new MageStockLdapException('Compte LDAP désactivé.', 'account_disabled', 'Identifiants incorrects ou compte désactivé.');
        $groups = magestockLdapReadGroups($service, $entry, $cfg, $login);
        $access = magestockLdapResolveAccess($groups, $cfg);
        $profile = magestockLdapBuildProfile($entry, $groups, $access, $cfg, $login);
    } finally { if (is_object($service) || is_resource($service)) @ldap_unbind($service); }

    $userConn = magestockLdapUserConnection($cfg, $profile['ldapDn'], $password, $userMeta);
    try {
        // Le bind est déjà validé par magestockLdapUserConnection, avec bascule serveur secondaire.
    } finally { if (is_object($userConn) || is_resource($userConn)) @ldap_unbind($userConn); }

    $user = ($cfg['syncOnLogin'] ?? true) ? magestockLdapProvisionUser($profile, $access) : magestockLdapFindProvisionedUser($profile);
    magestockLdapAudit('login_success', ['username'=>$profile['username'],'server'=>$meta['host'] ?? '','source'=>'ldap'], $user);
    return ['user'=>$user,'source'=>'ldap','connection'=>$meta,'profile'=>$profile,'access'=>$access];
}

function magestockLdapFindProvisionedUser(array $profile): array {
    foreach (loadData('users') as $user) {
        if (($user['authSource'] ?? 'local') === 'ldap' && ((string)($user['ldapId'] ?? '') === (string)$profile['ldapId'] || magestockLdapLower((string)($user['username'] ?? '')) === magestockLdapLower((string)$profile['username']))) return $user;
    }
    throw new MageStockLdapException('Compte LDAP non provisionné.', 'not_provisioned', 'Votre compte LDAP n’est pas encore provisionné dans MageStock.');
}

function magestockLdapProvisionUser(array $profile, array $access): array {
    $lock = fopen(MAGESTOCK_LDAP_LOCK_FILE, 'c+'); if (!$lock) throw new MageStockLdapException('Verrou LDAP impossible.', 'lock_failed');
    flock($lock, LOCK_EX);
    try {
        $users = loadData('users'); $foundIndex = null;
        foreach ($users as $i=>$u) {
            $source = $u['authSource'] ?? 'local';
            if ($source === 'ldap' && ((string)($u['ldapId'] ?? '') === (string)$profile['ldapId'] || magestockLdapLower((string)($u['username'] ?? '')) === magestockLdapLower((string)$profile['username']))) { $foundIndex=$i; break; }
            if ($source !== 'ldap' && magestockLdapLower((string)($u['username'] ?? '')) === magestockLdapLower((string)$profile['username'])) {
                throw new MageStockLdapException('Conflit avec un compte local existant.', 'local_account_conflict', 'Un compte local utilise déjà cet identifiant. Un administrateur doit résoudre le conflit.');
            }
        }

        $directory = magestockLdapDirectoryAccessSnapshot($access);
        $existing = $foundIndex === null ? null : $users[$foundIndex];
        $localOverride = is_array($existing) && magestockLdapAuthorizationSource($existing) === 'local';
        $fields = [
            'username'=>$profile['username'],'name'=>$profile['name'],'firstName'=>$profile['firstName'],'lastName'=>$profile['lastName'],'email'=>$profile['email'],
            'active'=>true,'authSource'=>'ldap','authorizationSource'=>$localOverride ? 'local' : 'directory',
            'ldapId'=>$profile['ldapId'],'ldapDn'=>$profile['ldapDn'],'ldapGroups'=>$profile['ldapGroups'],'ldapStatus'=>'active','lastLdapSyncAt'=>date('c'),
            'ldapDirectoryRole'=>$directory['role'],'ldapDirectoryAllowedWarehouses'=>$directory['allowedWarehouses'],
            'ldapDirectoryPermissions'=>$directory['permissions'],'ldapDirectoryAccessUpdatedAt'=>date('c'),
        ];
        if (!$localOverride) {
            $fields['role'] = $directory['role'];
            $fields['allowedWarehouses'] = $directory['allowedWarehouses'];
        }

        if ($foundIndex === null) {
            $fields['role'] = $directory['role'];
            $fields['allowedWarehouses'] = $directory['allowedWarehouses'];
            $fields['id'] = genId(); $fields['createdAt'] = date('c'); $fields['updatedAt']=date('c');
            $users[] = $fields; $user = $fields;
        } else {
            $fields['updatedAt']=date('c');
            $users[$foundIndex] = array_merge($users[$foundIndex], $fields);
            unset($users[$foundIndex]['password']);
            $user = $users[$foundIndex];
        }
        if (!saveData('users', $users)) throw new MageStockLdapException('Écriture utilisateur LDAP impossible.', 'user_write_failed');
        $perms = loadPermissions();
        if (!$localOverride) $perms['users'][(string)$user['id']] = $directory['permissions'];
        savePermissions($perms);
        magestockLdapAudit('directory_access_synchronized', [
            'userId'=>$user['id'] ?? null,
            'username'=>$user['username'] ?? '',
            'authorizationSource'=>$localOverride ? 'local' : 'directory',
            'directoryRole'=>$directory['role'],
        ], $user);
        return $user;
    } finally { flock($lock, LOCK_UN); fclose($lock); }
}

function magestockLdapNormalizeLogin(string $value): string {
    $value=trim($value); if($value==='') return '';
    $value=magestockLdapLower($value);
    if(function_exists('iconv')) { $ascii=@iconv('UTF-8','ASCII//TRANSLIT//IGNORE',$value); if(is_string($ascii)&&$ascii!=='') $value=$ascii; }
    return preg_replace('/[^a-z0-9@._-]+/i','',$value) ?? $value;
}
function magestockLdapFindLocalUser(array $users, string $login): ?array {
    $needle=magestockLdapNormalizeLogin($login);
    foreach($users as $u){
        if(!is_array($u)||($u['active']??true)===false||($u['authSource']??'local')==='ldap') continue;
        foreach([(string)($u['username']??''),(string)($u['email']??'')] as $candidate) if($candidate!==''&&magestockLdapNormalizeLogin($candidate)===$needle) return $u;
    }
    return null;
}
function magestockLdapVerifyLocal(array $users, string $login, string $password, bool $adminOnly=false): ?array {
    $u=magestockLdapFindLocalUser($users,$login); if(!$u) return null;
    if($adminOnly&&($u['role']??'')!=='admin') return null;
    $hash=(string)($u['password']??''); return ($hash!==''&&password_verify($password,$hash))?$u:null;
}
function magestockLdapHasEmergencyLocalAdmin(): bool {
    foreach(loadData('users') as $u) if(($u['active']??true)!==false&&($u['authSource']??'local')!=='ldap'&&($u['role']??'')==='admin'&&trim((string)($u['password']??''))!=='') return true;
    return false;
}
function magestockLdapLogin(string $login,string $password,array $users): array {
    $cfg=magestockLdapReadConfig(); $mode=($cfg['enabled']??false)?($cfg['mode']??'local'):'local';
    if($mode==='local') { $u=magestockLdapVerifyLocal($users,$login,$password); if($u)return ['user'=>$u,'source'=>'local']; throw new MageStockLdapException('Identifiants locaux invalides.','invalid_credentials','Identifiants incorrects ou compte désactivé.'); }
    if($mode==='hybrid') {
        $u=magestockLdapVerifyLocal($users,$login,$password); if($u)return ['user'=>$u,'source'=>'local'];
        return magestockLdapAuthenticate($login,$password,$cfg);
    }
    try { return magestockLdapAuthenticate($login,$password,$cfg); }
    catch(MageStockLdapException $e) {
        if($cfg['localFallbackAdminEnabled']??true){ $u=magestockLdapVerifyLocal($users,$login,$password,true); if($u){magestockLdapAudit('emergency_local_login',['username'=>$u['username']??''],$u);return ['user'=>$u,'source'=>'local_emergency'];} }
        throw $e;
    }
}

function magestockLdapTestAuthentication(string $login, string $password, ?array $cfg = null): array {
    if ($password === '' || strlen($password) > 4096) throw new MageStockLdapException('Mot de passe de test vide/invalide.', 'invalid_password');
    $cfg = $cfg ? magestockLdapNormalizeConfig($cfg, true) : magestockLdapNormalizeConfig(magestockLdapReadConfig(), true);
    $meta = null;
    $service = magestockLdapServiceConnection($cfg, $meta);
    try {
        $entry = magestockLdapSearchUser($service, $cfg, $login);
        if (magestockLdapIsDisabled($entry, $cfg)) throw new MageStockLdapException('Compte LDAP désactivé.', 'account_disabled', 'Le compte de test est désactivé dans l’annuaire.');
        $groups = magestockLdapReadGroups($service, $entry, $cfg, $login);
        $access = magestockLdapResolveAccess($groups, $cfg);
        $profile = magestockLdapBuildProfile($entry, $groups, $access, $cfg, $login);
    } finally {
        if (is_object($service) || is_resource($service)) @ldap_unbind($service);
    }
    $userConn = magestockLdapUserConnection($cfg, $profile['ldapDn'], $password, $userMeta);
    try {
        // Le bind utilisateur a réussi, y compris après bascule éventuelle vers le serveur secondaire.
    } finally {
        if (is_object($userConn) || is_resource($userConn)) @ldap_unbind($userConn);
    }
    return ['connection'=>$meta,'profile'=>$profile,'access'=>$access,'disabled'=>false];
}

function magestockLdapMarkSuccessfulTest(array $testedConfig,string $username,?array $actor=null): array {
    $testedConfig['enabled'] = false;
    $testedConfig = magestockLdapNormalizeConfig($testedConfig, true);
    // Le test porte sur le brouillon reçu, mais ne remplace jamais silencieusement la configuration active.
    // Seule la preuve de test (fingerprint + horodatage) est mémorisée ; l'administrateur doit ensuite enregistrer explicitement.
    $current = magestockLdapReadConfig();
    $current['lastSuccessfulTestAt']=date('c');
    $current['lastSuccessfulTestFingerprint']=magestockLdapConfigFingerprint($testedConfig);
    $current['lastSuccessfulTestUser']=magestockLdapCut($username,120);
    $current['lastErrorAt']='';
    $current['lastErrorCode']='';
    if (!magestockLdapAtomicWrite(MAGESTOCK_LDAP_SETTINGS_FILE,json_encode([$current],JSON_PRETTY_PRINT|JSON_UNESCAPED_UNICODE|JSON_UNESCAPED_SLASHES))) {
        throw new MageStockLdapException('Impossible de mémoriser le test LDAP.', 'test_state_write_failed', 'Le test a réussi mais son état n’a pas pu être enregistré.');
    }
    magestockLdapAudit('full_test_success',['username'=>$username,'fingerprint'=>substr($current['lastSuccessfulTestFingerprint'],0,12)],$actor);
    return $current;
}
function magestockLdapMarkError(string $code,?array $actor=null): void {
    $cfg=magestockLdapReadConfig(); $cfg['lastErrorAt']=date('c'); $cfg['lastErrorCode']=$code;
    magestockLdapAtomicWrite(MAGESTOCK_LDAP_SETTINGS_FILE,json_encode([$cfg],JSON_PRETTY_PRINT|JSON_UNESCAPED_UNICODE|JSON_UNESCAPED_SLASHES));
    magestockLdapAudit('ldap_error',['code'=>$code],$actor);
}
function magestockLdapDiagnostics(): array {
    $cfg=magestockLdapReadConfig();
    return [
        'extensionLoaded'=>extension_loaded('ldap'),'opensslLoaded'=>extension_loaded('openssl'),
        'secretDirectoryExists'=>is_dir(MAGESTOCK_LDAP_SECRET_DIR),'secretDirectoryWritable'=>is_dir(MAGESTOCK_LDAP_SECRET_DIR)&&is_writable(MAGESTOCK_LDAP_SECRET_DIR),
        'secretConfigured'=>magestockLdapBindSecretConfigured(),'caCertificateConfigured'=>magestockLdapCaConfigured(),
        'masterKeySource'=>getenv('MAGESTOCK_SECRET_KEY') ? 'environment' : 'local_protected_file',
        'emergencyLocalAdminAvailable'=>magestockLdapHasEmergencyLocalAdmin(),'mode'=>$cfg['mode'],'enabled'=>$cfg['enabled'],
        'lastSuccessfulTestAt'=>$cfg['lastSuccessfulTestAt'],'lastSuccessfulTestUser'=>$cfg['lastSuccessfulTestUser'],'lastErrorAt'=>$cfg['lastErrorAt'],'lastErrorCode'=>$cfg['lastErrorCode'],
        'phpVersion'=>PHP_VERSION,
    ];
}

function magestockLdapSyncKnownUsers(?array $actor=null): array {
    $cfg=magestockLdapNormalizeConfig(magestockLdapReadConfig(),true); $updated=0;$disabled=0;$errors=[];
    foreach(loadData('users') as $user){
        if(($user['authSource']??'local')!=='ldap') continue;
        try{
            $result=magestockLdapInspectUser((string)$user['username'],$cfg);
            if($result['disabled']){
                updateItem('users',$user['id'],['active'=>false,'ldapStatus'=>'disabled','lastLdapSyncAt'=>date('c')]);$disabled++;continue;
            }
            magestockLdapProvisionUser($result['profile'],$result['access']);$updated++;
        }catch(Throwable $e){
            $code=$e instanceof MageStockLdapException?$e->reasonCode:'sync_error';
            updateItem('users',$user['id'],['ldapStatus'=>$code,'lastLdapSyncAt'=>date('c')]);
            if($cfg['disableMissingUsers']&&$code==='user_not_found') updateItem('users',$user['id'],['active'=>false]);
            $errors[]=['username'=>$user['username']??'','code'=>$code];
        }
    }
    magestockLdapAudit('sync_known_users',['updated'=>$updated,'disabled'=>$disabled,'errors'=>count($errors)],$actor);
    return compact('updated','disabled','errors');
}

magestockLdapEnsureStorage();
