import 'dart:convert'; import 'package:cloud_firestore/cloud_firestore.dart'; import 'package:firebase_core/firebase_core.dart'; import 'package:flutter/foundation.dart'; import 'package:http/http.dart' as http; import '../auth/auth_service.dart'; import '../sync/r2_upload_service.dart'; abstract class CloudAccountCleanup { Future deleteCloudData(); } class AccountCloudCleanupService implements CloudAccountCleanup { final AuthService _auth; final FirebaseFirestore? _firestore; final http.Client _client; final String _workerUrl; AccountCloudCleanupService({ required this._auth, FirebaseFirestore? firestore, http.Client? client, String? workerUrl, }) : _firestore = firestore ?? _resolveFirestore(), _client = client ?? http.Client(), _workerUrl = (workerUrl ?? R2UploadService.defaultWorkerUrl).replaceAll(RegExp(r'/+$'), ''); static FirebaseFirestore? _resolveFirestore() { try { if (Firebase.apps.isNotEmpty) return FirebaseFirestore.instance; } catch (_) {} return null; } @override Future deleteCloudData() async { final user = _auth.currentUser; if (user == null || user.isAnonymous) throw StateError('A permanent account is required.'); final token = await _auth.getIdToken(true); if (token == null || token.isEmpty) throw StateError('A valid account session is required.'); final endpoint = Uri.tryParse('$_workerUrl/account/cloud-data'); if (endpoint == null || !endpoint.hasScheme || (endpoint.scheme != 'http' && endpoint.scheme != 'https')) { throw StateError('MotorCloud cleanup endpoint is unavailable.'); } final response = await _client.delete( endpoint, headers: {'Authorization': 'Bearer ${token.trim()}'}, ).timeout(const Duration(seconds: 30)); if (response.statusCode < 200 || response.statusCode >= 300) { final detail = _safeResponseDetail(response.body); debugPrint( '[DELETE_FLOW] cloud cleanup response status=${response.statusCode} ' 'body=$detail', ); throw StateError( 'MotorCloud cleanup was not completed (${response.statusCode}).', ); } if (response.statusCode != 204 && !_isSuccessfulWorkerResponse(response.body)) { final detail = _safeResponseDetail(response.body); debugPrint('[DELETE_FLOW] cloud cleanup response malformed body=$detail'); throw StateError('MotorCloud cleanup returned an invalid response.'); } final firestore = _firestore; if (firestore == null) throw StateError('MotorCloud database is unavailable.'); final userRef = firestore.collection('users').doc(user.uid); final rides = await userRef.collection('rides').get(); for (final doc in rides.docs) { await _deleteCollection(doc.reference.collection('events')); await _deleteCollection(doc.reference.collection('performance')); } await _deleteCollection(userRef.collection('rides')); await _deleteCollection(userRef.collection('motorcycles')); await _deleteCollection(userRef.collection('trips')); // Clean up reciprocal relationships on other users before deleting own references final friendsSnap = await userRef.collection('friends').get(); for (final friendDoc in friendsSnap.docs) { final friendUid = friendDoc.id; final reciprocalFriendRef = firestore .collection('users') .doc(friendUid) .collection('friends') .doc(user.uid); await reciprocalFriendRef.delete().catchError((_) {}); } final incomingRequestsSnap = await userRef.collection('friend_requests_incoming').get(); for (final incDoc in incomingRequestsSnap.docs) { final senderUid = incDoc.id; final reciprocalOutgoingRef = firestore .collection('users') .doc(senderUid) .collection('friend_requests_outgoing') .doc(user.uid); await reciprocalOutgoingRef.delete().catchError((_) {}); } final outgoingRequestsSnap = await userRef.collection('friend_requests_outgoing').get(); for (final outDoc in outgoingRequestsSnap.docs) { final recipientUid = outDoc.id; final reciprocalIncomingRef = firestore .collection('users') .doc(recipientUid) .collection('friend_requests_incoming') .doc(user.uid); await reciprocalIncomingRef.delete().catchError((_) {}); } await _deleteCollection(userRef.collection('events')); await _deleteCollection(userRef.collection('friends')); await _deleteCollection(userRef.collection('friend_requests_incoming')); await _deleteCollection(userRef.collection('friend_requests_outgoing')); await _deleteCollection(userRef.collection('blocks')); // Release handle reservation if exists final userSnap = await userRef.get(); final handle = userSnap.data()?['username'] as String?; if (handle != null && handle.isNotEmpty) { await firestore.collection('handles').doc(handle.toLowerCase()).delete(); } // Automatically cancel active rides organized by deleted account try { final organizedRides = await firestore .collection('group_rides') .where('organizerUid', isEqualTo: user.uid) .get(); for (final rDoc in organizedRides.docs) { final status = rDoc.data()['status'] as String?; if (status != 'canceled' && status != 'completed') { await rDoc.reference.update({ 'status': 'canceled', 'canceledReason': 'Organizer account deleted.', 'canceledAt': DateTime.now().toIso8601String(), 'updatedAt': DateTime.now().toIso8601String(), }).catchError((_) {}); } } } catch (_) {} // Delete public profile projection await firestore.collection('public_profiles').doc(user.uid).delete().catchError((_) {}); // Delete the profile last so retries can still discover the account scope. final profileBatch = firestore.batch()..delete(userRef); await profileBatch.commit(); } Future _deleteCollection(CollectionReference> collection) async { final snapshot = await collection.get(); for (var offset = 0; offset < snapshot.docs.length; offset += 450) { final batch = _firestore!.batch(); final end = (offset + 450).clamp(0, snapshot.docs.length); for (final doc in snapshot.docs.sublist(offset, end)) { batch.delete(doc.reference); } await batch.commit(); } } String _safeResponseDetail(String body) { final sanitized = body .replaceAll(RegExp(r'Bearer\s+\S+', caseSensitive: false), 'Bearer [REDACTED]') .replaceAll(RegExp(r'"(?:token|password|secret|pat)"\s*:\s*"[^"]*"', caseSensitive: false), '"credential":"[REDACTED]"'); final compact = sanitized.replaceAll(RegExp(r'\s+'), ' ').trim(); if (compact.isEmpty) return '[empty]'; return compact.length > 240 ? compact.substring(0, 240) : compact; } bool _isSuccessfulWorkerResponse(String body) { try { final decoded = jsonDecode(body); return decoded is Map && decoded['success'] == true && decoded['deletedObjects'] is int && (decoded['deletedObjects'] as int) >= 0; } on FormatException { return false; } } }