<?php
/**
 * Projet                    : MageStock
 * Logiciel                  : MageStock
 * Fichier                   : includes/password-recovery.php
 * Rôle                      : Génération, conservation sécurisée et consommation des jetons de récupération.
 * Auteur                    : Régis KREMER
 * Version du fichier        : 1.0.0
 * Version générale          : 2.6.119
 * Lot                       : MAGESTOCK CONNEXION RECUPERATION JETON ADMIN1
 * Base requise              : MageStock 2.6.118
 * Création                  : 2026-07-25
 * Dernière modification     : 2026-07-25
 * Dépendances               : config.php, Sodium non requis, stockage JSON MageStock
 * Données touchées          : users.json, password_recovery_tokens.json, password_recovery_audit.json
 * Permissions               : auth.recovery.manage pour la création ; accès public contrôlé pour la consommation
 * Sécurité                  : secret brut affiché une seule fois, SHA-256 uniquement au repos, expiration, usage unique, audit sans secret
 * Compatibilité             : PHP 8.1 à 8.4
 *
 * Résumé :
 * - Fournit un jeton temporaire à usage unique pour un compte local actif.
 * - Révoque les jetons précédents et toutes les sessions après changement de mot de passe.
 * - Refuse tout compte LDAP et applique la politique de mot de passe commune.
 *
 * Historique :
 * - 2.6.119 — Création du moteur de récupération locale contrôlée.
 */

declare(strict_types=1);

final class MageStockRecoveryException extends RuntimeException
{
    public function __construct(
        public readonly string $reasonCode,
        public readonly string $publicMessage,
        int $httpCode = 400,
        ?Throwable $previous = null
    ) {
        parent::__construct($publicMessage, $httpCode, $previous);
    }

    public function httpCode(): int
    {
        $code = $this->getCode();
        return $code >= 400 && $code <= 599 ? $code : 400;
    }
}

function magestockRecoveryAllowedDurations(): array
{
    return [15, 30, 60, 120, 240];
}

function magestockRecoveryDuration(int $minutes): int
{
    if(!in_array($minutes, magestockRecoveryAllowedDurations(), true)){
        throw new MageStockRecoveryException('invalid_duration', 'La durée du jeton doit être comprise entre 15 minutes et 4 heures.', 422);
    }
    return $minutes;
}

function magestockRecoveryBase64UrlEncode(string $raw): string
{
    return rtrim(strtr(base64_encode($raw), '+/', '-_'), '=');
}

function magestockRecoveryNormalizeToken(string $token): string
{
    $token = trim($token);
    if(strlen($token) < 20 || strlen($token) > 180) return '';
    return preg_match('/^MSR1\.[A-Za-z0-9_-]{40,120}$/', $token) ? $token : '';
}

function magestockRecoveryReadLocked($handle): array
{
    rewind($handle);
    $raw = stream_get_contents($handle);
    if(!is_string($raw) || trim($raw) === '') return [];
    $decoded = json_decode($raw, true);
    return is_array($decoded) ? $decoded : [];
}

