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')); 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(); } // 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; } } }