// ignore_for_file: type_init_formals import 'package:cloud_firestore/cloud_firestore.dart'; import 'package:drift/drift.dart' show Variable; import 'package:firebase_core/firebase_core.dart'; import '../../database/database.dart'; import '../auth/auth_service.dart'; class CloudRideMetadataDocument { final String id; final Map data; const CloudRideMetadataDocument({required this.id, required this.data}); } abstract class CloudRideMetadataStore { Future> listRideDocuments(String uid); } class FirestoreCloudRideMetadataStore implements CloudRideMetadataStore { final FirebaseFirestore? _firestore; FirestoreCloudRideMetadataStore({FirebaseFirestore? firestore}) : _firestore = firestore ?? _resolveFirestore(); static FirebaseFirestore? _resolveFirestore() { try { if (Firebase.apps.isNotEmpty) return FirebaseFirestore.instance; } catch (_) {} return null; } @override Future> listRideDocuments(String uid) async { final firestore = _firestore; if (firestore == null) { throw StateError('Firebase services are unavailable or unconfigured.'); } final snapshot = await firestore .collection('users') .doc(uid) .collection('rides') .get(); return snapshot.docs .map( (document) => CloudRideMetadataDocument(id: document.id, data: document.data()), ) .toList(growable: false); } } class CloudReconciliationResult { final int discovered; final int added; final int unchanged; final int conflicts; final int skipped; const CloudReconciliationResult({ this.discovered = 0, this.added = 0, this.unchanged = 0, this.conflicts = 0, this.skipped = 0, }); CloudReconciliationResult operator +(CloudReconciliationResult other) { return CloudReconciliationResult( discovered: discovered + other.discovered, added: added + other.added, unchanged: unchanged + other.unchanged, conflicts: conflicts + other.conflicts, skipped: skipped + other.skipped, ); } int get existing => unchanged + conflicts; } /// Reconciles cloud ride metadata into the local Drift database without ever /// replacing an existing local ride. Telemetry restoration is intentionally a /// separate phase because R2 download/authentication is not available yet. class CloudReconciliationService { final AppDatabase _db; final AuthService _authService; final CloudRideMetadataStore _metadataStore; CloudReconciliationService({ required AppDatabase this._db, required AuthService this._authService, FirebaseFirestore? firestore, CloudRideMetadataStore? metadataStore, }) : _metadataStore = metadataStore ?? FirestoreCloudRideMetadataStore(firestore: firestore); Future reconcileMetadata() async { final user = _authService.currentUser; if (user == null || user.isAnonymous) { throw StateError('Authentication is required for cloud reconciliation.'); } final documents = await _metadataStore.listRideDocuments(user.uid); var result = CloudReconciliationResult(discovered: documents.length); for (final document in documents) { result += await _reconcileDocument(document, user.uid); } return result; } Future _reconcileDocument( CloudRideMetadataDocument document, String ownerId, ) async { final data = document.data; // Never trust a client-selected owner. Firestore rules are authoritative, // and this client-side guard prevents accidental cross-account materialization. final storedOwner = data['ownerId'] ?? data['uid']; if (storedOwner != null && storedOwner != ownerId) { return const CloudReconciliationResult(skipped: 1); } final cloudRideId = document.id; if (!_isValidCloudRideId(cloudRideId)) { return const CloudReconciliationResult(skipped: 1); } final localRows = await _db .customSelect( 'SELECT id, start_time, end_time, distance_miles, top_speed_mph, cloud_ride_id ' 'FROM rides WHERE cloud_ride_id = ?', variables: [Variable(cloudRideId)], ) .get(); if (localRows.isNotEmpty) { final local = localRows.first.data; if (_metadataDiffers(local, data)) { // Conservative conflict policy: do not overwrite local data. A future // revision-aware sync layer can resolve this explicitly using versions. return const CloudReconciliationResult(conflicts: 1); } return const CloudReconciliationResult(unchanged: 1); } final localRideId = _asInt(data['localRideId']); if (localRideId != null) { final legacyRows = await _db .customSelect( 'SELECT id, cloud_ride_id, start_time FROM rides WHERE id = ?', variables: [Variable(localRideId)], ) .get(); if (legacyRows.isNotEmpty) { final existingCloudId = legacyRows.first.data['cloud_ride_id']; final startTime = _readDateTime(data['startTime'] ?? data['startedAt']); final localStartTime = _readDateTime(legacyRows.first.data['start_time']); final isSameRide = (existingCloudId == null || existingCloudId.toString().isEmpty || existingCloudId == cloudRideId) && startTime != null && localStartTime != null && (localStartTime.difference(startTime).inSeconds).abs() <= 5; if (isSameRide) { await _db.customStatement( 'UPDATE rides SET cloud_ride_id = ?, cloud_owner_uid = COALESCE(cloud_owner_uid, ?) WHERE id = ?', [cloudRideId, ownerId, localRideId], ); return const CloudReconciliationResult(unchanged: 1); } // If not the same ride, this is simply a device local-id collision. // Fall through directly to insert the cloud ride. } } final startTime = _readDateTime(data['startTime'] ?? data['startedAt']); if (startTime == null) { return const CloudReconciliationResult(skipped: 1); } final endTime = _readDateTime(data['endTime'] ?? data['endedAt']); await _insertRestoredMetadata( cloudRideId: cloudRideId, ownerId: ownerId, startTime: startTime, endTime: endTime, data: data, ); return const CloudReconciliationResult(added: 1); } Future _insertRestoredMetadata({ required String cloudRideId, required String ownerId, required DateTime startTime, required DateTime? endTime, required Map data, }) async { await _db.customStatement( ''' INSERT INTO rides ( cloud_ride_id, cloud_owner_uid, start_time, end_time, distance_miles, top_speed_mph, max_lean_left, max_lean_right, max_accel_g, max_braking_g, best0to60_ms, best0to100_ms, distance, average_speed, top_speed, moving_time, stopped_time, elevation_gain, elevation_loss, motorcycle_id, motorcycle_year, motorcycle_make, motorcycle_model, motorcycle_variant ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) ''', [ cloudRideId, ownerId, startTime.toUtc().millisecondsSinceEpoch, endTime?.toUtc().millisecondsSinceEpoch, _asDouble(data['distanceMiles']) ?? 0.0, _asDouble(data['topSpeedMph']) ?? 0.0, _asDouble(data['maxLeanLeft']) ?? 0.0, _asDouble(data['maxLeanRight']) ?? 0.0, _asDouble(data['maxAccelG']) ?? 0.0, _asDouble(data['maxBrakingG']) ?? 0.0, _asDouble(data['best0to60Ms'] ?? data['best0to60_ms']), _asDouble(data['best0to100Ms'] ?? data['best0to100_ms']), _asDouble(data['distanceMiles']) ?? 0.0, _asDouble(data['averageSpeed'] ?? data['averageSpeedMph']) ?? 0.0, _asDouble(data['topSpeedMph']) ?? 0.0, _asInt(data['movingTime']) ?? 0, _asInt(data['stoppedTime']) ?? 0, _asDouble(data['elevationGain']) ?? 0.0, _asDouble(data['elevationLoss']) ?? 0.0, _asInt(data['motorcycleId']), _asInt(data['motorcycleYear']), data['motorcycleMake']?.toString(), data['motorcycleModel']?.toString(), data['motorcycleVariant']?.toString(), ], ); } bool _metadataDiffers( Map local, Map cloud, ) { final localDistance = _asDouble(local['distance_miles']); final cloudDistance = _asDouble(cloud['distanceMiles']); final localTopSpeed = _asDouble(local['top_speed_mph']); final cloudTopSpeed = _asDouble(cloud['topSpeedMph']); // Only flag meaningful numeric divergence. Missing cloud fields are not // treated as authoritative and therefore cannot overwrite local values. if (cloudDistance != null && localDistance != null && (localDistance - cloudDistance).abs() > 0.0001) { return true; } if (cloudTopSpeed != null && localTopSpeed != null && (localTopSpeed - cloudTopSpeed).abs() > 0.0001) { return true; } return false; } bool _isValidCloudRideId(String id) => RegExp(r'^ride_[A-Za-z0-9_-]+$').hasMatch(id); int? _asInt(Object? value) => value is num ? value.toInt() : int.tryParse(value?.toString() ?? ''); double? _asDouble(Object? value) => value is num ? value.toDouble() : double.tryParse(value?.toString() ?? ''); DateTime? _readDateTime(Object? value) { if (value is Timestamp) return value.toDate().toUtc(); if (value is DateTime) return value.toUtc(); if (value is num) { return DateTime.fromMillisecondsSinceEpoch(value.toInt(), isUtc: true); } final parsed = DateTime.tryParse(value?.toString() ?? ''); return parsed?.toUtc(); } }