import 'dart:convert'; import 'dart:io'; import 'dart:typed_data'; import 'package:flutter/foundation.dart' show kIsWeb; import 'package:http/http.dart' as http; import 'package:shared_preferences/shared_preferences.dart'; import '../models/user.dart'; import '../models/am_registration_data.dart'; import '../models/user_registration_data.dart'; import '../utils/parent_registration_payload.dart'; import 'api/api_config.dart'; import 'api/tokenService.dart'; import '../utils/nir_utils.dart'; class AuthService { static const String _currentUserKey = 'current_user'; static String? _basenameFromPath(String path) { final s = path.replaceAll(r'\', '/'); final i = s.lastIndexOf('/'); final name = i >= 0 ? s.substring(i + 1) : s; return name.isEmpty ? null : name; } static String _imageMimeForBytes(Uint8List bytes) { if (bytes.length >= 8 && bytes[0] == 0x89 && bytes[1] == 0x50 && bytes[2] == 0x4E && bytes[3] == 0x47) { return 'image/png'; } return 'image/jpeg'; } /// Connexion de l'utilisateur /// Retourne l'utilisateur connecté avec le flag changement_mdp_obligatoire static Future login(String email, String password) async { try { final response = await http.post( Uri.parse('${ApiConfig.baseUrl}${ApiConfig.login}'), headers: ApiConfig.headers, body: jsonEncode({ 'email': email, 'password': password, }), ); if (response.statusCode == 200 || response.statusCode == 201) { final data = jsonDecode(response.body); // API renvoie access_token / refresh_token (snake_case) final accessToken = data['access_token'] as String? ?? data['accessToken'] as String?; final refreshToken = data['refresh_token'] as String? ?? data['refreshToken'] as String?; if (accessToken == null) throw Exception('Token absent dans la réponse serveur'); await TokenService.saveToken(accessToken); await TokenService.saveRefreshToken(refreshToken ?? ''); final user = await _fetchUserProfile(accessToken); // Stocker l'utilisateur en cache await _saveCurrentUser(user); return user; } else { final error = jsonDecode(response.body); throw Exception(error['message'] ?? 'Erreur de connexion'); } } catch (e) { if (e is Exception) rethrow; throw Exception('Erreur réseau: impossible de se connecter au serveur'); } } /// Récupère le profil utilisateur via /auth/me static Future _fetchUserProfile(String token) async { try { final response = await http.get( Uri.parse('${ApiConfig.baseUrl}${ApiConfig.authMe}'), headers: ApiConfig.authHeaders(token), ); if (response.statusCode == 200) { final data = jsonDecode(response.body); return AppUser.fromJson(data); } else { throw Exception('Erreur lors de la récupération du profil'); } } catch (e) { if (e is Exception) rethrow; throw Exception('Erreur réseau: impossible de récupérer le profil'); } } /// Changement de mot de passe obligatoire static Future changePasswordRequired({ required String currentPassword, required String newPassword, }) async { final token = await TokenService.getToken(); if (token == null) { throw Exception('Non authentifié'); } try { final response = await http.post( Uri.parse('${ApiConfig.baseUrl}${ApiConfig.changePasswordRequired}'), headers: ApiConfig.authHeaders(token), body: jsonEncode({ 'mot_de_passe_actuel': currentPassword, 'nouveau_mot_de_passe': newPassword, 'confirmation_mot_de_passe': newPassword, }), ); if (response.statusCode != 200 && response.statusCode != 201) { final error = jsonDecode(response.body); throw Exception(error['message'] ?? 'Erreur lors du changement de mot de passe'); } // Après le changement de MDP, rafraîchir le profil utilisateur final user = await _fetchUserProfile(token); await _saveCurrentUser(user); } catch (e) { if (e is Exception) rethrow; throw Exception('Erreur réseau: impossible de changer le mot de passe'); } } /// Déconnexion de l'utilisateur static Future logout() async { await TokenService.clearAll(); final prefs = await SharedPreferences.getInstance(); await prefs.remove(_currentUserKey); } /// Vérifie si l'utilisateur est connecté static Future isLoggedIn() async { final token = await TokenService.getToken(); return token != null; } /// Récupère l'utilisateur connecté depuis le cache static Future getCurrentUser() async { final prefs = await SharedPreferences.getInstance(); final userJson = prefs.getString(_currentUserKey); if (userJson != null) { return AppUser.fromJson(jsonDecode(userJson)); } return null; } /// Sauvegarde l'utilisateur actuel en cache static Future _saveCurrentUser(AppUser user) async { final prefs = await SharedPreferences.getInstance(); await prefs.setString(_currentUserKey, jsonEncode(user.toJson())); } /// Inscription AM complète (POST /auth/register/am). /// En cas de succès (201), aucune donnée utilisateur retournée ; rediriger vers login. static Future registerAM(AmRegistrationData data) async { if (data.agreementDate == null) { throw Exception('La date d\'obtention de l\'agrément est requise.'); } if (data.placesAvailable == null) { throw Exception('Le nombre de places disponibles est requis.'); } final lieuVille = data.birthCity.trim(); final lieuPays = data.birthCountry.trim(); if (lieuVille.length < 2 || lieuPays.length < 2) { throw Exception( 'La ville et le pays de naissance sont requis (au moins 2 caractères chacun).', ); } String? photoBase64; String? photoFilename; if (data.photoBytes != null && data.photoBytes!.isNotEmpty) { final mime = _imageMimeForBytes(data.photoBytes!); photoBase64 = 'data:$mime;base64,${base64Encode(data.photoBytes!)}'; final fn = (data.photoFilename ?? '').trim(); photoFilename = fn.isNotEmpty ? fn : 'photo_am.jpg'; } else if (data.photoPath != null && data.photoPath!.isNotEmpty && !data.photoPath!.startsWith('assets/')) { if (!kIsWeb) { try { final file = File(data.photoPath!); if (await file.exists()) { final bytes = await file.readAsBytes(); final mime = _imageMimeForBytes(bytes); photoBase64 = 'data:$mime;base64,${base64Encode(bytes)}'; photoFilename = _basenameFromPath(data.photoPath!) ?? 'photo_am.jpg'; } } catch (_) {} } } if (photoBase64 == null || photoBase64.isEmpty) { throw Exception('Une photo de profil est requise pour finaliser l’inscription.'); } final body = { 'email': data.email, 'prenom': data.firstName, 'nom': data.lastName, 'telephone': data.phone, 'adresse': data.streetAddress.isNotEmpty ? data.streetAddress : null, 'code_postal': data.postalCode.isNotEmpty ? data.postalCode : null, 'ville': data.city.isNotEmpty ? data.city : null, 'photo_base64': photoBase64, 'photo_filename': photoFilename ?? 'photo_am.jpg', 'consentement_photo': data.photoConsent, 'date_naissance': data.dateOfBirth != null ? '${data.dateOfBirth!.year}-${data.dateOfBirth!.month.toString().padLeft(2, '0')}-${data.dateOfBirth!.day.toString().padLeft(2, '0')}' : null, 'lieu_naissance_ville': lieuVille, 'lieu_naissance_pays': lieuPays, 'nir': normalizeNir(data.nir), 'numero_agrement': data.agrementNumber, 'date_agrement': '${data.agreementDate!.year}-${data.agreementDate!.month.toString().padLeft(2, '0')}-${data.agreementDate!.day.toString().padLeft(2, '0')}', 'capacite_accueil': data.capacity ?? 1, 'places_disponibles': data.placesAvailable!, 'biographie': data.presentationText.isNotEmpty ? data.presentationText : null, 'acceptation_cgu': data.cguAccepted, 'acceptation_privacy': data.cguAccepted, }; final String encodedBody; try { encodedBody = jsonEncode(body); } catch (_) { throw Exception( 'Impossible de préparer l’envoi (données trop volumineuses ou invalides). ' 'Réessayez avec une photo plus légère.', ); } late final http.Response response; try { response = await http.post( Uri.parse('${ApiConfig.baseUrl}${ApiConfig.registerAM}'), headers: ApiConfig.headers, body: encodedBody, ); } on http.ClientException { throw Exception( 'Connexion à ${ApiConfig.baseUrl} impossible. Vérifiez votre réseau, ' 'un pare-feu ou un bloqueur, puis réessayez.', ); } if (response.statusCode == 200 || response.statusCode == 201) { return; } final decoded = _tryDecodeJsonMap(response.body); final message = _extractErrorMessage(decoded, response.statusCode); throw Exception(message); } /// Inscription parent complète (POST /auth/register/parent). /// Succès : 201, pas de session — rediriger vers le login. static Future registerParent(UserRegistrationData data) async { final validationError = ParentRegistrationPayload.validateForApi(data); if (validationError != null) { throw Exception(validationError); } final body = ParentRegistrationPayload.toJson(data); final String encodedBody; try { encodedBody = jsonEncode(body); } catch (_) { throw Exception( 'Impossible de préparer l’envoi (données trop volumineuses ou invalides). ' 'Réessayez avec une photo plus légère ou sans caractères inhabituels.', ); } late final http.Response response; try { response = await http.post( Uri.parse('${ApiConfig.baseUrl}${ApiConfig.registerParent}'), headers: ApiConfig.headers, body: encodedBody, ); } on http.ClientException { throw Exception( 'Connexion à ${ApiConfig.baseUrl} impossible. Vérifiez votre réseau, ' 'un pare-feu ou un bloqueur, puis réessayez.', ); } if (response.statusCode == 200 || response.statusCode == 201) { return; } final decoded = _tryDecodeJsonMap(response.body); var message = _extractErrorMessage(decoded, response.statusCode); message = _humanizeParentRegistrationHttpError(message, response.statusCode); throw Exception(message); } static Map? _tryDecodeJsonMap(String body) { if (body.isEmpty) return null; try { final decoded = jsonDecode(body); if (decoded is Map) { return Map.from(decoded); } return null; } catch (_) { return null; } } /// Aplatit `message` (string, liste, ou objet Nest / AllExceptionsFilter). static String? _flattenApiMessageField(dynamic field) { if (field == null) return null; if (field is String) { final t = field.trim(); return t.isEmpty ? null : t; } if (field is List) { final parts = field .map(_flattenApiMessageField) .whereType() .where((s) => s.isNotEmpty) .toList(); if (parts.isEmpty) return null; return parts.join('. '); } if (field is Map) { if (field['message'] != null) { final inner = _flattenApiMessageField(field['message']); if (inner != null) return inner; } final err = field['error']; if (err is String && err.trim().isNotEmpty) return err.trim(); } return null; } /// Extrait le message d'erreur des réponses NestJS (message string, array, ou objet). static String _extractErrorMessage(dynamic decoded, int statusCode) { const fallback = 'Erreur lors de l\'inscription'; if (decoded == null || decoded is! Map) { return '$fallback ($statusCode)'; } final map = decoded; final fromMessage = _flattenApiMessageField(map['message']); if (fromMessage != null) return fromMessage; final err = map['error']; if (err is String && err.trim().isNotEmpty) return err.trim(); return '$fallback ($statusCode)'; } /// Remplace « Internal server error » (souvent contrainte SQL / 500) par un texte utile à l’inscription. static String _humanizeParentRegistrationHttpError(String msg, int statusCode) { if (statusCode == 409) return msg; final lower = msg.toLowerCase().trim(); final looksLikeGenericServerError = lower == 'internal server error' || lower == 'internal server error.' || lower.contains('internal server error'); if (statusCode >= 500 && looksLikeGenericServerError) { return 'Impossible d\'enregistrer votre demande pour le moment. ' 'Souvent, cela signifie que cette adresse e-mail est déjà utilisée : ' 'connectez-vous ou utilisez une autre adresse pour le parent principal ' '(et pour le co-parent si vous en indiquez un). ' 'Si le problème continue, réessayez plus tard ou contactez le support.'; } if (statusCode >= 500) { if (lower.contains('duplicate') || lower.contains('unique constraint') || lower.contains('23505') || lower.contains('already exist')) { return 'Cette adresse e-mail semble déjà enregistrée. ' 'Essayez de vous connecter ou modifiez l\'adresse du parent ou du co-parent.'; } } return msg; } /// Rafraîchit le profil utilisateur depuis l'API static Future refreshCurrentUser() async { final token = await TokenService.getToken(); if (token == null) return null; try { final user = await _fetchUserProfile(token); await _saveCurrentUser(user); return user; } catch (e) { return null; } } }