<?php
declare(strict_types=1);

namespace App\Security;

use App\Core\Database;
use App\Models\User;
use App\Services\SecurityEventMonitor;
use RuntimeException;

final class MfaService
{
    private const PERIOD = 30;
    private const DIGITS = 6;
    private const WINDOW = 1;

    public static function isEnabled(int $userId): bool
    {
        $stmt = Database::connection()->prepare('SELECT enabled_at FROM cda_user_mfa WHERE utilisateur_id=:id LIMIT 1');
        $stmt->execute(['id' => $userId]);
        return (bool)$stmt->fetchColumn();
    }

    public static function requiresEnrollment(int $userId): bool
    {
        return User::hasAnyRole($userId, 'super_admin', 'superadmin', 'administration', 'admin', 'direction');
    }

    /** @return array{secret:string,uri:string} */
    public static function beginEnrollment(int $userId): array
    {
        $secret = self::base32Encode(random_bytes(20));
        [$nonce, $cipher] = self::encrypt($secret);
        Database::connection()->prepare(
            "INSERT INTO cda_user_mfa(utilisateur_id,secret_nonce,secret_ciphertext,enabled_at,backup_codes_json,failed_attempts,locked_until,created_at,updated_at)
             VALUES(:id,:nonce,:cipher,NULL,NULL,0,NULL,NOW(),NOW())
             ON DUPLICATE KEY UPDATE secret_nonce=VALUES(secret_nonce),secret_ciphertext=VALUES(secret_ciphertext),enabled_at=NULL,backup_codes_json=NULL,failed_attempts=0,locked_until=NULL,updated_at=NOW()"
        )->execute(['id'=>$userId,'nonce'=>base64_encode($nonce),'cipher'=>base64_encode($cipher)]);
        $user = User::find($userId) ?? [];
        $label = rawurlencode('AXIOM CDA:' . (string)($user['email'] ?? ('user-' . $userId)));
        $issuer = rawurlencode('AXIOM CDA');
        return ['secret'=>$secret,'uri'=>'otpauth://totp/'.$label.'?secret='.$secret.'&issuer='.$issuer.'&algorithm=SHA1&digits=6&period=30'];
    }

    /** @return list<string> */
    public static function confirmEnrollment(int $userId, string $code): array
    {
        if (!self::verifyTotp($userId, $code, true)) throw new RuntimeException('Code MFA invalide. Vérifiez l’heure de votre téléphone.');
        $plain = [];
        $hashes = [];
        for ($i=0; $i<8; $i++) {
            $codeValue = strtoupper(substr(bin2hex(random_bytes(5)), 0, 10));
            $plain[] = substr($codeValue,0,5).'-'.substr($codeValue,5);
            $hashes[] = password_hash(str_replace('-','',$plain[$i]), PASSWORD_DEFAULT);
        }
        Database::connection()->prepare(
            'UPDATE cda_user_mfa SET enabled_at=NOW(),backup_codes_json=:codes,failed_attempts=0,locked_until=NULL,updated_at=NOW() WHERE utilisateur_id=:id'
        )->execute(['id'=>$userId,'codes'=>json_encode($hashes, JSON_UNESCAPED_SLASHES)]);
        SecurityEventMonitor::record('mfa.enrolled', 'info', ['user_ref'=>self::userRef($userId)]);
        return $plain;
    }

    public static function verify(int $userId, string $code): bool
    {
        $normalized = strtoupper(str_replace([' ', '-'], '', trim($code)));
        if (preg_match('/^\d{6}$/', $normalized)) return self::verifyTotp($userId, $normalized, false);
        if (preg_match('/^[A-F0-9]{10}$/', $normalized)) return self::verifyBackupCode($userId, $normalized);
        self::recordFailure($userId);
        return false;
    }

    public static function disable(int $userId): void
    {
        Database::connection()->prepare('DELETE FROM cda_user_mfa WHERE utilisateur_id=:id')->execute(['id'=>$userId]);
        Database::connection()->prepare('UPDATE utilisateurs SET security_version=security_version+1 WHERE id=:id')->execute(['id'=>$userId]);
        SecurityEventMonitor::record('mfa.disabled', 'warning', ['user_ref'=>self::userRef($userId)]);
    }

    /** @return array{enabled:bool,enabled_at:?string,backup_codes_remaining:int,locked_until:?string} */
    public static function status(int $userId): array
    {
        $stmt = Database::connection()->prepare('SELECT enabled_at,backup_codes_json,locked_until FROM cda_user_mfa WHERE utilisateur_id=:id LIMIT 1');
        $stmt->execute(['id'=>$userId]);
        $row = $stmt->fetch() ?: [];
        $codes = json_decode((string)($row['backup_codes_json'] ?? '[]'), true);
        return ['enabled'=>!empty($row['enabled_at']),'enabled_at'=>$row['enabled_at'] ?? null,'backup_codes_remaining'=>is_array($codes)?count($codes):0,'locked_until'=>$row['locked_until'] ?? null];
    }

    private static function verifyTotp(int $userId, string $code, bool $allowPending): bool
    {
        $row = self::row($userId);
        if (!$row || (!$allowPending && empty($row['enabled_at'])) || self::locked($row)) return false;
        $secret = self::decrypt((string)$row['secret_nonce'], (string)$row['secret_ciphertext']);
        $counter = intdiv(time(), self::PERIOD);
        for ($offset=-self::WINDOW; $offset<=self::WINDOW; $offset++) {
            if (hash_equals(self::totp($secret, $counter+$offset), $code)) {
                self::recordSuccess($userId);
                return true;
            }
        }
        self::recordFailure($userId);
        return false;
    }