function magestockRecoveryWriteLocked($handle, array $items): void
{
    $json = json_encode(array_values($items), JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
    if(!is_string($json)) throw new MageStockRecoveryException('storage_encode_failed', 'Impossible d’enregistrer la récupération.', 500);
    rewind($handle);
    if(!ftruncate($handle, 0) || fwrite($handle, $json) === false || !fflush($handle)) {
        throw new MageStockRecoveryException('storage_write_failed', 'Impossible d’enregistrer la récupération.', 500);
    }
}

function magestockRecoveryAudit(string $event, array $context): void
{
    $path = dataPath('password_recovery_audit');
    $handle = @fopen($path, 'c+');
    if(!$handle) return;
    try {
        if(!flock($handle, LOCK_EX)) return;
        $items = magestockRecoveryReadLocked($handle);
        $safe = [
            'id' => genId(),
            'event' => $event,
            'createdAt' => date('c'),
            'actorUserId' => $context['actorUserId'] ?? null,
            'targetUserId' => $context['targetUserId'] ?? null,
            'durationMinutes' => $context['durationMinutes'] ?? null,
            'result' => $context['result'] ?? 'unknown',
            'reasonCode' => $context['reasonCode'] ?? null,
            'source' => $context['source'] ?? 'magestock',
        ];
        $items[] = $safe;
        if(count($items) > 2000) $items = array_slice($items, -2000);
        magestockRecoveryWriteLocked($handle, $items);
    } catch(Throwable $e) {
        error_log('[MageStock Recovery Audit] ' . $e->getMessage());
    } finally {
        @flock($handle, LOCK_UN);
        @fclose($handle);
    }
}

function magestockRecoveryPublicUser(array $user): array
{
    return [
        'id' => $user['id'] ?? null,
        'username' => (string)($user['username'] ?? ''),
        'name' => (string)($user['name'] ?? ''),
        'authSource' => (($user['authSource'] ?? 'local') === 'ldap') ? 'ldap' : 'local',
        'active' => ($user['active'] ?? true) !== false,
    ];
}

function magestockRecoveryCreateToken($targetUserId, int $durationMinutes, array $actor): array
{
    $durationMinutes = magestockRecoveryDuration($durationMinutes);
    $target = findById(loadData('users'), $targetUserId);
    if(!$target) {
        magestockRecoveryAudit('token_create_failed', [
            'actorUserId'=>$actor['id'] ?? null,
            'targetUserId'=>$targetUserId,
            'durationMinutes'=>$durationMinutes,
            'result'=>'failed',
            'reasonCode'=>'user_not_found',
        ]);
        throw new MageStockRecoveryException('user_not_found', 'Utilisateur introuvable.', 404);
    }
    if(($target['authSource'] ?? 'local') === 'ldap') {
        magestockRecoveryAudit('token_create_failed', [
            'actorUserId'=>$actor['id'] ?? null,
            'targetUserId'=>$targetUserId,
            'durationMinutes'=>$durationMinutes,
            'result'=>'failed',
            'reasonCode'=>'ldap_account',
        ]);
        throw new MageStockRecoveryException('ldap_account', 'Le mot de passe de ce compte est géré par l’annuaire LDAP.', 422);
    }
    if(($target['active'] ?? true) === false) {
        magestockRecoveryAudit('token_create_failed', [
            'actorUserId'=>$actor['id'] ?? null,
            'targetUserId'=>$targetUserId,
            'durationMinutes'=>$durationMinutes,
            'result'=>'failed',
            'reasonCode'=>'inactive_account',
        ]);
        throw new MageStockRecoveryException('inactive_account', 'Le compte doit être actif avant de créer un jeton.', 422);
    }

    $token = 'MSR1.' . magestockRecoveryBase64UrlEncode(random_bytes(32));
    $hash = hash('sha256', $token);
    $now = time();
    $expiresAt = $now + ($durationMinutes * 60);
    $path = dataPath('password_recovery_tokens');
    $handle = @fopen($path, 'c+');
    if(!$handle || !flock($handle, LOCK_EX)) {
        if(is_resource($handle)) fclose($handle);
        throw new MageStockRecoveryException('storage_unavailable', 'Le stockage des jetons est indisponible.', 500);
    }

    try {
        $items = magestockRecoveryReadLocked($handle);
        foreach($items as &$item) {
            if((string)($item['userId'] ?? '') !== (string)$targetUserId) continue;
            if(!empty($item['consumedAt']) || !empty($item['revokedAt'])) continue;
            $item['revokedAt'] = date('c', $now);
            $item['revokedBy'] = $actor['id'] ?? null;
            $item['revokeReason'] = 'replaced';
        }
        unset($item);
        $record = [
            'id' => genId(),
            'userId' => $target['id'],
            'tokenHash' => $hash,
            'createdAt' => date('c', $now),
            'createdBy' => $actor['id'] ?? null,
            'expiresAt' => date('c', $expiresAt),
            'durationMinutes' => $durationMinutes,
            'consumedAt' => null,
            'revokedAt' => null,
        ];
        $items[] = $record;
        if(count($items) > 5000) {
            $threshold = strtotime('-180 days');
            $items = array_values(array_filter($items, static function(array $item) use ($threshold): bool {
                $created = strtotime((string)($item['createdAt'] ?? '')) ?: 0;
                $closed = !empty($item['consumedAt']) || !empty($item['revokedAt']);
                return !$closed || $created >= $threshold;
            }));
        }
        magestockRecoveryWriteLocked($handle, $items);
    } finally {
        flock($handle, LOCK_UN);
        fclose($handle);
    }

    magestockRecoveryAudit('token_created', [
        'actorUserId'=>$actor['id'] ?? null,
        'targetUserId'=>$target['id'],
        'durationMinutes'=>$durationMinutes,
        'result'=>'success',
    ]);

    return [
        'token' => $token,
        'expiresAt' => date('c', $expiresAt),
        'durationMinutes' => $durationMinutes,
        'user' => magestockRecoveryPublicUser($target),
    ];
}

function magestockRecoveryResetPassword(string $rawToken, string $newPassword): array
{
    $token = magestockRecoveryNormalizeToken($rawToken);
    if($token === '') {
        throw new MageStockRecoveryException('invalid_token', 'Le jeton est inconnu, expiré ou déjà utilisé.', 422);
    }
    $tokenHash = hash('sha256', $token);
    $tokenPath = dataPath('password_recovery_tokens');
    $tokenHandle = @fopen($tokenPath, 'c+');
    if(!$tokenHandle || !flock($tokenHandle, LOCK_EX)) {
        if(is_resource($tokenHandle)) fclose($tokenHandle);
        throw new MageStockRecoveryException('storage_unavailable', 'Le service de récupération est indisponible.', 500);
    }

    $matchedIndex = null;
    $targetUserId = null;
    $tokens = [];
    try {
        $tokens = magestockRecoveryReadLocked($tokenHandle);
        foreach($tokens as $index=>$item) {
            if(hash_equals((string)($item['tokenHash'] ?? ''), $tokenHash)) {
                $matchedIndex = $index;
                $targetUserId = $item['userId'] ?? null;
                break;
            }
        }
        if($matchedIndex === null) throw new MageStockRecoveryException('invalid_token', 'Le jeton est inconnu, expiré ou déjà utilisé.', 422);
        $record = $tokens[$matchedIndex];
        if(!empty($record['consumedAt'])) throw new MageStockRecoveryException('consumed_token', 'Le jeton est inconnu, expiré ou déjà utilisé.', 422);
        if(!empty($record['revokedAt'])) throw new MageStockRecoveryException('revoked_token', 'Le jeton est inconnu, expiré ou déjà utilisé.', 422);
        $expiresAt = strtotime((string)($record['expiresAt'] ?? '')) ?: 0;
        if($expiresAt <= time()) throw new MageStockRecoveryException('expired_token', 'Le jeton est inconnu, expiré ou déjà utilisé.', 422);

        $usersPath = dataPath('users');
        $usersHandle = @fopen($usersPath, 'c+');
        if(!$usersHandle || !flock($usersHandle, LOCK_EX)) {
            if(is_resource($usersHandle)) fclose($usersHandle);
            throw new MageStockRecoveryException('users_storage_unavailable', 'Le compte ne peut pas être mis à jour.', 500);
        }
        try {
            $users = magestockRecoveryReadLocked($usersHandle);
            $originalUsers = $users;
            $originalTokens = $tokens;
            $userIndex = null;
            foreach($users as $index=>$user) {
                if((string)($user['id'] ?? '') === (string)$targetUserId) {
                    $userIndex = $index;
                    break;
                }
            }
            if($userIndex === null) throw new MageStockRecoveryException('user_not_found', 'Le jeton est inconnu, expiré ou déjà utilisé.', 422);
            $user = $users[$userIndex];
            if(($user['authSource'] ?? 'local') === 'ldap') throw new MageStockRecoveryException('ldap_account', 'La réinitialisation doit être réalisée dans l’annuaire de référence.', 422);
            if(($user['active'] ?? true) === false) throw new MageStockRecoveryException('inactive_account', 'Le compte est désactivé. Contactez votre administrateur.', 422);
            $errors = magestockPasswordPolicyErrors($newPassword, $user);
            if(password_verify($newPassword, (string)($user['password'] ?? ''))) {
                $errors[] = 'Le nouveau mot de passe doit être différent du mot de passe actuel.';
            }
            if($errors) throw new MageStockRecoveryException('password_policy', implode(' ', $errors), 422);

            $users[$userIndex]['password'] = password_hash($newPassword, PASSWORD_DEFAULT);
            $users[$userIndex]['authVersion'] = magestockUserAuthVersion($user) + 1;
            $users[$userIndex]['passwordChangedAt'] = date('c');
            $users[$userIndex]['updatedAt'] = date('c');
            $users[$userIndex]['mustChangePassword'] = false;
            magestockRecoveryWriteLocked($usersHandle, $users);

            $tokens[$matchedIndex]['consumedAt'] = date('c');
            $tokens[$matchedIndex]['consumedByUserId'] = $targetUserId;
            try {
                magestockRecoveryWriteLocked($tokenHandle, $tokens);
            } catch(Throwable $writeError) {
                // Les deux magasins constituent une seule opération fonctionnelle.
                // Si la consommation du jeton ne peut pas être enregistrée, restaurer
                // le compte afin de ne jamais laisser un jeton réutilisable après reset.
                try {
                    magestockRecoveryWriteLocked($usersHandle, $originalUsers);
                    magestockRecoveryWriteLocked($tokenHandle, $originalTokens);
                } catch(Throwable $rollbackError) {
                    error_log('[MageStock Recovery Rollback] ' . $rollbackError->getMessage());
                }
                throw $writeError;
            }
            $updatedUser = $users[$userIndex];
        } finally {
            flock($usersHandle, LOCK_UN);
            fclose($usersHandle);
        }
    } catch(MageStockRecoveryException $e) {
        magestockRecoveryAudit('password_reset_failed', [
            'targetUserId'=>$targetUserId,
            'result'=>'failed',
            'reasonCode'=>$e->reasonCode,
            'source'=>'public_recovery',
        ]);
        throw $e;
    } finally {
        flock($tokenHandle, LOCK_UN);
        fclose($tokenHandle);
    }

    magestockRecoveryAudit('password_reset_completed', [
        'targetUserId'=>$targetUserId,
        'result'=>'success',
        'source'=>'public_recovery',
    ]);
    return magestockRecoveryPublicUser($updatedUser);
}
