import 'package:cloud_firestore/cloud_firestore.dart'; import 'package:crypto/crypto.dart'; import 'package:firebase_core/firebase_core.dart'; import '../../core/version/app_identity.dart'; import '../../database/database.dart'; import '../auth/auth_service.dart'; import 'r2_upload_service.dart'; import 'sync_bundle_worker.dart'; import 'sync_models.dart'; import 'sync_provider.dart'; class FirebaseSyncProvider implements SyncProvider { final AppDatabase _db; final AuthService _authService; final FirebaseFirestore? _firestore; final R2UploadService _r2UploadService; final SyncBundleWorker _bundleWorker; FirebaseSyncProvider({required AppDatabase db, required AuthService this._authService, FirebaseFirestore? firestore, R2UploadService? r2UploadService, SyncBundleWorker? bundleWorker}) : _db = db, _firestore = firestore ?? _resolveFirestore(), _r2UploadService = r2UploadService ?? R2UploadService(), _bundleWorker = bundleWorker ?? SyncBundleWorker(db); static FirebaseFirestore? _resolveFirestore() { try { if (Firebase.apps.isNotEmpty) return FirebaseFirestore.instance; } catch (_) {} return null; } @override String get providerId => 'firebase'; @override Future isAvailable() async { try { return Firebase.apps.isNotEmpty && _firestore != null; } catch (_) { return false; } } @override Future requiresAuthentication() async { final user = _authService.currentUser; return user == null || user.isAnonymous; } @override Future uploadRide(SyncItem item) async { if (!await isAvailable()) return SyncResult.temporaryFailure('Firebase services are unavailable or unconfigured. Ride remains safely stored locally.'); final user = _authService.currentUser; if (user == null || user.isAnonymous) return SyncResult.authRequired(message: 'Sign in to MotorCloud is required for cloud backup.'); // Refresh the Firebase ID token before a manual upload. This prevents a // recently completed sign-in from being rejected because the cached token // has not yet propagated through the app's auth abstraction. var idToken = await _authService.getIdToken(); if (idToken == null || idToken.isEmpty) { idToken = await _authService.getIdToken(true); } if (idToken == null || idToken.isEmpty) return SyncResult.authRequired(message: 'Valid Firebase authentication token required for cloud sync.'); try { final ride = await (_db.select(_db.rides)..where((r) => r.id.equals(item.localRideId))).getSingleOrNull(); if (ride == null) return SyncResult.permanentFailure('Local ride #${item.localRideId} not found in database.'); // The cloud identity is generated once and then reused forever. Never // derive the remote identity from the local auto-increment database ID. final remoteRideId = await _db.ensureCloudRideId(item.localRideId); final appIdentity = AppIdentity.current(); final zipBytes = await _bundleWorker.generateCompressedBundle(item.localRideId); if (zipBytes == null || zipBytes.isEmpty) return SyncResult.permanentFailure('Failed generating compressed telemetry bundle for ride #${item.localRideId}.'); final sha256Hash = sha256.convert(zipBytes).toString(); var r2Result = await _r2UploadService.uploadTelemetryBundle( idToken: idToken, remoteRideId: remoteRideId, zipBytes: zipBytes, sha256Hash: sha256Hash, appVersion: appIdentity.semanticVersion, ); // If the gateway rejects the first token as unauthorized, refresh once // and retry. This specifically handles token rotation/expiry without // turning a valid signed-in session into a false authentication error. if (!r2Result.success && r2Result.requiresAuth) { final refreshedToken = await _authService.getIdToken(true); if (refreshedToken != null && refreshedToken.isNotEmpty && refreshedToken != idToken) { idToken = refreshedToken; r2Result = await _r2UploadService.uploadTelemetryBundle( idToken: idToken, remoteRideId: remoteRideId, zipBytes: zipBytes, sha256Hash: sha256Hash, appVersion: appIdentity.semanticVersion, ); } } if (!r2Result.success) { if (r2Result.requiresAuth) return SyncResult.authRequired(message: r2Result.errorMessage ?? 'R2 Gateway authentication required'); if (r2Result.isRetryable) return SyncResult.temporaryFailure(r2Result.errorMessage ?? 'Temporary R2 upload error'); return SyncResult.permanentFailure(r2Result.errorMessage ?? 'Permanent R2 upload rejection'); } final objectKey = r2Result.objectKey ?? 'users/${user.uid}/rides/$remoteRideId/telemetry.zip'; final events = await (_db.select(_db.rideEvents)..where((e) => e.rideId.equals(item.localRideId))).get(); final performanceRuns = await (_db.select(_db.performanceRuns)..where((p) => p.rideId.equals(item.localRideId))).get(); final summary = await (_db.select(_db.fieldTestSummaries)..where((s) => s.rideId.equals(item.localRideId))).getSingleOrNull(); final firestorePayload = { 'id': remoteRideId, 'ownerId': user.uid, 'localRideId': ride.id, 'startTime': ride.startTime.toUtc().toIso8601String(), 'endTime': ride.endTime?.toUtc().toIso8601String(), 'distanceMiles': ride.distanceMiles, 'topSpeedMph': ride.topSpeedMph, 'maxLeanLeft': ride.maxLeanLeft, 'maxLeanRight': ride.maxLeanRight, 'maxAccelG': ride.maxAccelG, 'maxBrakingG': ride.maxBrakingG, 'averageSpeed': ride.averageSpeed, 'movingTime': ride.movingTime, 'stoppedTime': ride.stoppedTime, 'elevationGain': ride.elevationGain, 'elevationLoss': ride.elevationLoss, 'qualityScore': summary?.rideQualityScore ?? 100, 'qualityRating': summary?.rideQualityRating ?? 'EXCELLENT', 'motorcycleId': ride.motorcycleId, 'motorcycleMake': ride.motorcycleMake, 'motorcycleModel': ride.motorcycleModel, 'motorcycleYear': ride.motorcycleYear, 'motorcycleVariant': ride.motorcycleVariant, 'hardBrakingEvents': events.where((e) => e.eventType.contains('Braking')).length, 'extremeLeanEvents': events.where((e) => e.eventType.contains('Lean') || e.eventType.contains('Corner')).length, 'highSpeedEvents': events.where((e) => e.eventType.contains('Speed')).length, 'totalEventsCount': events.length, 'performanceRuns': performanceRuns.map((r) => {'type': r.type, 'timeMs': r.timeMs, 'topSpeedReached': r.topSpeedReached, 'timestamp': r.timestamp.toUtc().toIso8601String()}).toList(), 'telemetryBundle': {'provider': 'cloudflare-r2', 'objectKey': objectKey, 'status': 'uploaded', 'bundleSizeBytes': zipBytes.length, 'sha256': sha256Hash}, 'telemetryBundlePath': objectKey, 'bundleSizeBytes': zipBytes.length, 'syncVersion': 3, 'uploadedAt': FieldValue.serverTimestamp(), 'updatedAt': FieldValue.serverTimestamp(), 'software': appIdentity.toJson(), }; await _firestore!.collection('users').doc(user.uid).collection('rides').doc(remoteRideId).set(firestorePayload, SetOptions(merge: true)); return SyncResult.success(remoteId: remoteRideId); } on FirebaseException catch (e) { if (e.code == 'permission-denied' || e.code == 'unauthenticated') return SyncResult.authRequired(message: 'Firebase authorization error: ${e.message}'); if (e.code == 'unavailable' || e.code == 'network-request-failed' || e.code == 'retry-limit-exceeded') return SyncResult.temporaryFailure('Network or Firebase service temporary error (${e.code}): ${e.message}'); return SyncResult.permanentFailure('Firebase sync error (${e.code}): ${e.message}'); } catch (e) { return SyncResult.temporaryFailure('Unexpected sync exception: $e'); } } }