<?php
/**
 * Projet                    : MageStock
 * Logiciel                  : MageStock
 * Rôle du fichier           : Bootstrap, stockage JSON, authentification, permissions et services communs
 * Version générale          : 2.6.124
 * Lot                       : MAGESTOCK UPDATE CENTER INSTALLER PROXY RESPONSIVE RELEASE FINAL1
 * Base cumulative requise   : MageStock 2.6.123
 * Date de création          : 2026-02-21
 * Date de modification      : 2026-07-30
 * Dépendances               : PHP, Sodium, OpenSSL, fichiers JSON
 * Données persistantes      : ensemble des entités JSON selon les helpers appelés
 * Permissions requises      : variables selon le point d’entrée
 * Contraintes de sécurité   : sessions, CSRF, verrouillages, écritures atomiques, secrets protégés
 * Compatibilité             : PHP 8.1 à 8.4
 * Résumé des modifications  : détection HTTPS/proxy centralisée, cookies sécurisés cohérents et socle du nouvel installateur responsive.
 *

 * Fichier  : config.php
 * Auteur   : KREMER Régis
 * Version  : 2.6.124
 * Date     : 2026-07-30
 * Description : Fichier de configuration central, data layer JSON, auth, migrations, helpers
 *
 * Changelog :
 *   v2.1.0 (2026-02-21) — Version initiale auditée et sécurisée
 *   v2.1.1 (2026-02-21) — Corrections sécurité (CSRF, MIME, session, migrateSuppliers)
 *   v2.2.0 (2026-02-21) — Permissions magasins, bons de commande, rapport d'écart,
 *                      import CSV, historique prix, bon de livraison multi-lignes
 *   v2.3.0 (2026-02-26) — Lot 1 foundations: permissions fines (permissions.json),
 *                      emplacements internes (locations), unités & conditionnements dynamiques (packagings)
 *   v2.3.25 (2026-06-09) — Migration fail-soft EAN invalides et magasin principal produit.
 *   v2.4.0 (2026-07-08) — APP_VER lue depuis le fichier VERSION (source unique de vérité).
 *   v2.6.0 (2026-07-08) — Historique produit par fichier (pv* helpers) : évite la réécriture du global.
 *   v2.6.102 (2026-07-21) — Version LDAP sécurisée et source d’authentification locale/annuaire.
 *   v2.6.6 (2026-07-08) — Nom applicatif officiel MageStock et replis sûrs sans toucher aux données client.
 *   v2.6.7 (2026-07-08) — Alignement en-tête avec recette finale.
 *   v2.6.10 (2026-07-08) — Helpers APP_BASE_PATH dynamiques, URLs app/assets/API et cookie path portable.
 *   v2.6.12 (2026-07-08) — Helpers identité utilisateur : affichage Nom Prénom, initiales et replis humains.
 *   v2.6.14 (2026-07-08) — Helpers logo entreprise : validation chemin relatif, URL publique et fallback MageStock.
 *   v2.6.18 (2026-07-08) — Logo entreprise servi via proxy image local pour remplacer le texte même si le chemin public varie.
 *   v2.6.25 (2026-07-09) — PLAN 1 : socle données/permissions plans d’emplacements, stockage JSON fail-soft.
 *   v2.6.27 (2026-07-09) — PLAN 3 : enrichissement du schéma composants avec dimensions, catégories et formes visuelles.
 *   v2.6.44 (2026-07-09) — PLANS ARCHITECTE1 : ajout des composants murs, cloisons, ouvertures, fenêtres, escalier et repères.
 *   v2.6.45 (2026-07-09) — PLANS WALLS2 : composants murs/cloisons décrits comme segments par points et ouvertures accrochables.
 *   v2.6.47 (2026-07-09) — PLANS OPENINGS4 : porte double et métadonnées d’ouvertures pour découpe visuelle des murs.
 *   v2.6.57 (2026-07-09) — PLANS WALL DRAWING1 : tracé murs/cloisons par points, cloison grille et pan clic droit.
 *   v2.6.60 (2026-07-09) — PLANS STORAGE OBJECTS REALISM1 : dimensions réalistes et rendu plan premium des objets de stockage.
 *   v2.6.61 (2026-07-09) — PLANS STORAGE OBJECTS DETAIL2 : variantes visuelles et proportions corrigées des objets de stockage.
 *   v2.6.70 (2026-07-11) — I18N CORE LOCALE CURRENCY1 : paramètres région/devise, migration fail-soft et helpers de formatage sans conversion des valeurs.
 *   v2.6.72 (2026-07-11) — Restauration du socle i18n stable après régression v2.6.71.
 *   v2.6.73 (2026-07-11) — I18N MODULES SAFE ARCHI1 : traductions PHP par clés explicites, sans balayage DOM global.
 *   v2.6.99 (2026-07-20) — Distribution vierge : assistant de premier lancement, admin choisi par l’utilisateur, fuseau horaire, verrou d’installation et chemin de sauvegarde centralisé.
 *   v2.6.100 (2026-07-20) — Release physique préparée : version de distribution alignée avec la politique Plans mobile restaurée.
 *   v2.6.104 (2026-07-21) — Moteur sécurisé de mise à jour signé, staging, sauvegarde, migrations et rollback.
 *   v2.6.105 (2026-07-21) — Détection des dépôts manuels et blocage contrôlé avant finalisation.
 *   v2.6.106 (2026-07-21) — Recette des échecs, reprise et contrôles d’intégrité du moteur de mise à jour.
 *   v2.6.108 (2026-07-21) — Correctif PHP 8.4 : réponses JSON Utilisateurs/connexion préservées et PDF produit sans avertissement de dépréciation.
 *   v2.6.109 (2026-07-22) — Les permissions individuelles LDAP prennent explicitement le dessus sur le rôle et l’accès administrateur effectif est reconnu côté serveur.
 *   v2.6.110 (2026-07-22) — Précontrôle complet des permissions et de la remplaçabilité avant toute mise à jour.
 *   v2.6.113 (2026-07-22) — Rôle Magasinier opérationnel, exclusions système immuables et permissions RBAC dédiées.
 *   v2.6.114 (2026-07-22) — Version de secours alignée après correction du routage Documentation.
 *   v2.6.115 (2026-07-22) — Version de secours alignée après couverture documentaire complète.
 *   v2.6.116 (2026-07-25) — Version de secours alignée après consolidation de la gestion des magasins.
 *   v2.6.117 (2026-07-25) — Version de secours alignée après activation des paquets UPDATE cumulatifs et ponts.
 *   v2.6.118 (2026-07-25) — Découverte distante signée MageKorp Hub et téléchargement contrôlé.
 *   v2.6.119 (2026-07-25) — Politique de mot de passe commune, permission de récupération et version de session révocable.
 *   v2.6.120 (2026-07-25) — Consolidation finale de la distribution, version et documentation publique alignées.
 *   v2.6.121 (2026-07-26) — Identité MageStock renforcée sur connexion/récupération et action mot de passe réalignée.
 *   v2.6.123 (2026-07-28) — PRODUCTS MOBILE DOCUMENT OPENING KEYBOARD FLOW FIX2 : version cumulative 2.6.123.
 *   v2.6.124 (2026-07-30) — UPDATE CENTER / INSTALLER / PROXY : détection HTTPS proxy-aware, cookies Secure cohérents et installateur guidé responsive.
 *   v2.6.122 (2026-07-28) — Actions FT/FDS mobiles et assistant Produit compact compatible clavier virtuel.
 *   v2.6.111 (2026-07-22) — Surcharge locale des autorisations pour les comptes LDAP.
 */
// ================================================================
// MAGESTOCK — config.php
// Core configuration, JSON data-layer, auth helpers
// ================================================================
define('APP_ROOT',   dirname(__FILE__));
define('DATA_DIR',   APP_ROOT.'/data');
define('UPLOADS_DIR',APP_ROOT.'/uploads');
define('BACKUPS_DIR',APP_ROOT.'/backups');
define('APP_NAME',   'MageStock');
// Version : source unique de vérité = fichier VERSION à la racine.
// Repli sur une valeur figée si le fichier est absent/illisible (jamais d'erreur).
$__ver = @file_get_contents(APP_ROOT.'/VERSION');
define('APP_VER',    ($__ver !== false && trim($__ver) !== '') ? trim($__ver) : '2.6.124');
unset($__ver);


// ================================================================
// APP_BASE_PATH — Portabilité dossier / sous-domaine
// ================================================================
function normalizeAppPath(string $path, bool $trailingSlash = false): string {
    $path = str_replace('\\', '/', trim($path));
    if ($path === '' || $path === '.' || $path === '/') {
        return $trailingSlash ? '/' : '';
    }
    $path = preg_replace('#/+#', '/', $path);
    $path = '/' . trim($path, '/');
    return $trailingSlash ? $path . '/' : $path;
}

function appBasePath(): string {
    static $base = null;
    if ($base !== null) return $base;

    $script = (string)($_SERVER['SCRIPT_NAME'] ?? $_SERVER['PHP_SELF'] ?? '');
    $script = str_replace('\\', '/', $script);
    $dir = dirname($script);
    $dir = ($dir === '.' || $dir === '/') ? '' : rtrim($dir, '/');

    // Quand le point d'entrée est dans api/, on remonte à la racine applicative.
    foreach (['/api', '/includes', '/views'] as $suffix) {
        if ($dir === $suffix) {
            $dir = '';
            break;
        }
        if (substr($dir, -strlen($suffix)) === $suffix) {
            $dir = substr($dir, 0, -strlen($suffix));
            break;
        }
    }

    // Repli REQUEST_URI si SCRIPT_NAME est absent ou atypique.
    if ($dir === '' && !empty($_SERVER['REQUEST_URI'])) {
        $uriPath = parse_url((string)$_SERVER['REQUEST_URI'], PHP_URL_PATH) ?: '';
        $uriPath = str_replace('\\', '/', $uriPath);
        foreach (['/api/', '/assets/', '/libs/', '/uploads/'] as $marker) {
            $pos = strpos($uriPath, $marker);
            if ($pos !== false) {
                $dir = substr($uriPath, 0, $pos);
                break;
            }
        }
    }

    $base = normalizeAppPath($dir, false);
    return $base;
}

function appCookiePath(): string {
    $base = appBasePath();
    return $base === '' ? '/' : $base;
}

/**
 * Détecte le protocole réellement vu par le navigateur, y compris derrière un reverse proxy.
 * Les URL internes restent relatives ; cette fonction sert surtout aux cookies Secure et aux diagnostics.
 */
function appRequestScheme(): string {
    $https = strtolower(trim((string)($_SERVER['HTTPS'] ?? '')));
    if ($https !== '' && $https !== 'off' && $https !== '0') return 'https';

    $xfp = trim((string)($_SERVER['HTTP_X_FORWARDED_PROTO'] ?? ''));
    if ($xfp !== '') {
        $first = strtolower(trim(explode(',', $xfp, 2)[0]));
        if (in_array($first, ['http','https'], true)) return $first;
    }

    $xfssl = strtolower(trim((string)($_SERVER['HTTP_X_FORWARDED_SSL'] ?? '')));
    if (in_array($xfssl, ['on','1','true'], true)) return 'https';

    $forwarded = (string)($_SERVER['HTTP_FORWARDED'] ?? '');
    if ($forwarded !== '' && preg_match('/(?:^|[;,]\s*)proto=(?:"?)(https?)(?:"?)(?:[;,]|$)/i', $forwarded, $m)) {
        return strtolower($m[1]);
    }

    $forwardedPort = trim((string)($_SERVER['HTTP_X_FORWARDED_PORT'] ?? ''));
    if ($forwardedPort === '443') return 'https';
    if ($forwardedPort === '80') return 'http';

    $scheme = strtolower(trim((string)($_SERVER['REQUEST_SCHEME'] ?? '')));
    if (in_array($scheme, ['http','https'], true)) return $scheme;
    return ((int)($_SERVER['SERVER_PORT'] ?? 0) === 443) ? 'https' : 'http';
}

function appRequestIsSecure(): bool {
    return appRequestScheme() === 'https';
}

/** Informations non sensibles utilisées par l'installateur et les diagnostics administrateur. */
function appProxyContext(): array {
    $forwardedProto = trim((string)($_SERVER['HTTP_X_FORWARDED_PROTO'] ?? ''));
    $forwardedHost = trim((string)($_SERVER['HTTP_X_FORWARDED_HOST'] ?? ''));
    $forwarded = trim((string)($_SERVER['HTTP_FORWARDED'] ?? ''));
    $proxyDetected = $forwardedProto !== '' || $forwardedHost !== '' || $forwarded !== ''
        || trim((string)($_SERVER['HTTP_X_FORWARDED_SSL'] ?? '')) !== ''
        || trim((string)($_SERVER['HTTP_X_FORWARDED_PORT'] ?? '')) !== '';

    return [
        'scheme' => appRequestScheme(),
        'secure' => appRequestIsSecure(),
        'basePath' => appBasePath(),
        'proxyDetected' => $proxyDetected,
        'forwardedProto' => $forwardedProto !== '' ? explode(',', $forwardedProto, 2)[0] : '',
    ];
}

