import 'package:cloud_firestore/cloud_firestore.dart'; import 'package:drift/drift.dart' show Variable; import '../../database/database.dart'; import '../auth/auth_service.dart'; class CloudReconciliationResult { final int added; final int unchanged; final int conflicts; final int skipped; const CloudReconciliationResult({ this.added = 0, this.unchanged = 0, this.conflicts = 0, this.skipped = 0, }); CloudReconciliationResult operator +(CloudReconciliationResult other) { return CloudReconciliationResult( added: added + other.added, unchanged: unchanged + other.unchanged, conflicts: conflicts + other.conflicts, skipped: skipped + other.skipped, ); } } /// 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 FirebaseFirestore _firestore; CloudReconciliationService({ required AppDatabase this._db, required AuthService this._authService, FirebaseFirestore? firestore, }) : _firestore = firestore ?? FirebaseFirestore.instance; Future reconcileMetadata() async { final user = _authService.currentUser; if (user == null || user.isAnonymous) { throw StateError('Authentication is required for cloud reconciliation.'); } final snapshot = await _firestore .collection('users') .doc(user.uid) .collection('rides') .get(); var result = const CloudReconciliationResult(); for (final document in snapshot.docs) { result += await _reconcileDocument(document, user.uid); } return result; } Future _reconcileDocument( DocumentSnapshot> document, String ownerId, ) async { final data = document.data(); if (data == null) { return const CloudReconciliationResult(skipped: 1); } // 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 FROM rides WHERE id = ?', variables: [Variable(localRideId)], ).get(); if (legacyRows.isNotEmpty) { final existingCloudId = legacyRows.first.data['cloud_ride_id']; if (existingCloudId == null || existingCloudId.toString().isEmpty) { await _db.customStatement( 'UPDATE rides SET cloud_ride_id = ? WHERE id = ?', [cloudRideId, localRideId], ); return const CloudReconciliationResult(unchanged: 1); } if (existingCloudId == cloudRideId) { return const CloudReconciliationResult(unchanged: 1); } return const CloudReconciliationResult(conflicts: 1); } } 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, startTime: startTime, endTime: endTime, data: data, ); return const CloudReconciliationResult(added: 1); } Future _insertRestoredMetadata({ required String cloudRideId, required DateTime startTime, required DateTime? endTime, required Map data, }) async { await _db.customStatement(''' INSERT INTO rides ( cloud_ride_id, start_time, end_time, distance_miles, top_speed_mph, max_lean_left, max_lean_right, max_accel_g, max_braking_g, distance, average_speed, top_speed, elevation_gain, elevation_loss, motorcycle_id, motorcycle_year, motorcycle_make, motorcycle_model, motorcycle_variant ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) ''', [ cloudRideId, startTime.toUtc().millisecondsSinceEpoch, endTime?.toUtc().millisecondsSinceEpoch, _asDouble(data['distanceMiles']), _asDouble(data['topSpeedMph']), _asDouble(data['maxLeanLeft']), _asDouble(data['maxLeanRight']), _asDouble(data['maxAccelG']), _asDouble(data['maxBrakingG']), _asDouble(data['distanceMiles']), _asDouble(data['averageSpeed'] ?? data['averageSpeedMph']), _asDouble(data['topSpeedMph']), _asDouble(data['elevationGain']), _asDouble(data['elevationLoss']), _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(); } }