    private static function verifyBackupCode(int $userId, string $code): bool
    {
        $row = self::row($userId);
        if (!$row || empty($row['enabled_at']) || self::locked($row)) return false;
        $hashes = json_decode((string)($row['backup_codes_json'] ?? '[]'), true);
        if (!is_array($hashes)) $hashes=[];
        foreach ($hashes as $i=>$hash) {
            if (is_string($hash) && password_verify($code, $hash)) {
                unset($hashes[$i]);
                Database::connection()->prepare('UPDATE cda_user_mfa SET backup_codes_json=:codes,failed_attempts=0,locked_until=NULL,updated_at=NOW() WHERE utilisateur_id=:id')
                    ->execute(['id'=>$userId,'codes'=>json_encode(array_values($hashes),JSON_UNESCAPED_SLASHES)]);
                SecurityEventMonitor::record('mfa.backup_code_used', 'warning', ['user_ref'=>self::userRef($userId)]);
                return true;
            }
        }
        self::recordFailure($userId);
        return false;
    }

    private static function row(int $userId): ?array
    {
        $stmt=Database::connection()->prepare('SELECT * FROM cda_user_mfa WHERE utilisateur_id=:id LIMIT 1');
        $stmt->execute(['id'=>$userId]);
        return $stmt->fetch() ?: null;
    }

    private static function locked(array $row): bool
    {
        return !empty($row['locked_until']) && strtotime((string)$row['locked_until']) > time();
    }

    private static function recordFailure(int $userId): void
    {
        Database::connection()->prepare(
            "UPDATE cda_user_mfa SET failed_attempts=failed_attempts+1,
             locked_until=CASE WHEN failed_attempts+1>=8 THEN DATE_ADD(NOW(),INTERVAL 15 MINUTE) ELSE locked_until END,
             updated_at=NOW() WHERE utilisateur_id=:id"
        )->execute(['id'=>$userId]);
        SecurityEventMonitor::record('mfa.failed', 'warning', ['user_ref'=>self::userRef($userId)]);
    }

    private static function recordSuccess(int $userId): void
    {
        Database::connection()->prepare('UPDATE cda_user_mfa SET failed_attempts=0,locked_until=NULL,last_verified_at=NOW(),updated_at=NOW() WHERE utilisateur_id=:id')->execute(['id'=>$userId]);
    }

    private static function totp(string $secret, int $counter): string
    {
        $key=self::base32Decode($secret);
        $binary=pack('N2', intdiv($counter, 0x100000000), $counter & 0xffffffff);
        $hash=hash_hmac('sha1',$binary,$key,true);
        $offset=ord($hash[19]) & 0x0f;
        $value=((ord($hash[$offset]) & 0x7f)<<24)|((ord($hash[$offset+1]) & 0xff)<<16)|((ord($hash[$offset+2]) & 0xff)<<8)|(ord($hash[$offset+3]) & 0xff);
        return str_pad((string)($value % (10 ** self::DIGITS)), self::DIGITS, '0', STR_PAD_LEFT);
    }

    private static function encrypt(string $plain): array
    {
        $nonce=random_bytes(SODIUM_CRYPTO_SECRETBOX_NONCEBYTES);
        $key=(new CryptoKeyring())->current('mfa-totp');
        return [$nonce,sodium_crypto_secretbox($plain,$nonce,$key)];
    }

    private static function decrypt(string $nonce64,string $cipher64): string
    {
        $nonce=base64_decode($nonce64,true); $cipher=base64_decode($cipher64,true);
        if ($nonce===false || $cipher===false) throw new RuntimeException('Secret MFA corrompu.');
        foreach ((new CryptoKeyring())->candidates('mfa-totp') as $candidate) {
            $plain=sodium_crypto_secretbox_open($cipher,$nonce,$candidate['key']);
            if ($plain!==false) return $plain;
        }
        throw new RuntimeException('Secret MFA indéchiffrable.');
    }

    private static function base32Encode(string $data): string
    {
        $alphabet='ABCDEFGHIJKLMNOPQRSTUVWXYZ234567'; $bits=''; $out='';
        foreach (str_split($data) as $char) $bits.=str_pad(decbin(ord($char)),8,'0',STR_PAD_LEFT);
        foreach (str_split($bits,5) as $chunk) { if(strlen($chunk)<5)$chunk=str_pad($chunk,5,'0'); $out.=$alphabet[bindec($chunk)]; }
        return $out;
    }

    private static function base32Decode(string $value): string
    {
        $alphabet='ABCDEFGHIJKLMNOPQRSTUVWXYZ234567'; $bits=''; $out='';
        foreach (str_split(strtoupper($value)) as $char) { $p=strpos($alphabet,$char); if($p===false)continue; $bits.=str_pad(decbin($p),5,'0',STR_PAD_LEFT); }
        foreach (str_split($bits,8) as $chunk) if(strlen($chunk)===8)$out.=chr(bindec($chunk));
        return $out;
    }

    private static function userRef(int $id): string { return substr(hash('sha256','axiom-mfa:'.$id),0,20); }
}
