<?php
/**
 * Fichier  : config.php
 * Auteur   : KREMER Régis
 * Version  : 2.3.0
 * Date     : 2026-02-26
 * 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)
 */
// ================================================================
// STOCKMASTER PRO — 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('APP_NAME',   'StockMaster Pro');
define('APP_VER',    '2.3.0');

// Data files (Lot 1)
define('PERMISSIONS_FILE', DATA_DIR.'/permissions.json');
define('LOCATIONS_FILE',   DATA_DIR.'/locations.json');

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

// ── Default admin seed (created on first run) ──
function seedAdmin(){
    $users = loadData('users');
    if(empty($users)){
        // Generate a random secure password on first install
        $randomPassword = bin2hex(random_bytes(8)); // 16 hex chars
        // Write it to a one-time readable file so the admin can retrieve it
        $initFile = DATA_DIR.'/INITIAL_PASSWORD.txt';
        if(!file_exists($initFile)){
            file_put_contents($initFile,
                "StockMaster Pro — Mot de passe administrateur initial\n" .
                "Username : admin\n" .
                "Password : " . $randomPassword . "\n" .
                "SUPPRIMEZ CE FICHIER après votre première connexion.\n" .
                "Generated : " . date('c') . "\n"
            );
        }
        $users[] = [
            'id'        => 1,
            'username'  => 'admin',
            'password'  => password_hash($randomPassword, PASSWORD_DEFAULT),
            'role'      => 'admin',
            'name'      => 'Administrateur',
            'active'    => true,
            'mustChangePassword' => true,
            'createdAt' => date('c'),
        ];
        saveData('users', $users);
    }
}
seedAdmin();

// ── Lot 1: Permissions fines (permissions.json) ────────────────────
function seedPermissions(): void {
    if(!file_exists(PERMISSIONS_FILE)){
        $default = [
            'roles' => [
                'admin' => [
                    '*' => true
                ],
                '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,
                    'warehouses.manage' => false,
                    'locations.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 getEffectivePermissions(?array $user): array {
    if(!$user) return [];
    $permFile = loadPermissions();
    $role = (string)($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] : [];
    // Merge user overrides over role
    return array_merge($rolePerms, $userPerms);
}

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);
}


// ── 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; }
        if(!isset($p['version'])) { $p['version'] = 1; $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);
}

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

// ── 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', '{}');

// ================================================================
// 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;
}

/** 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;
}

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,[]); }

// ================================================================
// SETTINGS (singleton)
// ================================================================
function getSettings(): array {
    $all = loadData('settings');
    return $all[0] ?? ['id'=>1,'companyName'=>'StockMaster','skuPrefix'=>'PRD-','logoUrl'=>''];
}
function saveSettings(array $s): void { $s['id']=1; saveData('settings',[$s]); }

// ================================================================
// 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;
}

// ================================================================
// AUTH HELPERS
// ================================================================
function startSession(){ if(!session_id()) session_start(); }

function isLoggedIn(): bool {
    startSession();
    return isset($_SESSION['user_id']);
}

function currentUser(): ?array {
    startSession();
    if(!isset($_SESSION['user_id'])) return null;
    return findById(loadData('users'), $_SESSION['user_id']);
}

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

/**
 * 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: login'); exit;
    }
}

function requireAdmin(): void {
    requireAuth();
    if(!isAdmin()) 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;
}