function appUrl(string $path = ''): string {
    $path = trim($path);
    if ($path !== '' && preg_match('#^(?:https?:)?//#i', $path)) return $path;
    if ($path !== '' && preg_match('#^(?:data|blob|mailto|tel):#i', $path)) return $path;

    $base = appBasePath();
    if ($path === '') return $base === '' ? '/' : $base . '/';

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


// ================================================================
// INTERNATIONALISATION — langue, locale, pays et devise
// ================================================================
function appSupportedLanguages(): array {
    return ['fr','en','de','es','it','nl','pt','pl','ru','tr','ar','ja','ko','zh','hi','id','uk'];
}

/**
 * Résout la langue active sans analyser le DOM : cookie, préférence utilisateur, français.
 */
function appResolveLanguage(?array $user = null): string {
    $supported = appSupportedLanguages();
    $cookie = strtolower(trim((string)($_COOKIE['sm_lang'] ?? '')));
    if (in_array($cookie, $supported, true)) return $cookie;

    $preferred = strtolower(trim((string)($user['lang'] ?? '')));
    if (in_array($preferred, $supported, true)) return $preferred;

    return 'fr';
}

/**
 * Charge un catalogue JSON de manière sûre et le garde en mémoire pour la requête.
 */
function appTranslationCatalog(string $language): array {
    static $catalogs = [];
    $language = normalizeAppLanguage($language);
    if (array_key_exists($language, $catalogs)) return $catalogs[$language];

    $path = APP_ROOT . '/lang/' . $language . '.json';
    $decoded = [];
    if (is_file($path) && is_readable($path)) {
        $raw = @file_get_contents($path);
        if (is_string($raw) && $raw !== '') {
            $candidate = json_decode($raw, true);
            if (is_array($candidate)) $decoded = $candidate;
        }
    }
    $catalogs[$language] = $decoded;
    return $decoded;
}

/**
 * Traduction serveur par clé sémantique explicite avec interpolation {variable}.
 */
function appTranslate(string $key, ?string $language = null, array $params = [], ?string $fallback = null): string {
    $language = normalizeAppLanguage((string)($language ?: 'fr'));
    $catalog = appTranslationCatalog($language);
    $value = $catalog[$key] ?? null;

    $usable = is_string($value) && trim($value) !== '' && !preg_match('/^__TODO__(?:__)?$/i', trim($value));
    if (!$usable && $language !== 'fr') {
        $fr = appTranslationCatalog('fr');
        $candidate = $fr[$key] ?? null;
        if (is_string($candidate) && trim($candidate) !== '' && !preg_match('/^__TODO__(?:__)?$/i', trim($candidate))) {
            $value = $candidate;
            $usable = true;
        }
    }
    if (!$usable) $value = $fallback ?? $key;

    if ($params) {
        $replace = [];
        foreach ($params as $name => $paramValue) {
            $replace['{' . $name . '}'] = (string)$paramValue;
        }
        $value = strtr((string)$value, $replace);
    }
    return (string)$value;
}

/**
 * Date longue localisée pour les vues PHP, avec ordre défini dans le catalogue.
 */
function appFormatLongDate(DateTimeInterface $date, string $language): string {
    $language = normalizeAppLanguage($language);
    $weekday = appTranslate('date.weekday.' . $date->format('w'), $language, [], $date->format('l'));
    $month = appTranslate('date.month.' . $date->format('n'), $language, [], $date->format('F'));
    return appTranslate('date.long', $language, [
        'weekday' => $weekday,
        'day' => $date->format('j'),
        'month' => $month,
        'year' => $date->format('Y'),
    ], '{weekday} {day} {month} {year}');
}

function appLanguageLocaleMap(): array {
    return [
        'fr'=>'fr-FR', 'en'=>'en-GB', 'de'=>'de-DE', 'es'=>'es-ES', 'it'=>'it-IT',
        'nl'=>'nl-NL', 'pt'=>'pt-PT', 'pl'=>'pl-PL', 'ru'=>'ru-RU', 'tr'=>'tr-TR',
        'ar'=>'ar-SA', 'ja'=>'ja-JP', 'ko'=>'ko-KR', 'zh'=>'zh-CN', 'hi'=>'hi-IN',
        'id'=>'id-ID', 'uk'=>'uk-UA',
    ];
}

function appLocaleOptions(): array {
    return [
        ['code'=>'auto',  'label'=>'Automatique selon la langue'],
        ['code'=>'fr-FR', 'label'=>'Français — France'],
        ['code'=>'en-GB', 'label'=>'English — United Kingdom'],
        ['code'=>'en-US', 'label'=>'English — United States'],
        ['code'=>'de-DE', 'label'=>'Deutsch — Deutschland'],
        ['code'=>'es-ES', 'label'=>'Español — España'],
        ['code'=>'it-IT', 'label'=>'Italiano — Italia'],
        ['code'=>'nl-NL', 'label'=>'Nederlands — Nederland'],
        ['code'=>'pt-PT', 'label'=>'Português — Portugal'],
        ['code'=>'pl-PL', 'label'=>'Polski — Polska'],
        ['code'=>'ru-RU', 'label'=>'Русский — Россия'],
        ['code'=>'tr-TR', 'label'=>'Türkçe — Türkiye'],
        ['code'=>'ar-SA', 'label'=>'العربية — السعودية'],
        ['code'=>'ja-JP', 'label'=>'日本語 — 日本'],
        ['code'=>'ko-KR', 'label'=>'한국어 — 대한민국'],
        ['code'=>'zh-CN', 'label'=>'中文 — 中国'],
        ['code'=>'hi-IN', 'label'=>'हिन्दी — भारत'],
        ['code'=>'id-ID', 'label'=>'Bahasa Indonesia — Indonesia'],
        ['code'=>'uk-UA', 'label'=>'Українська — Україна'],
    ];
}

function appCountryOptions(): array {
    return [
        ['code'=>'FR','label'=>'France','defaultCurrency'=>'EUR'],
        ['code'=>'GB','label'=>'Royaume-Uni','defaultCurrency'=>'GBP'],
        ['code'=>'US','label'=>'États-Unis','defaultCurrency'=>'USD'],
        ['code'=>'DE','label'=>'Allemagne','defaultCurrency'=>'EUR'],
        ['code'=>'ES','label'=>'Espagne','defaultCurrency'=>'EUR'],
        ['code'=>'IT','label'=>'Italie','defaultCurrency'=>'EUR'],
        ['code'=>'NL','label'=>'Pays-Bas','defaultCurrency'=>'EUR'],
        ['code'=>'PT','label'=>'Portugal','defaultCurrency'=>'EUR'],
        ['code'=>'PL','label'=>'Pologne','defaultCurrency'=>'PLN'],
        ['code'=>'RU','label'=>'Russie','defaultCurrency'=>'RUB'],
        ['code'=>'TR','label'=>'Turquie','defaultCurrency'=>'TRY'],
        ['code'=>'SA','label'=>'Arabie saoudite','defaultCurrency'=>'SAR'],
        ['code'=>'JP','label'=>'Japon','defaultCurrency'=>'JPY'],
        ['code'=>'KR','label'=>'Corée du Sud','defaultCurrency'=>'KRW'],
        ['code'=>'CN','label'=>'Chine','defaultCurrency'=>'CNY'],
        ['code'=>'IN','label'=>'Inde','defaultCurrency'=>'INR'],
        ['code'=>'ID','label'=>'Indonésie','defaultCurrency'=>'IDR'],
        ['code'=>'UA','label'=>'Ukraine','defaultCurrency'=>'UAH'],
        ['code'=>'CH','label'=>'Suisse','defaultCurrency'=>'CHF'],
        ['code'=>'CA','label'=>'Canada','defaultCurrency'=>'CAD'],
        ['code'=>'BE','label'=>'Belgique','defaultCurrency'=>'EUR'],
    ];
}

function appCurrencyOptions(): array {
    return [
        ['code'=>'EUR','label'=>'Euro'],
        ['code'=>'GBP','label'=>'Livre sterling'],
        ['code'=>'USD','label'=>'Dollar américain'],
        ['code'=>'PLN','label'=>'Złoty polonais'],
        ['code'=>'RUB','label'=>'Rouble russe'],
        ['code'=>'TRY','label'=>'Livre turque'],
        ['code'=>'SAR','label'=>'Riyal saoudien'],
        ['code'=>'JPY','label'=>'Yen japonais'],
        ['code'=>'KRW','label'=>'Won sud-coréen'],
        ['code'=>'CNY','label'=>'Yuan renminbi'],
        ['code'=>'INR','label'=>'Roupie indienne'],
        ['code'=>'IDR','label'=>'Roupie indonésienne'],
        ['code'=>'UAH','label'=>'Hryvnia ukrainienne'],
        ['code'=>'CHF','label'=>'Franc suisse'],
        ['code'=>'CAD','label'=>'Dollar canadien'],
    ];
}

function normalizeAppLanguage(string $language, string $fallback = 'fr'): string {
    $language = strtolower(trim($language));
    return in_array($language, appSupportedLanguages(), true) ? $language : $fallback;
}

function normalizeAppLocale(string $locale, string $fallback = 'auto'): string {
    $locale = trim(str_replace('_', '-', $locale));
    $allowed = array_column(appLocaleOptions(), 'code');
    return in_array($locale, $allowed, true) ? $locale : $fallback;
}

function normalizeAppCountry(string $country, string $fallback = 'FR'): string {
    $country = strtoupper(trim($country));
    $allowed = array_column(appCountryOptions(), 'code');
    return in_array($country, $allowed, true) ? $country : $fallback;
}

function normalizeAppCurrency(string $currency, string $fallback = 'EUR'): string {
    $currency = strtoupper(trim($currency));
    $allowed = array_column(appCurrencyOptions(), 'code');
    return in_array($currency, $allowed, true) ? $currency : $fallback;
}

function appDefaultCurrencyForCountry(string $country): string {
    $country = normalizeAppCountry($country);
    foreach (appCountryOptions() as $option) {
        if ($option['code'] === $country) return $option['defaultCurrency'];
    }
    return 'EUR';
}

function appEffectiveLocale(?string $language = null, ?array $settings = null): string {
    $settings = $settings ?? (function_exists('getSettings') ? getSettings() : []);
    $configured = normalizeAppLocale((string)($settings['locale'] ?? 'auto'));
    if ($configured !== 'auto') return $configured;
    $language = normalizeAppLanguage((string)($language ?? 'fr'));
    return appLanguageLocaleMap()[$language] ?? 'fr-FR';
}

function appCountry(?array $settings = null): string {
    $settings = $settings ?? (function_exists('getSettings') ? getSettings() : []);
    return normalizeAppCountry((string)($settings['country'] ?? 'FR'));
}

function appCurrency(?array $settings = null): string {
    $settings = $settings ?? (function_exists('getSettings') ? getSettings() : []);
    return normalizeAppCurrency((string)($settings['currency'] ?? 'EUR'));
}

function formatMoneyValue(float|int|string|null $value, ?string $locale = null, ?string $currency = null, ?int $minimumFractionDigits = null, ?int $maximumFractionDigits = null): string {
    $amount = is_numeric($value) ? (float)$value : 0.0;
    $locale = normalizeAppLocale((string)($locale ?: 'fr-FR'), 'fr-FR');
    if ($locale === 'auto') $locale = 'fr-FR';
    $currency = normalizeAppCurrency((string)($currency ?: 'EUR'));
    $zeroDecimalCurrencies = ['JPY','KRW'];
    $defaultDigits = in_array($currency, $zeroDecimalCurrencies, true) ? 0 : 2;
    $minimumFractionDigits = $minimumFractionDigits ?? $defaultDigits;
    $maximumFractionDigits = $maximumFractionDigits ?? $defaultDigits;
    $minimumFractionDigits = max(0, $minimumFractionDigits);
    $maximumFractionDigits = max($minimumFractionDigits, $maximumFractionDigits);

    if (class_exists('NumberFormatter')) {
        $formatter = new NumberFormatter($locale, NumberFormatter::CURRENCY);
        $formatter->setAttribute(NumberFormatter::MIN_FRACTION_DIGITS, $minimumFractionDigits);
        $formatter->setAttribute(NumberFormatter::MAX_FRACTION_DIGITS, $maximumFractionDigits);
        $formatted = $formatter->formatCurrency($amount, $currency);
        if ($formatted !== false) return $formatted;
    }

    // Repli sans extension PHP intl : séparateurs et position de devise adaptés
    // aux locales prises en charge. Le code ne réalise aucune conversion monétaire.
    $language = strtolower(substr($locale, 0, 2));
    $rules = [
        'en'=>['decimal'=>'.','group'=>',','position'=>'before','space'=>'','grouping'=>'western'],
        'de'=>['decimal'=>',','group'=>'.','position'=>'after','space'=>"\u{00A0}",'grouping'=>'western'],
        'es'=>['decimal'=>',','group'=>'.','position'=>'after','space'=>"\u{00A0}",'grouping'=>'western'],
        'it'=>['decimal'=>',','group'=>'.','position'=>'after','space'=>"\u{00A0}",'grouping'=>'western'],
        'nl'=>['decimal'=>',','group'=>'.','position'=>'after','space'=>"\u{00A0}",'grouping'=>'western'],
        'pt'=>['decimal'=>',','group'=>"\u{00A0}",'position'=>'after','space'=>"\u{00A0}",'grouping'=>'western'],
        'pl'=>['decimal'=>',','group'=>"\u{00A0}",'position'=>'after','space'=>"\u{00A0}",'grouping'=>'western'],
        'ru'=>['decimal'=>',','group'=>"\u{00A0}",'position'=>'after','space'=>"\u{00A0}",'grouping'=>'western'],
        'tr'=>['decimal'=>',','group'=>'.','position'=>'after','space'=>"\u{00A0}",'grouping'=>'western'],
        'ar'=>['decimal'=>'٫','group'=>'٬','position'=>'after','space'=>"\u{00A0}",'grouping'=>'western'],
        'ja'=>['decimal'=>'.','group'=>',','position'=>'before','space'=>'','grouping'=>'western'],
        'ko'=>['decimal'=>'.','group'=>',','position'=>'before','space'=>'','grouping'=>'western'],
        'zh'=>['decimal'=>'.','group'=>',','position'=>'before','space'=>'','grouping'=>'western'],
        'hi'=>['decimal'=>'.','group'=>',','position'=>'before','space'=>'','grouping'=>'indian'],
        'id'=>['decimal'=>',','group'=>'.','position'=>'before','space'=>'','grouping'=>'western'],
        'uk'=>['decimal'=>',','group'=>"\u{00A0}",'position'=>'after','space'=>"\u{00A0}",'grouping'=>'western'],
        'fr'=>['decimal'=>',','group'=>"\u{202F}",'position'=>'after','space'=>"\u{00A0}",'grouping'=>'western'],
    ];
    $rule = $rules[$language] ?? $rules['fr'];
    $symbols = [
        'EUR'=>'€','GBP'=>'£','USD'=>'$','PLN'=>'zł','RUB'=>'₽','TRY'=>'₺','SAR'=>'ر.س',
        'JPY'=>'¥','KRW'=>'₩','CNY'=>'¥','INR'=>'₹','IDR'=>'Rp','UAH'=>'₴','CHF'=>'CHF','CAD'=>'CA$',
    ];
    $symbol = $symbols[$currency] ?? $currency;

    $negative = $amount < 0;
    $absolute = abs($amount);
    $raw = number_format($absolute, $maximumFractionDigits, '.', '');
    [$integer, $fraction] = array_pad(explode('.', $raw, 2), 2, '');
    if ($rule['grouping'] === 'indian' && strlen($integer) > 3) {
        $last = substr($integer, -3);
        $lead = substr($integer, 0, -3);
        $groups = [];
        while (strlen($lead) > 2) {
            array_unshift($groups, substr($lead, -2));
            $lead = substr($lead, 0, -2);
        }
        if ($lead !== '') array_unshift($groups, $lead);
        $integer = implode($rule['group'], $groups) . $rule['group'] . $last;
    } else {
        $integer = preg_replace('/\B(?=(\d{3})+(?!\d))/', $rule['group'], $integer) ?? $integer;
    }
    $number = ($negative ? '-' : '') . $integer;
    if ($maximumFractionDigits > 0) $number .= $rule['decimal'] . $fraction;
    if ($language === 'ar') {
        $number = strtr($number, ['0'=>'٠','1'=>'١','2'=>'٢','3'=>'٣','4'=>'٤','5'=>'٥','6'=>'٦','7'=>'٧','8'=>'٨','9'=>'٩','-'=>'−']);
    }
    return $rule['position'] === 'before'
        ? $symbol . $rule['space'] . $number
        : $number . $rule['space'] . $symbol;
}

function assetUrl(string $path = ''): string {
    $path = ltrim($path, '/');
    if ($path === '') return appUrl('assets/');
    if (str_starts_with($path, 'assets/')) return appUrl($path);
    return appUrl('assets/' . $path);
}

function apiUrl(string $path = ''): string {
    $path = ltrim($path, '/');
    if ($path === '') return appUrl('api/');
    if (str_starts_with($path, 'api/')) $path = substr($path, 4);
    return appUrl('api/' . $path);
}

function appRelativePublicUrl(string $path): string {
    $path = trim(str_replace('\\', '/', $path));
    if ($path === '') return '';
    if (preg_match('#^(?:https?:)?//#i', $path) || preg_match('#^(?:data|blob|mailto|tel):#i', $path)) {
        return '';
    }
    $path = ltrim($path, '/');
    $path = preg_replace('#/+#', '/', $path) ?: '';
    if ($path === '' || str_contains($path, '..') || str_starts_with($path, '.')) {
        return '';
    }
    return $path;
}

function appPublicFileExists(string $path): bool {
    $path = appRelativePublicUrl($path);
    if ($path === '') return false;
    return is_file(APP_ROOT . '/' . $path);
}

function appCompanyLogoUrl(?array $settings = null, bool $fallback = false): string {
    $settings = $settings ?? getSettings();
    $logo = appRelativePublicUrl((string)($settings['logoUrl'] ?? ''));
    if ($logo !== '' && appPublicFileExists($logo)) {
        return apiUrl('image.php?src=' . rawurlencode($logo));
    }
    return $fallback ? assetUrl('img/sm.png') : '';
}

function appCompanyLogoPath(?array $settings = null): string {
    $settings = $settings ?? getSettings();
    $logo = appRelativePublicUrl((string)($settings['logoUrl'] ?? ''));
    return ($logo !== '' && appPublicFileExists($logo)) ? $logo : '';
}

// ================================================================
// IDENTITÉ UTILISATEUR — affichage humain Nom Prénom
// ================================================================
function appNormalizeHumanText($value): string {
    $value = trim((string)$value);
    if ($value === '') return '';
    $value = str_replace(["\r", "\n", "\t"], ' ', $value);
    return preg_replace('/\s+/u', ' ', $value) ?: '';
}

function appHumanizeNameToken($value, bool $keepUpper = false): string {
    $value = appNormalizeHumanText($value);
    if ($value === '') return '';
    if ($keepUpper) {
        return function_exists('mb_strtoupper') ? mb_strtoupper($value, 'UTF-8') : strtoupper($value);
    }
    if (function_exists('mb_convert_case')) {
        return mb_convert_case(function_exists('mb_strtolower') ? mb_strtolower($value, 'UTF-8') : strtolower($value), MB_CASE_TITLE, 'UTF-8');
    }
    return ucwords(strtolower($value));
}

function appIsGenericUsername(string $username): bool {
    $u = function_exists('mb_strtolower') ? mb_strtolower(appNormalizeHumanText($username), 'UTF-8') : strtolower(appNormalizeHumanText($username));
    return $u === '' || in_array($u, ['admin','administrator','administrateur','user','utilisateur','test'], true) || str_contains($u, '@');
}

function appUserNameParts(?array $user): array {
    $user = $user ?: [];

    $firstKeys = ['firstName','first_name','firstname','givenName','given_name','prenom','prénom'];
    $lastKeys  = ['lastName','last_name','lastname','familyName','family_name','surname','nom'];

    $first = '';
    foreach ($firstKeys as $key) {
        if (!empty($user[$key])) { $first = appHumanizeNameToken($user[$key]); break; }
    }

    $last = '';
    foreach ($lastKeys as $key) {
        if (!empty($user[$key])) { $last = appHumanizeNameToken($user[$key], true); break; }
    }

    $name = appNormalizeHumanText($user['name'] ?? '');
    $username = appNormalizeHumanText($user['username'] ?? '');

    if ($last === '' && $name !== '') {
        // Dans les données historiques MageStock, name contient souvent le NOM seul.
        // S'il ressemble déjà à un nom complet, on le garde comme libellé complet plus bas.
        $last = (str_contains($name, ' ') && !preg_match('/^[A-ZÀ-ÖØ-Þ\-\s]+$/u', $name)) ? '' : appHumanizeNameToken($name, true);
    }

    if ($first === '' && !appIsGenericUsername($username)) {
        $first = appHumanizeNameToken($username);
    }

    return [$first, $last, $name, $username];
}

function appUserDisplayName(?array $user, string $fallback = 'Utilisateur'): string {
    $user = $user ?: [];
    $displayName = appNormalizeHumanText($user['displayName'] ?? '');
    if ($displayName !== '') return $displayName;

    [$first, $last, $name, $username] = appUserNameParts($user);

    if ($last !== '' && $first !== '') return trim($last . ' ' . $first);
    if ($name !== '') return $name;
    if ($username !== '') return appHumanizeNameToken($username);
    return $fallback;
}

function appUserInitials(?array $user, string $fallback = 'U'): string {
    [$first, $last, $name, $username] = appUserNameParts($user);
    $source = [];
    if ($last !== '') $source[] = $last;
    if ($first !== '') $source[] = $first;
    if (empty($source)) $source = preg_split('/\s+/u', appNormalizeHumanText($name ?: $username)) ?: [];

    $initials = '';
    foreach ($source as $part) {
        $part = appNormalizeHumanText($part);
        if ($part === '') continue;
        $initials .= function_exists('mb_substr') ? mb_substr($part, 0, 1, 'UTF-8') : substr($part, 0, 1);
        if ((function_exists('mb_strlen') ? mb_strlen($initials, 'UTF-8') : strlen($initials)) >= 2) break;
    }

    $initials = $initials !== '' ? $initials : $fallback;
    return function_exists('mb_strtoupper') ? mb_strtoupper($initials, 'UTF-8') : strtoupper($initials);
}

// Data files (Lot 1)
define('PERMISSIONS_FILE', DATA_DIR.'/permissions.json');
define('LOCATIONS_FILE',   DATA_DIR.'/locations.json');
define('VARIANT_FAMILIES_FILE', DATA_DIR.'/variant_families.json');
define('PLANS_FILE', DATA_DIR.'/plans.json');
define('PLAN_OBJECTS_FILE', DATA_DIR.'/plan_objects.json');

// Auto-create dirs
foreach([DATA_DIR, UPLOADS_DIR] as $d) if(!is_dir($d)) mkdir($d,0775,true);

// ── MageStock v2.3.1: familles de déclinaisons génériques ───────────────
function defaultVariantFamilies(): array {
    return [
        [
            'id' => 'clothing_size',
            'label' => 'Vêtements / EPI — tailles',
            'sourceType' => 'official',
            'description' => 'Tailles vêtements, chaussures, gants et EPI.',
            'attributes' => [
                ['key'=>'size','label'=>'Taille','unit'=>'','type'=>'select','presetValues'=>['XS','S','M','L','XL','XXL','3XL','4XL']],
                ['key'=>'sizeNorm','label'=>'Norme','unit'=>'','type'=>'select','presetValues'=>['EU','UK','US']],
                ['key'=>'color','label'=>'Couleur','unit'=>'','type'=>'text','presetValues'=>[]],
            ],
            'createdAt' => date('c'),
            'updatedAt' => date('c'),
        ],
        [
            'id' => 'signage_round',
            'label' => 'Signalisation — panneau rond',
            'sourceType' => 'official',
            'description' => 'Panneaux circulaires avec diamètre normalisé.',
            'attributes' => [
                ['key'=>'diameterMm','label'=>'Diamètre','unit'=>'mm','type'=>'number','presetValues'=>[450,650,850,1000]],
                ['key'=>'retroClass','label'=>'Classe rétro','unit'=>'','type'=>'select','presetValues'=>['Classe 1','Classe 2','Classe 3']],
                ['key'=>'material','label'=>'Matière','unit'=>'','type'=>'select','presetValues'=>['Aluminium','Acier','PVC']],
            ],
            'createdAt' => date('c'),
            'updatedAt' => date('c'),
        ],
        [
            'id' => 'signage_triangle',
            'label' => 'Signalisation — panneau triangulaire',
            'sourceType' => 'official',
            'description' => 'Panneaux triangulaires avec côté normalisé.',
            'attributes' => [
                ['key'=>'sideMm','label'=>'Côté','unit'=>'mm','type'=>'number','presetValues'=>[500,700,1000,1250]],
                ['key'=>'retroClass','label'=>'Classe rétro','unit'=>'','type'=>'select','presetValues'=>['Classe 1','Classe 2','Classe 3']],
                ['key'=>'material','label'=>'Matière','unit'=>'','type'=>'select','presetValues'=>['Aluminium','Acier','PVC']],
            ],
            'createdAt' => date('c'),
            'updatedAt' => date('c'),
        ],
        [
            'id' => 'signage_rectangle',
            'label' => 'Signalisation — panneau / panonceau rectangulaire',
            'sourceType' => 'official',
            'description' => 'Panneaux rectangulaires, panonceaux et plaques avec largeur/hauteur.',
            'attributes' => [
                ['key'=>'widthMm','label'=>'Largeur','unit'=>'mm','type'=>'number','presetValues'=>[350,500,700,900,1000]],
                ['key'=>'heightMm','label'=>'Hauteur','unit'=>'mm','type'=>'number','presetValues'=>[150,250,350,500,700]],
                ['key'=>'retroClass','label'=>'Classe rétro','unit'=>'','type'=>'select','presetValues'=>['Classe 1','Classe 2','Classe 3']],
                ['key'=>'material','label'=>'Matière','unit'=>'','type'=>'select','presetValues'=>['Aluminium','Acier','PVC']],
            ],
            'createdAt' => date('c'),
            'updatedAt' => date('c'),
        ],
        [
            'id' => 'tube_dimension',
            'label' => 'Tubes — diamètre / longueur / épaisseur',
            'sourceType' => 'official',
            'description' => 'Tubes, poteaux et profils par dimensions.',
            'attributes' => [
                ['key'=>'diameterMm','label'=>'Diamètre','unit'=>'mm','type'=>'number','presetValues'=>[20,25,32,40,50,60,76,90]],
                ['key'=>'lengthMm','label'=>'Longueur','unit'=>'mm','type'=>'number','presetValues'=>[1000,2000,3000,4000,6000]],
                ['key'=>'thicknessMm','label'=>'Épaisseur','unit'=>'mm','type'=>'number','presetValues'=>[1,1.5,2,3,4]],
                ['key'=>'material','label'=>'Matière','unit'=>'','type'=>'select','presetValues'=>['Acier galvanisé','Acier','Aluminium','PVC']],
            ],
            'createdAt' => date('c'),
            'updatedAt' => date('c'),
        ],

        [
            'id' => 'hardware_screws_bolts',
            'label' => 'Quincaillerie — vis / boulons',
            'sourceType' => 'official',
            'description' => 'Vis, boulons et fixations filetées par diamètre, longueur, tête et matière.',
            'attributes' => [
                ['key'=>'fastenerType','label'=>'Type','unit'=>'','type'=>'select','presetValues'=>['Vis','Boulon','Tirefond','Goujon','Tige filetée']],
                ['key'=>'threadDiameter','label'=>'Diamètre / filetage','unit'=>'','type'=>'select','presetValues'=>['M4','M5','M6','M8','M10','M12','M14','M16','M20']],
                ['key'=>'lengthMm','label'=>'Longueur','unit'=>'mm','type'=>'number','presetValues'=>[10,16,20,25,30,40,50,60,80,100,120,150,200]],
                ['key'=>'headType','label'=>'Tête / empreinte','unit'=>'','type'=>'select','presetValues'=>['Tête hexagonale','Tête fraisée','Tête cylindrique','Torx','Pozidriv','Allen']],
                ['key'=>'material','label'=>'Matière / finition','unit'=>'','type'=>'select','presetValues'=>['Acier zingué','Acier galvanisé','Inox A2','Inox A4','Laiton']],
            ],
            'createdAt' => date('c'),
            'updatedAt' => date('c'),
        ],
        [
            'id' => 'hardware_nuts_washers',
            'label' => 'Quincaillerie — écrous / rondelles',
            'sourceType' => 'official',
            'description' => 'Écrous, rondelles et accessoires de serrage par diamètre et matière.',
            'attributes' => [
                ['key'=>'hardwareType','label'=>'Type','unit'=>'','type'=>'select','presetValues'=>['Écrou','Écrou frein','Écrou papillon','Rondelle plate','Rondelle grower','Rondelle large']],
                ['key'=>'threadDiameter','label'=>'Diamètre / filetage','unit'=>'','type'=>'select','presetValues'=>['M4','M5','M6','M8','M10','M12','M14','M16','M20']],
                ['key'=>'standard','label'=>'Standard','unit'=>'','type'=>'select','presetValues'=>['Standard','Large','Frein','Grower','Nylstop']],
                ['key'=>'material','label'=>'Matière / finition','unit'=>'','type'=>'select','presetValues'=>['Acier zingué','Acier galvanisé','Inox A2','Inox A4','Laiton']],
            ],
            'createdAt' => date('c'),
            'updatedAt' => date('c'),
        ],
        [
            'id' => 'hardware_anchors_plugs',
            'label' => 'Quincaillerie — chevilles / ancrages',
            'sourceType' => 'official',
            'description' => 'Chevilles, goujons d’ancrage et fixations selon diamètre, longueur et support.',
            'attributes' => [
                ['key'=>'anchorType','label'=>'Type','unit'=>'','type'=>'select','presetValues'=>['Cheville nylon','Cheville métallique','Goujon d’ancrage','Scellement chimique','Cheville à frapper']],
                ['key'=>'diameterMm','label'=>'Diamètre','unit'=>'mm','type'=>'number','presetValues'=>[5,6,8,10,12,14,16,20]],
                ['key'=>'lengthMm','label'=>'Longueur','unit'=>'mm','type'=>'number','presetValues'=>[25,30,40,50,60,80,100,120,140,160]],
                ['key'=>'support','label'=>'Support','unit'=>'','type'=>'select','presetValues'=>['Béton','Brique','Parpaing','Pierre','Plaque de plâtre','Multi-matériaux']],
                ['key'=>'material','label'=>'Matière','unit'=>'','type'=>'select','presetValues'=>['Nylon','Acier zingué','Acier galvanisé','Inox']],
            ],
            'createdAt' => date('c'),
            'updatedAt' => date('c'),
        ],
        [
            'id' => 'generic_dimension',
            'label' => 'Générique — dimensions libres',
            'sourceType' => 'official',
            'description' => 'Déclinaison polyvalente largeur / hauteur / profondeur / diamètre.',
            'attributes' => [
                ['key'=>'widthMm','label'=>'Largeur','unit'=>'mm','type'=>'number','presetValues'=>[]],
                ['key'=>'heightMm','label'=>'Hauteur','unit'=>'mm','type'=>'number','presetValues'=>[]],
                ['key'=>'depthMm','label'=>'Profondeur','unit'=>'mm','type'=>'number','presetValues'=>[]],
                ['key'=>'diameterMm','label'=>'Diamètre','unit'=>'mm','type'=>'number','presetValues'=>[]],
            ],
            'createdAt' => date('c'),
            'updatedAt' => date('c'),
        ],
    ];
}

function mergeOfficialVariantFamilies(array $existing): array {
    $defaults = defaultVariantFamilies();
    $byId = [];
    foreach($existing as $family){
        if(is_array($family) && isset($family['id'])) $byId[(string)$family['id']] = $family;
    }
    foreach($defaults as $official){
        $id = (string)($official['id'] ?? '');
        if($id === '') continue;
        if(!isset($byId[$id])){
            $byId[$id] = $official;
            continue;
        }
        // Les modèles officiels peuvent recevoir de nouveaux attributs/presets lors des MAJ.
        // Les modèles personnalisés de l'utilisateur ne sont jamais écrasés.
        if(($byId[$id]['sourceType'] ?? 'official') === 'official'){
            $createdAt = $byId[$id]['createdAt'] ?? ($official['createdAt'] ?? date('c'));
            $byId[$id] = array_merge($official, ['createdAt'=>$createdAt, 'updatedAt'=>date('c')]);
        }
    }
    return array_values($byId);
}

function seedVariantFamilies(): void {
    if(!file_exists(VARIANT_FAMILIES_FILE)){
        file_put_contents(VARIANT_FAMILIES_FILE, json_encode(defaultVariantFamilies(), JSON_PRETTY_PRINT|JSON_UNESCAPED_UNICODE));
        return;
    }
    $raw = @file_get_contents(VARIANT_FAMILIES_FILE);
    $data = json_decode($raw ?: '', true);
    if(!is_array($data)) $data = [];
    $merged = mergeOfficialVariantFamilies($data);
    if(json_encode($merged, JSON_UNESCAPED_UNICODE) !== json_encode($data, JSON_UNESCAPED_UNICODE)){
        file_put_contents(VARIANT_FAMILIES_FILE, json_encode($merged, JSON_PRETTY_PRINT|JSON_UNESCAPED_UNICODE));
    }
}
seedVariantFamilies();

function loadVariantFamilies(): array {
    seedVariantFamilies();
    $raw = @file_get_contents(VARIANT_FAMILIES_FILE);
    $data = json_decode($raw ?: '', true);
    return is_array($data) ? mergeOfficialVariantFamilies($data) : defaultVariantFamilies();
}

function saveVariantFamilies(array $families): void {
    file_put_contents(VARIANT_FAMILIES_FILE, json_encode(array_values($families), JSON_PRETTY_PRINT|JSON_UNESCAPED_UNICODE));
}


// ── Rôles MageStock ─────────────────────────────────────────────
function magestockKnownRoles(): array {
    return ['admin','magasinier','user'];
}

function magestockNormalizeRole($role): string {
    $role = strtolower(trim((string)$role));
    return in_array($role, magestockKnownRoles(), true) ? $role : 'user';
}

function magestockRoleLabel($role): string {
    return match (magestockNormalizeRole($role)) {
        'admin' => 'Administrateur',
        'magasinier' => 'Magasinier',
        default => 'Utilisateur',
    };
}

function magestockMagasinierDeniedPermissions(): array {
    return ['users.manage','auth.recovery.manage','settings.write','ldap.manage','backup.manage','archive.manage','update.manage'];
}

// ── Installation state ──────────────────────────────────────────
// Une distribution vierge ne crée jamais de mot de passe temporaire.
// Le premier administrateur est créé par install.php après validation explicite.
function appHasAnyUsers(): bool {
    return count(loadData('users')) > 0;
}

function appHasActiveAdministrator(): bool {
    foreach(loadData('users') as $user){
        if(!is_array($user)) continue;
        if(($user['active'] ?? true) === false) continue;
        if(($user['role'] ?? '') === 'admin') return true;
    }
    return false;
}

function appInstallationRequired(): bool {
    // Le premier lancement ne s'ouvre que sur une installation réellement vierge.
    // Une instance contenant déjà des utilisateurs ne doit jamais être réinitialisée
    // automatiquement, même si aucun administrateur actif n'est détecté.
    return !appHasAnyUsers();
}

// ── Lot 1: Permissions fines (permissions.json) ────────────────────
function seedPermissions(): void {
    if(!file_exists(PERMISSIONS_FILE)){
        $default = [
            'roles' => [
                'admin' => [
                    '*' => true
                ],
                'magasinier' => [
                    // Accès métier complet. Les zones système restent interdites.
                    'products.read' => true,
                    'products.manage' => true,
                    'stock.read' => true,
                    'stock.manage' => true,
                    'movements.read' => true,
                    'movements.create' => true,
                    'movements.delete' => true,
                    'inventory.read' => true,
                    'inventory.write' => true,
                    'orders.read' => true,
                    'orders.manage' => true,
                    'kits.read' => true,
                    'kits.manage' => true,
                    'lots.read' => true,
                    'lots.manage' => true,
                    'reservations.read' => true,
                    'reservations.manage' => true,
                    'analytics.read' => true,
                    'reports.read' => true,
                    'alerts.read' => true,
                    'alerts.manage' => true,
                    'barcodes.read' => true,
                    'barcodes.manage' => true,
                    'suppliers.read' => true,
                    'suppliers.manage' => true,
                    'brands.read' => true,
                    'brands.manage' => true,
                    'warehouses.read' => true,
                    'warehouses.manage' => true,
                    'locations.read' => true,
                    'locations.manage' => true,
                    'plans.read' => true,
                    'plans.manage' => true,
                    'product.versions.read' => true,
                    'price.read' => true,
                    'valuation.read' => true,
                    'documentation.read' => true,
                    'admin.access' => true,
                    // Exclusions système obligatoires.
                    'users.manage' => false,
                    'auth.recovery.manage' => false,
                    'settings.write' => false,
                    'ldap.manage' => false,
                    'backup.manage' => false,
                    'archive.manage' => false,
                    'update.manage' => false,
                ],
                'user' => [
                    // Default "user" role can consult stock/products and do add/remove
                    'products.read' => true,
                    'stock.read' => true,
                    'movements.read' => true,
                    'movements.create' => true,
                    'movements.delete' => false,
                    'inventory.read' => true,
                    'inventory.write' => false,
                    // Analytics, kits, lots, reservations, valuation, versions — read by default
                    'analytics.read' => true,
                    'kits.read' => true,
                    'lots.read' => true,
                    'reservations.read' => true,
                    'valuation.read' => false,
                    'product.versions.read' => true,
                    // Prices hidden by default
                    'price.read' => false,
                    // Admin areas
                    'admin.access' => false,
                    'settings.write' => false,
                    'users.manage' => false,
                    'auth.recovery.manage' => false,
                    'warehouses.manage' => false,
                    'locations.manage' => false,
                    'plans.read' => true,
                    'plans.manage' => false,
                ]
            ],
            'users' => [
                // per-user overrides by userId (string) => perms map
            ]
        ];
        file_put_contents(PERMISSIONS_FILE, json_encode($default, JSON_PRETTY_PRINT|JSON_UNESCAPED_UNICODE));
    }
}
seedPermissions();

function loadPermissions(): array {
    seedPermissions();
    $raw = @file_get_contents(PERMISSIONS_FILE);
    $data = json_decode($raw ?: '', true);
    if(!is_array($data)) $data = ['roles'=>[],'users'=>[]];
    if(!isset($data['roles']) || !is_array($data['roles'])) $data['roles']=[];
    if(!isset($data['users']) || !is_array($data['users'])) $data['users']=[];
    return $data;
}

function savePermissions(array $data): void {
    file_put_contents(PERMISSIONS_FILE, json_encode($data, JSON_PRETTY_PRINT|JSON_UNESCAPED_UNICODE));
}

function magestockMagasinierRoleDefaults(): array {
    return [
        'products.read'=>true,'products.manage'=>true,'stock.read'=>true,'stock.manage'=>true,
        'movements.read'=>true,'movements.create'=>true,'movements.delete'=>true,
        'inventory.read'=>true,'inventory.write'=>true,'orders.read'=>true,'orders.manage'=>true,
        'kits.read'=>true,'kits.manage'=>true,'lots.read'=>true,'lots.manage'=>true,
        'reservations.read'=>true,'reservations.manage'=>true,'analytics.read'=>true,'reports.read'=>true,
        'alerts.read'=>true,'alerts.manage'=>true,'barcodes.read'=>true,'barcodes.manage'=>true,
        'suppliers.read'=>true,'suppliers.manage'=>true,'brands.read'=>true,'brands.manage'=>true,
        'warehouses.read'=>true,'warehouses.manage'=>true,'locations.read'=>true,'locations.manage'=>true,
        'plans.read'=>true,'plans.manage'=>true,'product.versions.read'=>true,
        'price.read'=>true,'valuation.read'=>true,'documentation.read'=>true,'admin.access'=>true,
        'users.manage'=>false,'auth.recovery.manage'=>false,'settings.write'=>false,'ldap.manage'=>false,
        'backup.manage'=>false,'archive.manage'=>false,'update.manage'=>false,
    ];
}

function migratePermissionsDefaults(): void {
    $data = loadPermissions();
    $changed = false;

    if(!isset($data['roles']['user']) || !is_array($data['roles']['user'])) {
        $data['roles']['user'] = [];
        $changed = true;
    }
    foreach([
        'plans.read'=>true,'plans.manage'=>false,'documentation.read'=>false,
        'auth.recovery.manage'=>false,'ldap.manage'=>false,'backup.manage'=>false,'archive.manage'=>false,'update.manage'=>false,
    ] as $key=>$value){
        if(!array_key_exists($key,$data['roles']['user'])){ $data['roles']['user'][$key]=$value; $changed=true; }
    }

    $magasinier = magestockMagasinierRoleDefaults();
    if(!isset($data['roles']['magasinier']) || !is_array($data['roles']['magasinier'])){
        $data['roles']['magasinier']=$magasinier;
        $changed=true;
    } else {
        foreach($magasinier as $key=>$value){
            if(!array_key_exists($key,$data['roles']['magasinier']) || in_array($key,magestockMagasinierDeniedPermissions(),true)){
                if(($data['roles']['magasinier'][$key] ?? null)!==$value){ $data['roles']['magasinier'][$key]=$value; $changed=true; }
            }
        }
    }

    if($changed) savePermissions($data);
}
migratePermissionsDefaults();

function getEffectivePermissions(?array $user): array {
    if(!$user) return [];
    $permFile = loadPermissions();
    $role = magestockNormalizeRole($user['role'] ?? 'user');
    $rolePerms = $permFile['roles'][$role] ?? [];
    $userId = (string)($user['id'] ?? '');
    $userPerms = ($userId && isset($permFile['users'][$userId]) && is_array($permFile['users'][$userId])) ? $permFile['users'][$userId] : [];
    // Les droits individuels restent prioritaires sur les fonctions métier.
    $effective = array_replace($rolePerms, $userPerms);
    // Le rôle Magasinier ne peut jamais recevoir les privilèges système, même par override LDAP/local.
    if($role === 'magasinier'){
        foreach(magestockMagasinierDeniedPermissions() as $denied) $effective[$denied] = false;
        $effective['admin.access'] = true;
        $effective['documentation.read'] = true;
    }
    return $effective;
}

function hasPerm(string $key): bool {
    $u = currentUser();
    if(!$u) return false;
    $perms = getEffectivePermissions($u);
    if(isset($perms['*']) && $perms['*'] === true) return true;
    return (bool)($perms[$key] ?? false);
}

function requirePerm(string $key): void {
    if(hasPerm($key)) return;
    jsonError("Permission insuffisante ($key).", 403);
}


// ── Product migration helpers (v2.3.25) ───────────────────────────────
function normalizeProductEansForMigration($raw): array {
    if(is_string($raw)){
        $trimmed = trim($raw);
        if($trimmed === '' || in_array(strtolower($trimmed), ['[]', '{}', 'null', 'undefined'], true)) return [];
        $decoded = json_decode($trimmed, true);
        if(json_last_error() === JSON_ERROR_NONE) $raw = is_array($decoded) ? $decoded : [];
        else $raw = array_filter(array_map('trim', preg_split('/[,;\s]+/', $trimmed)));
    }
    if(!is_array($raw)) return [];
    $out = [];
    foreach($raw as $ean){
        if(is_array($ean) || is_object($ean) || $ean === null) continue;
        $ean = trim((string)$ean);
        if($ean === '' || in_array(strtolower($ean), ['[]', '{}', 'null', 'undefined'], true)) continue;
        if(!in_array($ean, $out, true)) $out[] = $ean;
    }
    return $out;
}

function normalizeWarehouseIdsForMigration($raw): array {
    if(is_string($raw)){
        $decoded = json_decode($raw, true);
        if(json_last_error() === JSON_ERROR_NONE) $raw = $decoded;
        else $raw = preg_split('/[,;\s]+/', trim($raw));
    }
    if(!is_array($raw)) return [];
    $out = [];
    foreach($raw as $id){
        $id = (int)$id;
        if($id > 0 && !in_array($id, $out, true)) $out[] = $id;
    }
    return $out;
}

// ── Auto-migrate products: add all missing fields ──
function migrateProducts(){
    $products = loadData('products');
    $migrated = false;
    foreach($products as &$p){
        if(!isset($p['brand']))        { $p['brand']        = ''; $migrated=true; }
        if(!isset($p['cost']))         { $p['cost']         = 0;  $migrated=true; }
        if(!isset($p['supplier']))     { $p['supplier']     = ''; $migrated=true; }
        if(!isset($p['datasheetUrl'])) { $p['datasheetUrl'] = ''; $migrated=true; }
        if(!isset($p['msdsUrl']))      { $p['msdsUrl']      = ''; $migrated=true; }
        // ── Legacy packaging fields (kept for backward compatibility) ──
        if(!isset($p['unitType']))      { $p['unitType']      = 'piece'; $migrated=true; } // piece, carton, palette, fut, bidon, litre, kg
        if(!isset($p['unitsPerBox']))   { $p['unitsPerBox']   = 0;  $migrated=true; }     // legacy "carton"
        if(!isset($p['boxesPerPallet'])){ $p['boxesPerPallet']= 0;  $migrated=true; }     // legacy "palette"
        if(!isset($p['volume']))        { $p['volume']        = 0;  $migrated=true; }     // legacy litres (bidon)

        // ── New essential fields (fail-soft defaults; no regression) ──
        if(!isset($p['packBaseUnit']))  { $p['packBaseUnit']  = in_array($p['unitType'],['litre','kg']) ? $p['unitType'] : 'piece'; $migrated=true; }
        if(!isset($p['packBaseLabelSingular']) || $p['packBaseLabelSingular']==='') { $p['packBaseLabelSingular'] = unitLabel($p['packBaseUnit'] ?? 'piece'); $migrated=true; }
        if(!isset($p['packBaseLabelPlural']) || $p['packBaseLabelPlural']==='') {
            $sing = unitLabel($p['packBaseUnit'] ?? 'piece');
            $mapPlural = ['pièce'=>'pièces','mètre linéaire (ml)'=>'mètres linéaires','litre'=>'litres','kg'=>'kg','m'=>'m','m²'=>'m²','m³'=>'m³'];
            $p['packBaseLabelPlural'] = $mapPlural[$sing] ?? ($sing . 's');
            $migrated=true;
        }
        if(!isset($p['packCartonQty'])) { $p['packCartonQty'] = (int)($p['unitsPerBox'] ?? 0); $migrated=true; }
        if(!isset($p['packPaletteQty'])){
            $u = (int)($p['unitsPerBox'] ?? 0);
            $b = (int)($p['boxesPerPallet'] ?? 0);
            $p['packPaletteQty'] = ($u>0 && $b>0) ? ($u*$b) : 0;
            $migrated=true;
        }
        if(!isset($p['packBidonQty']))  { $p['packBidonQty']  = (float)($p['volume'] ?? 0); $migrated=true; }
        if(!isset($p['packFutQty']))    { $p['packFutQty']    = 0; $migrated=true; }

        if(!isset($p['costByUnit']))    { $p['costByUnit']    = []; $migrated=true; } // map: unit => cost
        if(!isset($p['trackLot']))      { $p['trackLot']      = false; $migrated=true; }
        if(!isset($p['trackExpiry']))   { $p['trackExpiry']   = false; $migrated=true; }
        if(!isset($p['shelfLifeDays'])) { $p['shelfLifeDays'] = null;  $migrated=true; }
        if(!isset($p['safetyStock']))   { $p['safetyStock']   = null;  $migrated=true; }
        if(!isset($p['cump']))          { $p['cump']          = (float)($p['cost'] ?? 0); $migrated=true; }
        // ── Lot 4: EAN multiples + versioning (fail-soft) ──
        if(!isset($p['eans']) || !is_array($p['eans'])) { $p['eans'] = []; $migrated=true; }
        $cleanEans = normalizeProductEansForMigration($p['eans'] ?? []);
        if(($p['eans'] ?? []) !== $cleanEans) { $p['eans'] = $cleanEans; $migrated=true; }

        $cleanWarehouses = normalizeWarehouseIdsForMigration($p['allowedWarehouses'] ?? []);
        if(!array_key_exists('allowedWarehouses', $p) || ($p['allowedWarehouses'] ?? []) !== $cleanWarehouses) { $p['allowedWarehouses'] = $cleanWarehouses; $migrated=true; }
        $defaultWarehouse = (int)($p['defaultWarehouse'] ?? 0);
        if($defaultWarehouse <= 0 || (!empty($cleanWarehouses) && !in_array($defaultWarehouse, $cleanWarehouses, true))) {
            $p['defaultWarehouse'] = !empty($cleanWarehouses) ? (int)$cleanWarehouses[0] : null;
            $migrated=true;
        }

        if(!isset($p['version'])) { $p['version'] = 1; $migrated=true; }
        if(!isset($p['decompSub'])) { $p['decompSub'] = false; $migrated=true; }
        // ── EPI / Tailles (déclinaisons) ──
        if(!isset($p['sizeType'])) { $p['sizeType'] = 'none'; $migrated=true; }
        if(!isset($p['sizeNorm'])) { $p['sizeNorm'] = 'eu'; $migrated=true; }
        if(!isset($p['size']))     { $p['size'] = null; $migrated=true; }
        if(!isset($p['parentId'])) { $p['parentId'] = null; $migrated=true; }
        // ── MageStock v2.3.1: déclinaisons génériques ──
        if(!isset($p['isVariantParent'])) { $p['isVariantParent'] = false; $migrated=true; }
        if(!isset($p['variantFamily'])) { $p['variantFamily'] = ''; $migrated=true; }
        if(!isset($p['variantAttributes']) || !is_array($p['variantAttributes'])) { $p['variantAttributes'] = []; $migrated=true; }
        if(!isset($p['variantLabel'])) { $p['variantLabel'] = ''; $migrated=true; }
        if(!isset($p['variantSort'])) { $p['variantSort'] = 0; $migrated=true; }

        // ── Lot 1: Dynamic packagings (kept alongside legacy fields; fail-soft) ──
        if(!isset($p['packagings']) || !is_array($p['packagings'])){
            $baseUnit = normalizeUnit($p['packBaseUnit'] ?? ($p['unitType'] ?? 'piece'));
            $packs = [];
            // Base unit always exists
            $packs[] = ['type'=>'unite','qty'=>1,'unit'=>$baseUnit];
            $map = [
                'carton'  => (float)($p['packCartonQty'] ?? 0),
                'palette' => (float)($p['packPaletteQty'] ?? 0),
                'bidon'   => (float)($p['packBidonQty'] ?? 0),
                'fut'     => (float)($p['packFutQty'] ?? 0),
                'aerosol' => (float)($p['packAerosolQty'] ?? 0),
                'sac'     => (float)($p['packSacQty'] ?? 0),
                'seau'    => (float)($p['packSeauQty'] ?? 0),
                'rouleau' => (float)($p['packRouleauQty'] ?? 0),
            ];
            foreach($map as $t=>$q){
                if($q>0) $packs[] = ['type'=>$t,'qty'=>$q,'unit'=>$baseUnit,'price'=>(float)($p['costByUnit'][$t] ?? 0)];
            }
            $p['packagings'] = $packs;
            $migrated=true;
        }

    }
    unset($p);
    if($migrated) saveData('products', $products);
}

// ── Auto-migrate stock: safetyStock + lot-level traceability + maxAuthorized ──
function migrateStock(){
    $stock = loadData('stock');
    $migrated = false;
    foreach($stock as &$s){
        if(!array_key_exists('safetyStock',$s)) { $s['safetyStock']=null; $migrated=true; }
        if(!isset($s['lots']) || !is_array($s['lots'])) { $s['lots']=[]; $migrated=true; }

        if(!isset($s['locations']) || !is_array($s['locations'])) {
            // Lot 1: internal locations (fail-soft). Default bucket keeps backward compatibility.
            $s['locations'] = [
                'default' => [
                    'quantity' => (int)($s['quantity'] ?? 0),
                    'lots' => $s['lots'] ?? []
                ]
            ];
            $migrated=true;
        } else {
            if(!isset($s['locations']['default'])) {
                $s['locations']['default'] = ['quantity'=>(int)($s['quantity'] ?? 0), 'lots'=>$s['lots'] ?? []];
                $migrated=true;
            }
            // Ensure each location has proper shape
            foreach($s['locations'] as $lk=>&$lv){
                if(!is_array($lv)) { $lv = ['quantity'=>0,'lots'=>[]]; $migrated=true; }
                if(!isset($lv['lots']) || !is_array($lv['lots'])) { $lv['lots']=[]; $migrated=true; }
                if(!isset($lv['quantity'])) { $lv['quantity']=0; $migrated=true; }
            }
            unset($lv);
        }

        if(!isset($s['lastMovementAt'])) { $s['lastMovementAt']=null; $migrated=true; }
        // NEW V2.1: maxAuthorized for entry control
        if(!isset($s['maxAuthorized'])) { 
            // Initialize with current quantity, but at least 1 to avoid blocking all adds
            // If quantity is 0 (new/empty stock), use a sensible default so non-admin can still add
            $initQty = (int)($s['quantity'] ?? 0);
            $s['maxAuthorized'] = $initQty > 0 ? $initQty : 9999; 
            $migrated=true; 
        }
        // Keep backward compatibility: top-level lots/quantity reflect the aggregated view
        $locLots = [];
        $total = 0;
        if(isset($s['locations']) && is_array($s['locations'])){
            foreach($s['locations'] as $loc){
                $total += (int)($loc['quantity'] ?? 0);
                $locLots[] = $loc['lots'] ?? [];
            }
            $merged = mergeLots($locLots);
            $s['lots'] = $merged;
            if((int)($s['quantity'] ?? 0) !== $total){ $s['quantity']=$total; $migrated=true; }
        } else {
            if(isset($s['lots']) && is_array($s['lots']) && count($s['lots'])>0){
                $sum = 0;
                foreach($s['lots'] as $lot){ $sum += (int)($lot['quantity'] ?? 0); }
                if((int)($s['quantity'] ?? 0) !== $sum){ $s['quantity']=$sum; $migrated=true; }
            }
        }
    }
    unset($s);
    if($migrated) saveData('stock',$stock);
}

// ── Auto-migrate suppliers: add brands field ──
function migrateSuppliers(){
    $suppliers = loadData('suppliers');
    $migrated = false;
    foreach($suppliers as &$sup){
        if(!isset($sup['brands'])) {
            $sup['brands'] = [];
            $migrated = true;
        } elseif(is_string($sup['brands'])) {
            // Legacy: brands was stored as JSON-encoded string — convert to native array
            $decoded = json_decode($sup['brands'], true);
            $sup['brands'] = is_array($decoded) ? $decoded : [];
            $migrated = true;
        }
        // If already a native array, nothing to do
    }
    unset($sup);
    if($migrated) saveData('suppliers', $suppliers);
}

// ── Paramètres région/devise : migration idempotente sans conversion financière ──
function migrateLocaleCurrencySettings(): void {
    $settings = loadData('settings');
    if (empty($settings)) {
        $settings = [['id'=>1,'companyName'=>'MageStock','skuPrefix'=>'PRD-','logoUrl'=>'']];
    }
    $current = $settings[0];
    $changed = false;
    $defaults = ['locale'=>'auto','country'=>'FR','currency'=>'EUR','localeCurrencyVersion'=>'1.0'];
    foreach ($defaults as $key => $value) {
        if (!array_key_exists($key, $current) || trim((string)$current[$key]) === '') {
            $current[$key] = $value;
            $changed = true;
        }
    }
    $normalizedLocale = normalizeAppLocale((string)$current['locale']);
    $normalizedCountry = normalizeAppCountry((string)$current['country']);
    $normalizedCurrency = normalizeAppCurrency((string)$current['currency']);
    foreach (['locale'=>$normalizedLocale,'country'=>$normalizedCountry,'currency'=>$normalizedCurrency] as $key=>$value) {
        if (($current[$key] ?? null) !== $value) {
            $current[$key] = $value;
            $changed = true;
        }
    }
    if (($current['localeCurrencyVersion'] ?? '') !== '1.0') {
        $current['localeCurrencyVersion'] = '1.0';
        $changed = true;
    }
    if ($changed) {
        $settings[0] = $current;
        saveData('settings', $settings);
    }
}

// ── Run migrations only when schema version changes ──
(function(){
    global $argv; // allow CLI usage
    $schemaVersion = '2.1.2';
    $s = loadData('settings');
    $cur = ($s[0] ?? [])['schemaVersion'] ?? '';
    if($cur !== $schemaVersion){
        migrateProducts();
        migrateStock();
        migrateSuppliers();
        $settings = loadData('settings');
        if(empty($settings)){
            $settings = [['id'=>1,'companyName'=>'MageStock','skuPrefix'=>'PRD-','logoUrl'=>'']];
        }
        $settings[0]['schemaVersion'] = $schemaVersion;
        saveData('settings', $settings);
    }
})();
migrateLocaleCurrencySettings();

// ── Auto-create purchase_prices.json if missing ──
if(!file_exists(DATA_DIR.'/purchase_prices.json')) file_put_contents(DATA_DIR.'/purchase_prices.json', '[]');
if(!file_exists(DATA_DIR.'/suppliers.json')) file_put_contents(DATA_DIR.'/suppliers.json', '[]');
if(!file_exists(DATA_DIR.'/orders.json')) file_put_contents(DATA_DIR.'/orders.json', '[]');
if(!file_exists(DATA_DIR.'/rate_limits.json')) file_put_contents(DATA_DIR.'/rate_limits.json', '{}');


// ================================================================
// PLANS D’EMPLACEMENTS — stockage JSON fail-soft et bibliothèque composants
// ================================================================
function planObjectComponentTypes(): array {
    return [
        'mur' => ['label'=>'Mur', 'category'=>'construire', 'color'=>'#334155', 'icon'=>'wall', 'shape'=>'wallSegment', 'defaultWidth'=>520, 'defaultHeight'=>22, 'description'=>'Mur porteur ou limite épaisse du local, à dessiner par deux points.'],
        'cloison' => ['label'=>'Cloison', 'category'=>'construire', 'color'=>'#7b8798', 'icon'=>'partition', 'shape'=>'partitionSegment', 'defaultWidth'=>420, 'defaultHeight'=>10, 'description'=>'Cloison pleine intérieure, plus légère qu’un mur porteur, tracée point par point.'],
        'cloison_grille' => ['label'=>'Cloison grille', 'category'=>'construire', 'color'=>'#8a96a8', 'icon'=>'partition-grid', 'shape'=>'gridPartitionSegment', 'defaultWidth'=>420, 'defaultHeight'=>8, 'description'=>'Cloison ajourée ou grillagée, tracée par points avec barreaux visibles et rendu non massif.'],
        'ouverture' => ['label'=>'Ouverture', 'category'=>'ouvertures', 'color'=>'#e5e7eb', 'icon'=>'opening', 'shape'=>'opening', 'defaultWidth'=>150, 'defaultHeight'=>34, 'description'=>'Passage libre sans battant, représenté par une vraie rupture de mur.'],
        'fenetre' => ['label'=>'Fenêtre', 'category'=>'ouvertures', 'color'=>'#dbeafe', 'icon'=>'window', 'shape'=>'window', 'defaultWidth'=>160, 'defaultHeight'=>28, 'description'=>'Fenêtre ou baie avec châssis en lignes techniques, à accrocher au mur.'],
        'porte' => ['label'=>'Porte simple', 'category'=>'ouvertures', 'color'=>'#e5e7eb', 'icon'=>'door', 'shape'=>'door', 'defaultWidth'=>130, 'defaultHeight'=>100, 'description'=>'Porte simple avec battant et arc de rotation lisibles, à accrocher au mur.'],
        'porte_double' => ['label'=>'Porte double', 'category'=>'ouvertures', 'color'=>'#e5e7eb', 'icon'=>'door-double', 'shape'=>'doorDouble', 'defaultWidth'=>170, 'defaultHeight'=>108, 'description'=>'Double porte avec deux battants et arcs de rotation, à poser sur un mur.'],
        'depot' => ['label'=>'Dépôt', 'category'=>'structure', 'color'=>'#e0f2fe', 'icon'=>'warehouse', 'shape'=>'space', 'defaultWidth'=>520, 'defaultHeight'=>320, 'description'=>'Volume général du dépôt ou de l’entrepôt.'],
        'local' => ['label'=>'Local', 'category'=>'structure', 'color'=>'#dbeafe', 'icon'=>'local', 'shape'=>'room', 'defaultWidth'=>420, 'defaultHeight'=>260, 'description'=>'Local technique, magasin ou pièce fermée.'],
        'piece' => ['label'=>'Pièce', 'category'=>'structure', 'color'=>'#ede9fe', 'icon'=>'room', 'shape'=>'room', 'defaultWidth'=>380, 'defaultHeight'=>220, 'description'=>'Pièce indépendante à l’intérieur du dépôt.'],
        'atelier' => ['label'=>'Atelier', 'category'=>'structure', 'color'=>'#fef3c7', 'icon'=>'tools', 'shape'=>'workshop', 'defaultWidth'=>440, 'defaultHeight'=>260, 'description'=>'Zone de travail ou d’entretien.'],
        'zone' => ['label'=>'Zone', 'category'=>'organisation', 'color'=>'#dcfce7', 'icon'=>'zone', 'shape'=>'zone', 'defaultWidth'=>300, 'defaultHeight'=>160, 'description'=>'Zone de rangement ou secteur fonctionnel.'],
        'allee' => ['label'=>'Allée', 'category'=>'circulation', 'color'=>'#f8fafc', 'icon'=>'path', 'shape'=>'path', 'defaultWidth'=>420, 'defaultHeight'=>80, 'description'=>'Allée entre racks ou armoires.'],
        'circulation' => ['label'=>'Circulation', 'category'=>'circulation', 'color'=>'#f1f5f9', 'icon'=>'walk', 'shape'=>'path', 'defaultWidth'=>420, 'defaultHeight'=>120, 'description'=>'Zone de passage, accès ou cheminement.'],
        'escalier' => ['label'=>'Escalier', 'category'=>'circulation', 'color'=>'#e2e8f0', 'icon'=>'stairs', 'shape'=>'stairs', 'defaultWidth'=>180, 'defaultHeight'=>120, 'description'=>'Escalier ou changement de niveau schématique.'],
        'armoire' => ['label'=>'Armoire simple', 'category'=>'stockage', 'color'=>'#e2e8f0', 'icon'=>'cabinet', 'shape'=>'cabinet', 'defaultWidth'=>80, 'defaultHeight'=>55, 'description'=>'Armoire compacte vue de dessus, empreinte 80 × 55 cm.'],
        'armoire_double' => ['label'=>'Armoire double', 'category'=>'stockage', 'color'=>'#d8dee9', 'icon'=>'cabinet-double', 'shape'=>'cabinet_double', 'defaultWidth'=>160, 'defaultHeight'=>55, 'description'=>'Armoire double à deux vantaux, empreinte 160 × 55 cm.'],
        'rack' => ['label'=>'Rack', 'category'=>'stockage', 'color'=>'#dbe7f3', 'icon'=>'rack', 'shape'=>'rack', 'defaultWidth'=>300, 'defaultHeight'=>120, 'description'=>'Rack / palettier lourd avec travées, empreinte 300 × 120 cm.'],
        'rayonnage' => ['label'=>'Rayonnage', 'category'=>'stockage', 'color'=>'#e0f2fe', 'icon'=>'shelves', 'shape'=>'shelves', 'defaultWidth'=>220, 'defaultHeight'=>65, 'description'=>'Rayonnage linéaire à tablettes, empreinte 220 × 65 cm.'],
        'etagere' => ['label'=>'Étagère', 'category'=>'stockage', 'color'=>'#eef6fb', 'icon'=>'shelf', 'shape'=>'shelves', 'defaultWidth'=>140, 'defaultHeight'=>45, 'description'=>'Étagère basse ou meuble léger, empreinte 140 × 45 cm.'],
        'palette' => ['label'=>'Zone palette', 'category'=>'stockage', 'color'=>'#f3d7ad', 'icon'=>'pallet', 'shape'=>'pallet', 'defaultWidth'=>120, 'defaultHeight'=>80, 'description'=>'Empreinte palette Europe EPAL 120 × 80 cm.'],
        'emplacement' => ['label'=>'Emplacement', 'category'=>'stockage', 'color'=>'#dcfce7', 'icon'=>'pin', 'shape'=>'location', 'defaultWidth'=>120, 'defaultHeight'=>70, 'description'=>'Emplacement précis à rattacher à MageStock, empreinte 120 × 70 cm.'],
        'repere' => ['label'=>'Repère', 'category'=>'repere', 'color'=>'#fde68a', 'icon'=>'marker', 'shape'=>'marker', 'defaultWidth'=>120, 'defaultHeight'=>50, 'description'=>'Note ou repère métier visible sur le plan.'],
    ];
}
function ensurePlansStorage(): void {
    if(!file_exists(PLANS_FILE)) file_put_contents(PLANS_FILE, '[]');
    if(!file_exists(PLAN_OBJECTS_FILE)) file_put_contents(PLAN_OBJECTS_FILE, '[]');
}

function migratePlansData(): void {
    ensurePlansStorage();
    $now = date('c');
    $changedPlans = false;
    $plans = loadData('plans');
    foreach($plans as &$p){
        if(!is_array($p)) { $p = []; $changedPlans = true; }
        if(empty($p['id'])) { $p['id'] = genId(); $changedPlans = true; }
        if(!isset($p['name'])) { $p['name'] = 'Plan sans nom'; $changedPlans = true; }
        if(!isset($p['warehouseId'])) { $p['warehouseId'] = 0; $changedPlans = true; }
        if(!isset($p['type'])) { $p['type'] = 'depot'; $changedPlans = true; }
        if(!isset($p['width'])) { $p['width'] = 1600; $changedPlans = true; }
        if(!isset($p['height'])) { $p['height'] = 1000; $changedPlans = true; }
        if(!isset($p['scale'])) { $p['scale'] = 1; $changedPlans = true; }
        if(!array_key_exists('active', $p)) { $p['active'] = true; $changedPlans = true; }
        if(!isset($p['createdAt'])) { $p['createdAt'] = $now; $changedPlans = true; }
        if(!isset($p['updatedAt'])) { $p['updatedAt'] = $p['createdAt']; $changedPlans = true; }
        if(!isset($p['createdBy'])) { $p['createdBy'] = null; $changedPlans = true; }
        if(!isset($p['updatedBy'])) { $p['updatedBy'] = null; $changedPlans = true; }
    }
    unset($p);
    if($changedPlans) saveData('plans', array_values($plans));

    $types = planObjectComponentTypes();
    $changedObjects = false;
    $objects = loadData('plan_objects');
    foreach($objects as &$o){
        if(!is_array($o)) { $o = []; $changedObjects = true; }
        if(empty($o['id'])) { $o['id'] = genId(); $changedObjects = true; }
        if(!isset($o['planId'])) { $o['planId'] = 0; $changedObjects = true; }
        if(!isset($o['type']) || !isset($types[(string)$o['type']])) { $o['type'] = 'emplacement'; $changedObjects = true; }
        if(!isset($o['label'])) { $o['label'] = ''; $changedObjects = true; }
        foreach(['x'=>0,'y'=>0,'rotation'=>0] as $k=>$v){ if(!isset($o[$k])) { $o[$k] = $v; $changedObjects = true; } }
        foreach(['width'=>160,'height'=>80] as $k=>$v){ if(!isset($o[$k])) { $o[$k] = $v; $changedObjects = true; } }
        if(!isset($o['color'])) { $o['color'] = $types[$o['type']]['color'] ?? '#dbeafe'; $changedObjects = true; }
        if(!isset($o['icon'])) { $o['icon'] = $types[$o['type']]['icon'] ?? ''; $changedObjects = true; }
        foreach(['linkedWarehouseId','linkedLocationId','linkedZone','linkedProductId'] as $k){ if(!array_key_exists($k, $o)) { $o[$k] = null; $changedObjects = true; } }
        if(!isset($o['metadata']) || !is_array($o['metadata'])) { $o['metadata'] = []; $changedObjects = true; }
        if(!isset($o['createdAt'])) { $o['createdAt'] = $now; $changedObjects = true; }
        if(!isset($o['updatedAt'])) { $o['updatedAt'] = $o['createdAt']; $changedObjects = true; }
    }
    unset($o);
    if($changedObjects) saveData('plan_objects', array_values($objects));
}
migratePlansData();

// ================================================================
// STOCK LIMIT CONTROL (NEW V2.1)
// ================================================================

/**
 * Check if adding quantity would exceed authorized maximum
 * @return array ['ok' => bool, 'current' => int, 'max' => int, 'message' => string]
 */
function checkStockLimit(int $productId, int $warehouseId, float $qtyToAdd): array {
    $stock = loadData('stock');
    $entry = null;
    foreach($stock as $s) {
        if((string)($s['productId']??'')===(string)$productId 
           && (string)($s['warehouseId']??'')===(string)$warehouseId) {
            $entry = $s;
            break;
        }
    }
    
    if(!$entry) {
        return ['ok' => true, 'current' => 0, 'max' => 0, 'message' => ''];
    }
    
    $current = (int)($entry['quantity'] ?? 0);
    $maxAuthRaw = $entry['maxAuthorized'] ?? null;
    // maxAuthorized=0 ou null = pas de limite définie (valeur par défaut à la création)
    // Seule une valeur > 0 explicitement définie est une vraie limite
    $maxAuth = ($maxAuthRaw !== null && (int)$maxAuthRaw > 0) ? (int)$maxAuthRaw : PHP_INT_MAX;
    $newTotal = $current + (int)round($qtyToAdd);
    
    if($newTotal > $maxAuth && $maxAuth < PHP_INT_MAX) {
        return [
            'ok' => false,
            'current' => $current,
            'max' => $maxAuth,
            'message' => "Stock maximum autorisé dépassé ({$maxAuth}). Actuel: {$current}, Ajout demandé: ".(int)round($qtyToAdd).". Contactez un administrateur pour une entrée de stock."
        ];
    }
    
    return ['ok' => true, 'current' => $current, 'max' => $maxAuth, 'message' => ''];
}

/**
 * Increase authorized maximum stock (admin entry)
 */
function increaseMaxAuthorized(int $productId, int $warehouseId, float $qtyReceived): void {
    $stock = loadData('stock');
    $found = false;
    
    foreach($stock as &$s) {
        if((string)($s['productId']??'')===(string)$productId 
           && (string)($s['warehouseId']??'')===(string)$warehouseId) {
            $s['maxAuthorized'] = ((int)($s['maxAuthorized'] ?? 0)) + (int)round($qtyReceived);
            $found = true;
            break;
        }
    }
    unset($s);
    
    if(!$found) {
        $stock[] = [
            'id' => genId(),
            'productId' => $productId,
            'warehouseId' => $warehouseId,
            'quantity' => 0,
            'maxAuthorized' => (int)round($qtyReceived),
            'minStock' => null,
            'maxStock' => null,
            'safetyStock' => null,
            'lots' => [],
            'lastMovementAt' => null,
            'createdAt' => date('c')
        ];
    }
    
    saveData('stock', $stock);
}

// ================================================================
// LOT / LOCATION HELPERS (Lot 1)
// ================================================================

function mergeLots(array $locationsLots): array {
    $out = [];
    foreach($locationsLots as $lots){
        if(!is_array($lots)) continue;
        foreach($lots as $l){
            $lot = (string)($l['lotNumber'] ?? '');
            $exp = (string)($l['expiryDate'] ?? '');
            $key = $lot.'|'.$exp;
            $qty = (int)($l['quantity'] ?? 0);
            if($qty<=0) continue;

            $blocked = (bool)($l['blocked'] ?? false);
            $reason  = (string)($l['blockReason'] ?? '');
            $blockedAt = (string)($l['blockedAt'] ?? '');
            $blockedBy = (string)($l['blockedBy'] ?? '');

            if(!isset($out[$key])){
                $out[$key] = $l;
                // normalize block fields
                $out[$key]['blocked'] = $blocked;
                if($blocked){
                    $out[$key]['blockReason'] = $reason;
                    $out[$key]['blockedAt'] = $blockedAt;
                    $out[$key]['blockedBy'] = $blockedBy;
                }
            } else {
                $out[$key]['quantity'] = (int)($out[$key]['quantity'] ?? 0) + $qty;

                // keep latest movement date
                $out[$key]['lastMovementAt'] = max((string)($out[$key]['lastMovementAt'] ?? ''),(string)($l['lastMovementAt'] ?? ''));

                // keep last known unitCost if any
                if((float)($l['unitCost'] ?? 0) > 0) $out[$key]['unitCost'] = (float)$l['unitCost'];

                // block propagation: if any location lot is blocked, the merged lot is blocked
                if($blocked){
                    $out[$key]['blocked'] = true;
                    if($reason) $out[$key]['blockReason'] = $reason;
                    if($blockedAt) $out[$key]['blockedAt'] = $blockedAt;
                    if($blockedBy) $out[$key]['blockedBy'] = $blockedBy;
                }
            }
        }
    }
    return array_values($out);
}

function getLocationKey($locationId): string {
    $k = trim((string)$locationId);
    return ($k === '' || $k === '0') ? 'default' : $k;
}

/**
 * Ensure and return a reference to a location bucket inside a stock entry.
 * Each bucket stores: quantity (int) and lots (array).
 */
function &getStockLocationBucket(array &$entry, string $locKey): array {
    if(!isset($entry['locations']) || !is_array($entry['locations'])) $entry['locations'] = [];
    if(!isset($entry['locations'][$locKey]) || !is_array($entry['locations'][$locKey])) {
        $entry['locations'][$locKey] = ['quantity'=>0,'lots'=>[]];
    }
    if(!isset($entry['locations'][$locKey]['lots']) || !is_array($entry['locations'][$locKey]['lots'])) $entry['locations'][$locKey]['lots'] = [];
    if(!isset($entry['locations'][$locKey]['quantity'])) $entry['locations'][$locKey]['quantity'] = 0;
    return $entry['locations'][$locKey];
}

// ================================================================
// LOT TRACEABILITY HELPERS (Lot 2)
// ================================================================
function lotKey(string $lotNumber, string $expiryDate=''): string {
    return trim($lotNumber).'|'.trim($expiryDate);
}

function isLotBlocked(array $lot): bool {
    return (bool)($lot['blocked'] ?? false);
}

/**
 * Allocate lots from a stock entry + location bucket according to strategy.
 * Strategy:
 *  - FEFO: earliest expiry first (non-empty expiry first), then receivedAt.
 *  - FIFO: earliest receivedAt first (fallback lastMovementAt).
 * Returns: array of lines [lotNumber, expiryDate, quantity, unitCost, locationKey]
 */
function allocateLots(array $stockEntry, string $locKey, int $qtyNeeded, string $strategy='FEFO'): array {
    $qtyNeeded = (int)$qtyNeeded;
    if($qtyNeeded <= 0) return [];

    $locKey = getLocationKey($locKey);
    $loc = $stockEntry['locations'][$locKey] ?? null;
    $lots = is_array($loc) ? ($loc['lots'] ?? []) : [];
    if(!is_array($lots) || empty($lots)) return [];

    // filter available lots (skip blocked or zero qty)
    $candidates = array_values(array_filter($lots, function($l){
        if(!is_array($l)) return false;
        if((int)($l['quantity'] ?? 0) <= 0) return false;
        if((bool)($l['blocked'] ?? false)) return false;
        return true;
    }));

    usort($candidates, function($a,$b) use($strategy){
        $ra = (string)($a['receivedAt'] ?? '');
        $rb = (string)($b['receivedAt'] ?? '');
        $ma = (string)($a['lastMovementAt'] ?? '');
        $mb = (string)($b['lastMovementAt'] ?? '');
        $ea = (string)($a['expiryDate'] ?? '');
        $eb = (string)($b['expiryDate'] ?? '');
        if($strategy === 'FIFO'){
            $da = $ra ?: $ma;
            $db = $rb ?: $mb;
            return strcmp($da, $db);
        }
        // FEFO: empty expiry goes last
        if($ea === '' && $eb !== '') return 1;
        if($ea !== '' && $eb === '') return -1;
        if($ea !== $eb) return strcmp($ea, $eb);
        $da = $ra ?: $ma;
        $db = $rb ?: $mb;
        return strcmp($da, $db);
    });

    $out = [];
    $remain = $qtyNeeded;
    foreach($candidates as $l){
        if($remain <= 0) break;
        $have = (int)($l['quantity'] ?? 0);
        $take = min($have, $remain);
        $out[] = [
            'lotNumber' => (string)($l['lotNumber'] ?? ''),
            'expiryDate' => (string)($l['expiryDate'] ?? ''),
            'quantity' => $take,
            'unitCost' => (float)($l['unitCost'] ?? 0),
            'locationKey' => $locKey,
        ];
        $remain -= $take;
    }
    return $out;
}

/**
 * Update product CUMP (weighted average cost) after an inbound movement.
 * qtyAdded: base units
 * costAddedTotal: base cost total
 * currentQtyBefore: base units currently in stock (all warehouses)
 */
function updateProductCUMP(int $productId, float $qtyAdded, float $costAddedTotal, float $currentQtyBefore): void {
    if($qtyAdded <= 0 || $costAddedTotal <= 0) return;
    $products = loadData('products');
    foreach($products as &$p){
        if((int)($p['id'] ?? 0) === (int)$productId){
            $oldCump = (float)($p['cump'] ?? ($p['cost'] ?? 0));
            $oldQty  = max(0.0, (float)$currentQtyBefore);
            $newQty  = $oldQty + (float)$qtyAdded;
            if($newQty <= 0) break;
            $newCump = (($oldCump * $oldQty) + (float)$costAddedTotal) / $newQty;
            $p['cump'] = round($newCump, 6);
            $p['updatedAt'] = date('c');
            break;
        }
    }
    unset($p);
    saveData('products', $products);
}

function getTotalStockQty(int $productId): float {
    $stock = loadData('stock');
    $sum = 0.0;
    foreach($stock as $s){
        if((int)($s['productId'] ?? 0) === (int)$productId){
            $sum += (float)($s['quantity'] ?? 0);
        }
    }
    return $sum;
}

// ================================================================
// RESERVATION-AWARE STOCK HELPERS
// ================================================================

/**
 * Sum of active reservations (base qty) for a product/warehouse.
 * If $warehouseId is null, sums across all warehouses.
 */
function getReservedQty(int $productId, ?int $warehouseId = null, ?string $locationId = null): float {
    $all = loadData('reservations');
    $sum = 0.0;
    foreach($all as $r){
        if(($r['status'] ?? 'active') !== 'active') continue;
        if((int)($r['productId'] ?? 0) !== $productId) continue;
        if($warehouseId !== null && (int)($r['warehouseId'] ?? 0) !== $warehouseId) continue;
        if($locationId !== null && (string)($r['locationId'] ?? 'default') !== (string)$locationId) continue;
        $sum += (float)($r['baseQty'] ?? 0);
    }
    return $sum;
}

/**
 * Available stock = physical stock - active reservations.
 * Returns ['available' => float, 'physical' => float, 'reserved' => float]
 */
function getAvailableStock(int $productId, int $warehouseId): array {
    $stock = loadData('stock');
    $physical = 0.0;
    foreach($stock as $s){
        if((int)($s['productId'] ?? 0) === $productId
           && (int)($s['warehouseId'] ?? 0) === $warehouseId){
            $physical = (float)($s['quantity'] ?? 0);
            break;
        }
    }
    $reserved = getReservedQty($productId, $warehouseId);
    return [
        'physical'  => $physical,
        'reserved'  => $reserved,
        'available' => max(0.0, $physical - $reserved),
    ];
}

/**
 * Compute stock valuation:
 * - use lot unitCost if set (>0), else product cump/cost.
 * Returns totals + breakdowns.
 */
function computeStockValuation(?int $warehouseId = null): array {
    $products = loadData('products');
    $productsById = [];
    foreach($products as $p) $productsById[(string)($p['id'] ?? '')] = $p;

    $warehouses = loadData('warehouses');
    $whById = [];
    foreach($warehouses as $w) $whById[(string)($w['id'] ?? '')] = $w;

    $stock = loadData('stock');
    $total = 0.0;
    $byWarehouse = [];
    $byCategory = [];
    $blockedValue = 0.0;
    $excluded = [
        'rows' => 0,
        'reason_unit_mismatch' => 0,
    ];

    foreach($stock as $s){
        $pid = (int)($s['productId'] ?? 0);
        $wid = (int)($s['warehouseId'] ?? 0);
        if($warehouseId !== null && $wid !== (int)$warehouseId) continue;
        $p = $productsById[(string)$pid] ?? null;
        if(!$p) continue;

        // Cost reliability: if base unit differs from unitType and no explicit costByUnit,
        // we cannot assume that "cost" is expressed per base unit.
        $baseUnit = normalizeUnit((string)($p['packBaseUnit'] ?? ($p['unitType'] ?? 'piece')));
        $unitType = normalizeUnit((string)($p['unitType'] ?? $baseUnit));

        $cump = (float)($p['cump'] ?? ($p['cost'] ?? 0));
        $cat = (string)($p['category'] ?? '—');

        $costByUnit = $p['costByUnit'] ?? [];
        $explicitBaseCost = null;
        if(is_array($costByUnit)){
            foreach($costByUnit as $cu){
                if(!is_array($cu)) continue;
                $u = normalizeUnit((string)($cu['unit'] ?? ''));
                $v = (float)($cu['cost'] ?? 0);
                if($u !== '' && $u === $baseUnit && $v > 0){
                    $explicitBaseCost = $v;
                    break;
                }
            }
        }

        $fallbackUnitCost = null;
        if($explicitBaseCost !== null){
            $fallbackUnitCost = $explicitBaseCost;
        } elseif($unitType === $baseUnit) {
            $fallbackUnitCost = $cump > 0 ? $cump : null;
        } else {
            // unit mismatch and no explicit base cost => valuation excluded (conservative)
            $fallbackUnitCost = null;
        }

        // lot-aware value
        $value = 0.0;
        $blocked = 0.0;

        $lots = $s['lots'] ?? [];
        if(is_array($lots) && count($lots)>0){
            foreach($lots as $l){
                $q = (float)($l['quantity'] ?? 0);
                if($q <= 0) continue;
                $uc = (float)($l['unitCost'] ?? 0);
                $use = ($uc>0) ? $uc : ($fallbackUnitCost ?? 0);
                if($use <= 0){
                    $excluded['rows']++;
                    if($unitType !== $baseUnit && $explicitBaseCost === null) $excluded['reason_unit_mismatch']++;
                    continue;
                }
                $v = $q * $use;
                $value += $v;
                if((bool)($l['blocked'] ?? false)) $blocked += $v;
            }
        } else {
            $q = (float)($s['quantity'] ?? 0);
            if($q > 0){
                if($fallbackUnitCost === null || $fallbackUnitCost <= 0){
                    $excluded['rows']++;
                    if($unitType !== $baseUnit && $explicitBaseCost === null) $excluded['reason_unit_mismatch']++;
                } else {
                    $value = $q * $fallbackUnitCost;
                }
            }
        }

        $total += $value;
        $blockedValue += $blocked;

        $whName = $whById[(string)$wid]['name'] ?? ('Magasin '.$wid);
        if(!isset($byWarehouse[$whName])) $byWarehouse[$whName] = 0.0;
        $byWarehouse[$whName] += $value;

        if(!isset($byCategory[$cat])) $byCategory[$cat] = 0.0;
        $byCategory[$cat] += $value;
    }

    // sort desc
    arsort($byWarehouse);
    arsort($byCategory);

    return [
        'totalValue' => round($total, 2),
        'blockedValue' => round($blockedValue, 2),
        'byWarehouse' => array_map(fn($v)=>round($v,2), $byWarehouse),
        'byCategory' => array_map(fn($v)=>round($v,2), $byCategory),
        'excluded' => $excluded,
        'generatedAt' => date('c'),
    ];
}


// ================================================================
// PACKAGING HELPERS
// ================================================================
function normalizeUnit(string $u): string {
    $u = strtolower(trim($u));
    $map = [
        'piece'=>'piece','pièce'=>'piece','pcs'=>'piece','pc'=>'piece',
        'carton'=>'carton','box'=>'carton',
        'palette'=>'palette','pallet'=>'palette',
        'fût'=>'fut','fut'=>'fut','barrel'=>'fut',
        'bidon'=>'bidon','jerrycan'=>'bidon',
        'l'=>'litre','litre'=>'litre','liter'=>'litre',
        'kg'=>'kg','kilogramme'=>'kg','kilogram'=>'kg',
        // "ml" est utilisé ici pour "mètre linéaire" (pas millilitre)
        'ml'=>'ml','m.l'=>'ml','m.l.'=>'ml','ml.'=>'ml',
        'metre lineaire'=>'ml','mètre linéaire'=>'ml','metre linéaire'=>'ml','mètre lineaire'=>'ml',
        'm'=>'m','metre'=>'m','mètre'=>'m',
        'm2'=>'m2','m²'=>'m2','metre2'=>'m2','mètre2'=>'m2',
        'm3'=>'m3','m³'=>'m3','metre3'=>'m3','mètre3'=>'m3',
        'aerosol'=>'aerosol','aérosol'=>'aerosol',
        'sac'=>'sac','seau'=>'seau','rouleau'=>'rouleau','bobine'=>'bobine','tube'=>'tube','boite'=>'boite','boîte'=>'boite','paquet'=>'paquet'
    ];
    return $map[$u] ?? $u;
}

function unitLabel(string $unit): string {
    $unit = normalizeUnit($unit);
    $labels = [
        'piece'=>'pièce', 'carton'=>'carton', 'palette'=>'palette', 'fut'=>'fût',
        'bidon'=>'bidon', 'litre'=>'litre', 'kg'=>'kg', 'ml'=>'mètre linéaire (ml)',
        'm'=>'m', 'm2'=>'m²', 'm3'=>'m³', 'aerosol'=>'aérosol', 'sac'=>'sac',
        'seau'=>'seau', 'rouleau'=>'rouleau', 'bobine'=>'bobine', 'tube'=>'tube',
        'boite'=>'boîte', 'paquet'=>'paquet'
    ];
    return $labels[$unit] ?? $unit;
}

/** Convert a quantity expressed in a packaging unit into base units. */
function toBaseQuantity(array $product, string $unit, $qty): float {
    $qty = (float)$qty;
    $unit = normalizeUnit($unit);
    $base = normalizeUnit($product['packBaseUnit'] ?? ($product['unitType'] ?? 'piece'));
    if($qty <= 0) return 0;
    if($unit === $base) return $qty;

    // Lot 1: generic unit conversion (ml↔litre, g↔kg, m3↔litre, etc.)
    $converted = convertQuantity($qty, $unit, $base);
    if($converted !== $qty || ($unit!==$base && in_array($unit,['ml','g','m3','litre','kg','m','cm','mm']))) {
        // If conversion is meaningful (or unit is in known families), accept it.
        if($unit!==$base && $converted>0) return $converted;
    }


    $carton  = (float)($product['packCartonQty'] ?? ($product['unitsPerBox'] ?? 0));
    $palette = (float)($product['packPaletteQty'] ?? 0);
    $aerosol = (float)($product['packAerosolQty'] ?? 0);
    $sac     = (float)($product['packSacQty'] ?? 0);
    $seau    = (float)($product['packSeauQty'] ?? 0);
    $rouleau = (float)($product['packRouleauQty'] ?? 0);
    $bobine  = (float)($product['packBobineQty'] ?? 0);
    $tube    = (float)($product['packTubeQty'] ?? 0);
    $boite   = (float)($product['packBoiteQty'] ?? 0);
    $paquet  = (float)($product['packPaquetQty'] ?? 0);

    if($base === 'piece'){
        if($unit === 'carton' && $carton>0) return $qty * $carton;
        if($unit === 'palette'){
            if($palette>0) return $qty * $palette;
            $boxes = (int)($product['boxesPerPallet'] ?? 0);
            if($carton>0 && $boxes>0) return $qty * ($carton*$boxes);
        }
        // Other packagings expressed in base units per container
        if($unit === 'aerosol' && $aerosol>0) return $qty * $aerosol;
        if($unit === 'sac' && $sac>0) return $qty * $sac;
        if($unit === 'seau' && $seau>0) return $qty * $seau;
        if($unit === 'rouleau' && $rouleau>0) return $qty * $rouleau;
        if($unit === 'bobine' && $bobine>0) return $qty * $bobine;
        if($unit === 'tube' && $tube>0) return $qty * $tube;
        if($unit === 'boite' && $boite>0) return $qty * $boite;
        if($unit === 'paquet' && $paquet>0) return $qty * $paquet;
    }

    if($base === 'litre'){
        $bidon = (float)($product['packBidonQty'] ?? ($product['volume'] ?? 0));
        $fut   = (float)($product['packFutQty'] ?? 0);
        if($unit === 'bidon' && $bidon>0) return $qty * $bidon;
        if($unit === 'fut'   && $fut>0)   return $qty * $fut;
        if($unit === 'aerosol' && $aerosol>0) return $qty * $aerosol;
        if($unit === 'sac' && $sac>0) return $qty * $sac;
        if($unit === 'seau' && $seau>0) return $qty * $seau;
        if($unit === 'rouleau' && $rouleau>0) return $qty * $rouleau;
        if($unit === 'bobine' && $bobine>0) return $qty * $bobine;
        if($unit === 'tube' && $tube>0) return $qty * $tube;
        if($unit === 'boite' && $boite>0) return $qty * $boite;
        if($unit === 'paquet' && $paquet>0) return $qty * $paquet;
    }
    if($base === 'ml'){
        // Quantities already expressed in ml (base unit)
        if($unit === 'aerosol' && $aerosol>0) return $qty * $aerosol;
        if($unit === 'bidon' && (float)($product['packBidonQty'] ?? 0)>0) return $qty * (float)($product['packBidonQty'] ?? 0);
        if($unit === 'fut' && (float)($product['packFutQty'] ?? 0)>0) return $qty * (float)($product['packFutQty'] ?? 0);
        if($unit === 'sac' && $sac>0) return $qty * $sac;
        if($unit === 'seau' && $seau>0) return $qty * $seau;
        if($unit === 'rouleau' && $rouleau>0) return $qty * $rouleau;
        if($unit === 'bobine' && $bobine>0) return $qty * $bobine;
        if($unit === 'tube' && $tube>0) return $qty * $tube;
        if($unit === 'boite' && $boite>0) return $qty * $boite;
        if($unit === 'paquet' && $paquet>0) return $qty * $paquet;
    }

    if($base === 'kg'){
        $bidon = (float)($product['packBidonQty'] ?? 0);
        $fut   = (float)($product['packFutQty'] ?? 0);
        if($unit === 'bidon' && $bidon>0) return $qty * $bidon;
        if($unit === 'fut'   && $fut>0)   return $qty * $fut;
        if($unit === 'aerosol' && $aerosol>0) return $qty * $aerosol;
        if($unit === 'sac' && $sac>0) return $qty * $sac;
        if($unit === 'seau' && $seau>0) return $qty * $seau;
        if($unit === 'rouleau' && $rouleau>0) return $qty * $rouleau;
        if($unit === 'bobine' && $bobine>0) return $qty * $bobine;
        if($unit === 'tube' && $tube>0) return $qty * $tube;
        if($unit === 'boite' && $boite>0) return $qty * $boite;
        if($unit === 'paquet' && $paquet>0) return $qty * $paquet;
    }

    return $qty; // fail-soft if no conversion
}

/** Convert a unit-cost expressed for a given unit into base-unit cost. */
function toBaseUnitCost(array $product, string $unit, $unitCost): float {
    $unitCost = (float)$unitCost;
    if($unitCost <= 0) return 0;
    $unit = normalizeUnit($unit);
    $base = normalizeUnit($product['packBaseUnit'] ?? ($product['unitType'] ?? 'piece'));
    if($unit === $base) return $unitCost;
    $one = toBaseQuantity($product, $unit, 1);
    if($one <= 0) return 0;
    return $unitCost / $one;
}

// ================================================================
// JSON DATA LAYER
// ================================================================
function dataPath(string $entity): string {
    return DATA_DIR.'/'.$entity.'.json';
}

function loadData(string $entity): array {
    $f = dataPath($entity);
    if(!file_exists($f)) return [];
    $c = file_get_contents($f);
    return ($c && ($d=json_decode($c,true)) && is_array($d)) ? $d : [];
}

function saveData(string $entity, array $data): bool {
    $f = dataPath($entity);
    $fp = fopen($f,'c+');
    if(!$fp) return false;
    if(flock($fp, LOCK_EX)){
        ftruncate($fp,0); rewind($fp);
        fwrite($fp, json_encode($data, JSON_PRETTY_PRINT|JSON_UNESCAPED_UNICODE));
        flock($fp, LOCK_UN);
    }
    fclose($fp);
    return true;
}

// ══ Historique produit par fichier (perf) ═══════════════════════════
// Chaque produit a son propre fichier data/versions/{id}.json (petit) : évite de
// recharger/réécrire le gros product_versions.json global à chaque édition.
// Repli transparent sur l'ancien fichier global tant que la migration n'a pas
// tourné → aucune perte d'historique, aucune casse.
if(!defined('PRODUCT_VERSIONS_KEEP')) define('PRODUCT_VERSIONS_KEEP', 50);

function pvDir(): string {
    $d = DATA_DIR.'/versions';
    if(!is_dir($d)) @mkdir($d, 0775, true);
    return $d;
}
function pvFile(int $pid): string { return pvDir().'/'.$pid.'.json'; }

/** Lit UNIQUEMENT le fichier par produit (chemin d'écriture : rapide). */
function pvLoadOne(int $pid): array {
    $f = pvFile($pid);
    if(!is_file($f)) return [];
    $c = file_get_contents($f);
    return ($c && ($d=json_decode($c,true)) && is_array($d)) ? $d : [];
}

/** Écrit le fichier par produit sous verrou, en appliquant le plafond N. */
function pvSaveOne(int $pid, array $arr): bool {
    $keep = (int)PRODUCT_VERSIONS_KEEP;
    if($keep > 0 && count($arr) > $keep){
        usort($arr, function($a,$b){
            $va=(int)($a['version']??0); $vb=(int)($b['version']??0);
            if($va!==$vb) return $va<=>$vb;
            return strcmp((string)($a['createdAt']??''), (string)($b['createdAt']??''));
        });
        $arr = array_slice($arr, count($arr)-$keep); // garde les $keep plus récents
    }
    $f = pvFile($pid);
    $fp = fopen($f,'c+'); if(!$fp) return false;
    if(flock($fp, LOCK_EX)){
        ftruncate($fp,0); rewind($fp);
        fwrite($fp, json_encode(array_values($arr), JSON_PRETTY_PRINT|JSON_UNESCAPED_UNICODE));
        flock($fp, LOCK_UN);
    }
    fclose($fp);
    return true;
}

/** Lecture fusionnée pour UN produit : fichier par produit + ancien global (dédup par id). */
function pvReadMerged(int $pid): array {
    $out = pvLoadOne($pid);
    $seen = [];
    foreach($out as $v){ $seen[(string)($v['id']??'')] = true; }
    foreach(loadData('product_versions') as $v){
        if((int)($v['productId']??0)!==$pid) continue;
        $id=(string)($v['id']??'');
        if($id!=='' && isset($seen[$id])) continue;
        $out[] = $v; $seen[$id]=true;
    }
    return $out;
}

/** Lecture fusionnée de TOUT l'historique (fichiers par produit + ancien global, dédup par id). */
function pvReadAll(): array {
    $out = []; $seen = [];
    foreach(glob(pvDir().'/*.json') ?: [] as $f){
        $c=file_get_contents($f); $arr=($c?json_decode($c,true):null);
        if(is_array($arr)) foreach($arr as $v){ $out[]=$v; $seen[(string)($v['id']??'')]=true; }
    }
    foreach(loadData('product_versions') as $v){
        $id=(string)($v['id']??'');
        if($id!=='' && isset($seen[$id])) continue;
        $out[]=$v; $seen[$id]=true;
    }
    return $out;
}

/** Écrit un lot complet réparti par produit (restauration d'archive). */
function pvSaveAll(array $items): void {
    $byPid = [];
    foreach($items as $v){ $byPid[(int)($v['productId']??0)][] = $v; }
    foreach($byPid as $pid=>$arr){ pvSaveOne((int)$pid, $arr); }
}

function genId(): int {
    // Use high-resolution time + random component to minimize collision risk
    // Falls back to a counter-based approach using a shared file if needed
    static $seq = 0;
    $seq = ($seq + 1) % 1000;
    return (int)(microtime(true) * 1000) * 1000 + $seq + rand(0, 99);
}

function findById(array $items, $id): ?array {
    foreach($items as $i) if((string)($i['id']??'')===(string)$id) return $i;
    return null;
}

function addItem(string $entity, array $item): array {
    $items = loadData($entity);
    $item['id'] = genId();
    if(!isset($item['createdAt'])) $item['createdAt'] = date('c');
    $items[] = $item;
    saveData($entity, $items);
    return $item;
}

function updateItem(string $entity, $id, array $fields): ?array {
    $items = loadData($entity);
    foreach($items as &$i){
        if((string)($i['id']??'')===(string)$id){
            $i = array_merge($i, $fields);
            $i['updatedAt'] = date('c');
            saveData($entity, $items);
            return $i;
        }
    }
    return null;
}

function deleteItem(string $entity, $id): bool {
    $items = loadData($entity);
    $new = array_filter($items, fn($i)=>(string)($i['id']??'')!==(string)$id);
    saveData($entity, array_values($new));
    return true;
}

function clearData(string $entity): void { saveData($entity,[]); }

// ================================================================
// TIMEZONE
// ================================================================
function normalizeAppTimezone(string $timezone, string $fallback = 'Europe/Paris'): string {
    $timezone = trim($timezone);
    if($timezone !== '' && in_array($timezone, timezone_identifiers_list(), true)) return $timezone;
    return $fallback;
}

// ================================================================
// SETTINGS (singleton)
// ================================================================
function getSettings(): array {
    $defaults = [
        'id'=>1,
        'companyName'=>'MageStock',
        'skuPrefix'=>'PRD-',
        'logoUrl'=>'',
        'locale'=>'auto',
        'country'=>'FR',
        'currency'=>'EUR',
        'timezone'=>'Europe/Paris',
        'installationComplete'=>false,
        'localeCurrencyVersion'=>'1.0',
    ];
    $all = loadData('settings');
    $settings = array_merge($defaults, $all[0] ?? []);
    $settings['locale'] = normalizeAppLocale((string)$settings['locale']);
    $settings['country'] = normalizeAppCountry((string)$settings['country']);
    $settings['currency'] = normalizeAppCurrency((string)$settings['currency']);
    $settings['timezone'] = normalizeAppTimezone((string)($settings['timezone'] ?? 'Europe/Paris'));
    $settings['installationComplete'] = (bool)($settings['installationComplete'] ?? false);
    return $settings;
}
function saveSettings(array $s): void {
    $current = getSettings();
    $s = array_merge($current, $s);
    $s['id'] = 1;
    $s['locale'] = normalizeAppLocale((string)($s['locale'] ?? 'auto'));
    $s['country'] = normalizeAppCountry((string)($s['country'] ?? 'FR'));
    $s['currency'] = normalizeAppCurrency((string)($s['currency'] ?? 'EUR'));
    $s['timezone'] = normalizeAppTimezone((string)($s['timezone'] ?? 'Europe/Paris'));
    $s['installationComplete'] = (bool)($s['installationComplete'] ?? false);
    $s['localeCurrencyVersion'] = '1.0';
    saveData('settings', [$s]);
}

// Appliqué à chaque requête après disponibilité de la couche settings.
@date_default_timezone_set(normalizeAppTimezone((string)(getSettings()['timezone'] ?? 'Europe/Paris')));

// ================================================================
// SKU GENERATOR
// ================================================================
function nextSKU(): string {
    $pfx = getSettings()['skuPrefix'] ?? 'PRD-';
    $nums = array_map(function($p) use($pfx){
        $s=$p['sku']??'';
        return str_starts_with($s,$pfx) ? (int)ltrim(substr($s,strlen($pfx)),'0') : 0;
    }, loadData('products'));
    return $pfx . str_pad((string)((count($nums)?max($nums):0)+1), 4, '0', STR_PAD_LEFT);
}

// ================================================================
// CSRF PROTECTION (NEW V2.1)
// ================================================================
function csrfToken(): string {
    startSession();
    if (!isset($_SESSION['csrf_token'])) {
        $_SESSION['csrf_token'] = bin2hex(random_bytes(32));
    }
    return $_SESSION['csrf_token'];
}

function csrfCheck(): void {
    startSession();
    $token = $_POST['csrf_token'] ?? $_SERVER['HTTP_X_CSRF_TOKEN'] ?? '';
    if (!hash_equals($_SESSION['csrf_token'] ?? '', $token)) {
        jsonError('CSRF token invalide', 403);
    }
}

// ================================================================
// RATE LIMITING (NEW V2.1)
// ================================================================
function checkRateLimit(string $key, int $maxAttempts = 5, int $windowSeconds = 300): bool {
    $file = DATA_DIR.'/rate_limits.json';
    
    // Use file locking to prevent race conditions on concurrent requests
    $fp = fopen($file, 'c+');
    if (!$fp) return true; // fail-open if file can't be opened
    
    $now = time();
    $limits = [];
    
    if (flock($fp, LOCK_EX)) {
        $content = stream_get_contents($fp);
        if ($content) {
            $limits = json_decode($content, true) ?: [];
        }
        
        $allow = true;
        if (!isset($limits[$key])) {
            $limits[$key] = ['attempts' => 1, 'firstAttempt' => $now];
        } else {
            $data = $limits[$key];
            // Reset if window expired
            if ($now - $data['firstAttempt'] > $windowSeconds) {
                $limits[$key] = ['attempts' => 1, 'firstAttempt' => $now];
            } elseif ($data['attempts'] >= $maxAttempts) {
                $allow = false;
            } else {
                $limits[$key]['attempts']++;
            }
        }
        
        // Write back
        ftruncate($fp, 0);
        rewind($fp);
        fwrite($fp, json_encode($limits));
        flock($fp, LOCK_UN);
    }
    
    fclose($fp);
    return $allow;
}

// ================================================================
// PASSWORD POLICY & SESSION REVOCATION — 2.6.119
// ================================================================
function magestockPasswordLength(string $password): int {
    return function_exists('mb_strlen') ? mb_strlen($password, 'UTF-8') : strlen($password);
}

function magestockNormalizePasswordComparison(string $value): string {
    $value = trim($value);
    if(function_exists('normalizer_normalize') && class_exists('Normalizer')){
        $normalized = @normalizer_normalize($value, Normalizer::FORM_C);
        if(is_string($normalized)) $value = $normalized;
    }
    return function_exists('mb_strtolower') ? mb_strtolower($value, 'UTF-8') : strtolower($value);
}

function magestockPasswordPolicyStatus(string $password, ?array $user = null): array {
    $length = magestockPasswordLength($password);
    $normalizedPassword = magestockNormalizePasswordComparison($password);
    $forbidden = ['magestock'];
    foreach(['username','email','name'] as $field){
        $candidate = magestockNormalizePasswordComparison((string)($user[$field] ?? ''));
        if(magestockPasswordLength($candidate) >= 3) $forbidden[] = $candidate;
    }
    $identityFree = true;
    foreach(array_unique($forbidden) as $needle){
        if($needle !== '' && str_contains($normalizedPassword, $needle)){
            $identityFree = false;
            break;
        }
    }
    return [
        'length' => $length >= 14 && $length <= 255,
        'lowercase' => (bool)preg_match('/\p{Ll}/u', $password),
        'uppercase' => (bool)preg_match('/\p{Lu}/u', $password),
        'number' => (bool)preg_match('/\p{N}/u', $password),
        'symbol' => (bool)preg_match('/[\p{P}\p{S}]/u', $password),
        'identityFree' => $identityFree,
    ];
}

function magestockPasswordPolicyErrors(string $password, ?array $user = null): array {
    $status = magestockPasswordPolicyStatus($password, $user);
    $labels = [
        'length' => 'Le mot de passe doit contenir entre 14 et 255 caractères.',
        'lowercase' => 'Le mot de passe doit contenir au moins une lettre minuscule.',
        'uppercase' => 'Le mot de passe doit contenir au moins une lettre majuscule.',
        'number' => 'Le mot de passe doit contenir au moins un chiffre.',
        'symbol' => 'Le mot de passe doit contenir au moins un symbole ou signe de ponctuation.',
        'identityFree' => 'Le mot de passe ne doit pas contenir MageStock, le nom ou l’identifiant du compte.',
    ];
    $errors = [];
    foreach($status as $key=>$valid) if(!$valid) $errors[] = $labels[$key];
    return $errors;
}

function magestockPasswordPolicyValid(string $password, ?array $user = null): bool {
    return count(magestockPasswordPolicyErrors($password, $user)) === 0;
}

function magestockUserAuthVersion(?array $user): int {
    return max(1, (int)($user['authVersion'] ?? 1));
}

function magestockClearAuthenticationSession(): void {
    startSession();
    $_SESSION = [];
    if(ini_get('session.use_cookies')){
        $params = session_get_cookie_params();
        setcookie(session_name(), '', [
            'expires'=>time()-42000,
            'path'=>$params['path'] ?: appCookiePath(),
            'domain'=>$params['domain'] ?? '',
            'secure'=>(bool)($params['secure'] ?? false),
            'httponly'=>(bool)($params['httponly'] ?? true),
            'samesite'=>$params['samesite'] ?? 'Lax',
        ]);
    }
    if(session_status() === PHP_SESSION_ACTIVE) session_destroy();
}

// ================================================================
// AUTH HELPERS
// ================================================================
function startSession(){
    if(session_status() !== PHP_SESSION_ACTIVE){
        $cookiePath = appCookiePath();
        if(!headers_sent()){
            $params = session_get_cookie_params();
            session_set_cookie_params([
                'lifetime' => (int)($params['lifetime'] ?? 0),
                'path' => $cookiePath,
                'domain' => (string)($params['domain'] ?? ''),
                'secure' => appRequestIsSecure(),
                'httponly' => true,
                'samesite' => trim((string)($params['samesite'] ?? '')) !== '' ? (string)$params['samesite'] : 'Lax',
            ]);
        }
        session_start();
    }
}

function isLoggedIn(): bool {
    return currentUser() !== null;
}

function currentUser(): ?array {
    startSession();
    if(!isset($_SESSION['user_id'])) return null;
    $user = findById(loadData('users'), $_SESSION['user_id']);
    if(!$user || ($user['active'] ?? true) === false){
        magestockClearAuthenticationSession();
        return null;
    }
    $currentVersion = magestockUserAuthVersion($user);
    if(!isset($_SESSION['auth_version'])){
        // Compatibilité des sessions ouvertes avant 2.6.119 sans permettre à une
        // ancienne session de survivre à un changement de mot de passe récent.
        $authenticatedAt = (int)($_SESSION['authenticated_at'] ?? 0);
        $passwordChangedAt = strtotime((string)($user['passwordChangedAt'] ?? '')) ?: 0;
        if($passwordChangedAt > 0 && ($authenticatedAt <= 0 || $authenticatedAt < $passwordChangedAt)){
            magestockClearAuthenticationSession();
            return null;
        }
        $_SESSION['auth_version'] = $currentVersion;
    } elseif((int)$_SESSION['auth_version'] !== $currentVersion){
        magestockClearAuthenticationSession();
        return null;
    }
    return $user;
}

function isAdmin(): bool {
    $u = currentUser();
    return $u && ($u['role']??'')==='admin';
}

/**
 * Accès administrateur effectif.
 * Le rôle admin conserve tous les droits. Un compte user peut également recevoir
 * l'accès à la zone d'administration via l'override individuel admin.access,
 * notamment après synchronisation LDAP.
 */
function hasAdminAccess(?array $user = null): bool {
    $u = $user ?? currentUser();
    if(!$u) return false;
    if(in_array(magestockNormalizeRole($u['role'] ?? 'user'), ['admin','magasinier'], true)) return true;
    $perms = getEffectivePermissions($u);
    return ($perms['admin.access'] ?? false) === true;
}

/**
 * Check if current user can access a specific warehouse
 * Admin users have access to all warehouses
 * Regular users are limited to their allowedWarehouses list
 * If allowedWarehouses is empty/not set, user has access to all (backward compatibility)
 */
function userCanAccessWarehouse($warehouseId): bool {
    $u = currentUser();
    if(!$u) return false;
    
    // Admin has access to everything
    if(($u['role']??'')==='admin') return true;
    
    // Get allowed warehouses
    $allowed = $u['allowedWarehouses'] ?? [];
    if(is_string($allowed)) {
        $allowed = json_decode($allowed, true) ?: [];
    }
    
    // If no restriction set, allow all (backward compatibility)
    if(empty($allowed)) return true;
    
    // Check if warehouse is in allowed list
    return in_array((int)$warehouseId, array_map('intval', $allowed));
}

/**
 * Get list of warehouse IDs the current user can access
 * Returns null if user has access to all (admin or no restriction)
 */
function getUserAllowedWarehouses(): ?array {
    $u = currentUser();
    if(!$u) return [];
    
    // Admin has access to everything
    if(($u['role']??'')==='admin') return null;
    
    // Get allowed warehouses
    $allowed = $u['allowedWarehouses'] ?? [];
    if(is_string($allowed)) {
        $allowed = json_decode($allowed, true) ?: [];
    }
    
    // If no restriction set, return null (access to all)
    if(empty($allowed)) return null;
    
    return array_map('intval', $allowed);
}

function requireAuth(): void {
    if(!isLoggedIn()){
        // API or page?
        if(($_SERVER['HTTP_X_REQUESTED_WITH']??'')==='XMLHttpRequest'){
            jsonResponse(['error'=>'Non authentifié.'],401); exit;
        }
        header('Location: ' . appUrl('login')); exit;
    }
}

function requireAdmin(): void {
    requireAuth();
    if(!hasAdminAccess()) jsonError('Accès interdit.',403);
}


// ================================================================
// ANALYTICS HELPERS (Lot 3)
// - consommation moyenne
// - projections 30/60/90
// - produits dormants
// - top consommés / risques de rupture
// ================================================================

function parseIsoToTs(?string $iso): int {
    if(!$iso) return 0;
    $t = strtotime($iso);
    return $t ? (int)$t : 0;
}

function movementTs(array $m): int {
    // createdAt is the canonical field in the app
    if(isset($m['createdAt'])) return parseIsoToTs((string)$m['createdAt']);
    if(isset($m['date'])) return parseIsoToTs((string)$m['date']);
    return 0;
}

function isOutboundMovement(array $m): bool {
    return (string)($m['type'] ?? '') === 'remove';
}

function movementBaseQty(array $m): float {
    // prefer baseQuantity, fallback to quantity
    if(isset($m['baseQuantity'])) return (float)$m['baseQuantity'];
    return (float)($m['quantity'] ?? 0);
}

/**
 * Compute consumption totals (outbound) over the last N days.
 * @return array keyed by "warehouseId|productId" => totalQty
 */
function computeConsumptionTotals(int $days, ?int $warehouseId = null): array {
    $movements = loadData('movements');
    $fromTs = time() - ($days * 86400);
    $out = [];
    foreach($movements as $m){
        if(!is_array($m)) continue;
        if(!isOutboundMovement($m)) continue;
        $wh = (int)($m['warehouseId'] ?? 0);
        if($warehouseId !== null && $wh !== (int)$warehouseId) continue;
        $ts = movementTs($m);
        if($ts > 0 && $ts < $fromTs) continue;

        $pid = (int)($m['productId'] ?? 0);
        if($pid <= 0 || $wh <= 0) continue;

        $k = $wh.'|'.$pid;
        $out[$k] = ($out[$k] ?? 0) + max(0.0, movementBaseQty($m));
    }
    return $out;
}

/**
 * Returns an associative map for quick access: productId => product
 */
function indexProductsById(): array {
    $products = loadData('products');
    $idx = [];
    foreach($products as $p){
        if(!is_array($p)) continue;
        $id = (int)($p['id'] ?? 0);
        if($id>0) $idx[$id] = $p;
    }
    return $idx;
}

/**
 * Current stock snapshot indexed by "warehouseId|productId"
 * Includes quantity and best-effort unit cost for valuation.
 */
function indexStockSnapshot(): array {
    $stock = loadData('stock');
    $out = [];
    foreach($stock as $s){
        if(!is_array($s)) continue;
        $pid = (int)($s['productId'] ?? 0);
        $wh  = (int)($s['warehouseId'] ?? 0);
        if($pid<=0 || $wh<=0) continue;
        $k = $wh.'|'.$pid;

        $qty = (float)($s['quantity'] ?? 0);

        // Best-effort unit cost: prefer lot unitCost average, else product cump/cost will be used at higher level.
        $unitCost = 0.0;
        $lots = $s['lots'] ?? [];
        if(is_array($lots) && count($lots)>0){
            $sumQ = 0.0; $sumV = 0.0;
            foreach($lots as $l){
                if(!is_array($l)) continue;
                $q = (float)($l['quantity'] ?? 0);
                if($q<=0) continue;
                $c = (float)($l['unitCost'] ?? 0);
                if($c<=0) continue;
                $sumQ += $q;
                $sumV += ($q*$c);
            }
            if($sumQ>0) $unitCost = $sumV/$sumQ;
        }

        $out[$k] = [
            'productId' => $pid,
            'warehouseId' => $wh,
            'quantity' => $qty,
            'unitCost' => $unitCost,
            'lastMovementAt' => (string)($s['lastMovementAt'] ?? ''),
        ];
    }
    return $out;
}

/**
 * Compute smart thresholds & rupture risks for a horizon (days).
 * - avgDaily = consumption / windowDays
 * - dynamicSafety = avgDaily * coverDays
 * coverDays defaults to 30 (configurable per product via smartCoverDays)
 */
function computeRuptureRisks(int $windowDays = 90, int $horizonDays = 90, ?int $warehouseId = null): array {
    $products = indexProductsById();
    $stockIdx = indexStockSnapshot();
    $cons = computeConsumptionTotals($windowDays, $warehouseId);

    $risks = [];
    foreach($stockIdx as $k=>$s){
        $pid = (int)$s['productId'];
        $wh  = (int)$s['warehouseId'];
        if($warehouseId !== null && $wh !== (int)$warehouseId) continue;

        $p = $products[$pid] ?? null;
        if(!$p) continue;

        $coverDays = (int)($p['smartCoverDays'] ?? 30);
        if($coverDays <= 0) $coverDays = 30;

        $cTot = (float)($cons[$k] ?? 0);
        $avgDaily = $windowDays>0 ? ($cTot / $windowDays) : 0.0;

        $qty = (float)($s['quantity'] ?? 0);
        $dynamicSafety = $avgDaily * $coverDays;

        $daysLeft = ($avgDaily > 0) ? ($qty / $avgDaily) : INF;
        $projectedQty = $qty - ($avgDaily * $horizonDays);

        $riskLevel = 'ok';
        if($avgDaily > 0){
            if($daysLeft <= 7) $riskLevel = 'critique';
            else if($daysLeft <= 30) $riskLevel = 'alerte';
            else if($daysLeft <= 60) $riskLevel = 'attention';
        }

        $risks[] = [
            'warehouseId' => $wh,
            'productId' => $pid,
            'productName' => (string)($p['name'] ?? ('#'.$pid)),
            'sku' => (string)($p['sku'] ?? ''),
            'category' => (string)($p['category'] ?? ''),
            'quantity' => $qty,
            'avgDaily' => $avgDaily,
            'windowDays' => $windowDays,
            'coverDays' => $coverDays,
            'dynamicSafety' => $dynamicSafety,
            'daysLeft' => is_infinite($daysLeft) ? null : $daysLeft,
            'projectedQty' => $projectedQty,
            'risk' => $riskLevel,
        ];
    }

    usort($risks, function($a,$b){
        $da = $a['daysLeft'] ?? 1e9;
        $db = $b['daysLeft'] ?? 1e9;
        return $da <=> $db;
    });

    return $risks;
}

/**
 * Dormant products: no movement for N days, with non-zero stock.
 */
function computeDormantProducts(int $dormantDays = 180, ?int $warehouseId = null): array {
    $products = indexProductsById();
    $stockIdx = indexStockSnapshot();

    $cutTs = time() - ($dormantDays * 86400);
    $out = [];
    foreach($stockIdx as $k=>$s){
        $wh = (int)$s['warehouseId'];
        if($warehouseId !== null && $wh !== (int)$warehouseId) continue;

        $qty = (float)($s['quantity'] ?? 0);
        if($qty <= 0) continue;

        $lastTs = parseIsoToTs($s['lastMovementAt'] ?? null);
        if($lastTs === 0){
            // If never moved, consider dormant if createdAt older than cut
            $lastTs = $cutTs - 1;
        }
        if($lastTs > $cutTs) continue;

        $pid = (int)$s['productId'];
        $p = $products[$pid] ?? null;
        if(!$p) continue;

        $out[] = [
            'warehouseId' => $wh,
            'productId' => $pid,
            'productName' => (string)($p['name'] ?? ('#'.$pid)),
            'sku' => (string)($p['sku'] ?? ''),
            'quantity' => $qty,
            'lastMovementAt' => (string)($s['lastMovementAt'] ?? ''),
            'dormantDays' => $dormantDays,
        ];
    }

    usort($out, fn($a,$b)=>strcmp((string)$a['lastMovementAt'], (string)$b['lastMovementAt']));
    return $out;
}

// ================================================================
// JSON RESPONSE HELPERS
// ================================================================
function jsonResponse(array $data, int $code=200): void {
    http_response_code($code);
    header('Content-Type: application/json; charset=UTF-8');
    echo json_encode($data, JSON_PRETTY_PRINT|JSON_UNESCAPED_UNICODE);
    exit;
}
function jsonError(string $msg, int $code=400): void { jsonResponse(['error'=>$msg],$code); }


function convertQuantity(float $qty, string $fromUnit, string $toUnit): float {
    $from = normalizeUnit($fromUnit);
    $to   = normalizeUnit($toUnit);
    if($qty == 0) return 0;
    if($from === $to) return $qty;

    // Mass
    if($from==='g' && $to==='kg') return $qty / 1000.0;
    if($from==='kg' && $to==='g') return $qty * 1000.0;

    // Volume
    if($from==='ml' && $to==='litre') return $qty / 1000.0;
    if($from==='litre' && $to==='ml') return $qty * 1000.0;

    // Length
    if($from==='mm' && $to==='cm') return $qty / 10.0;
    if($from==='cm' && $to==='mm') return $qty * 10.0;
    if($from==='cm' && $to==='m')  return $qty / 100.0;
    if($from==='m'  && $to==='cm') return $qty * 100.0;
    if($from==='mm' && $to==='m')  return $qty / 1000.0;
    if($from==='m'  && $to==='mm') return $qty * 1000.0;

    // m3 <-> litre
    if($from==='m3' && $to==='litre') return $qty * 1000.0;
    if($from==='litre' && $to==='m3') return $qty / 1000.0;

    return $qty;
}

// ================================================================
// MISES À JOUR SÉCURISÉES — état, détection et garde API
// ================================================================
require_once APP_ROOT . '/includes/update-helper.php';
require_once APP_ROOT . '/includes/update-discovery.php';
magestockUpdateBootstrapState();
magestockUpdateEnforceRequestGate();

