import 'dart:convert'; import 'dart:math' as math; import 'package:cloud_firestore/cloud_firestore.dart'; import 'package:drift/drift.dart'; import 'package:firebase_core/firebase_core.dart'; import '../../database/database.dart'; import '../../models/maintenance_models.dart'; import '../auth/auth_service.dart'; import 'cloud_models.dart'; /// Synchronizes account/profile and motorcycle metadata independently of the /// ride upload pipeline. This keeps identity data small and queryable while /// leaving high-frequency telemetry in the existing ride/R2 path. class FirebaseAccountSyncService { final AppDatabase db; final AuthService auth; final FirebaseFirestore? _firestore; final bool _customFirestore; FirebaseAccountSyncService({ required this.db, required this.auth, FirebaseFirestore? firestore, }) : _customFirestore = firestore != null, _firestore = firestore ?? _resolveFirestore(); static FirebaseFirestore? _resolveFirestore() { try { if (Firebase.apps.isNotEmpty) return FirebaseFirestore.instance; } catch (_) {} return null; } bool get isAvailable => _firestore != null && (_customFirestore || Firebase.apps.isNotEmpty); /// Extracts the stored cloudId from a motorcycle's specificationJson metadata. static String? extractCloudId(String? specJson) { if (specJson == null || specJson.isEmpty) return null; try { final decoded = json.decode(specJson); if (decoded is Map) { final id = decoded['cloudId']?.toString(); if (id != null && id.isNotEmpty) return id; } } catch (_) {} return null; } /// Injects or updates cloudId inside specificationJson without altering other specs. static String attachCloudId(String? specJson, String cloudId) { Map map = {}; if (specJson != null && specJson.isNotEmpty) { try { final decoded = json.decode(specJson); if (decoded is Map) { map = Map.from(decoded); } } catch (_) {} } map['cloudId'] = cloudId; return json.encode(map); } /// Merges local and remote specificationJson, preserving maintenance records /// from both devices (deduplicated by record id), retaining the highest odometer, /// and attaching the authoritative cloudId. static String mergeSpecificationJson({ required String? localSpecJson, required String? remoteSpecJson, required String cloudId, }) { final localData = MaintenanceData.fromSpecificationJson(localSpecJson); final remoteData = MaintenanceData.fromSpecificationJson(remoteSpecJson); // Merge service records deduplicated by id final recordMap = {}; for (final r in localData.serviceRecords) { recordMap[r.id] = r; } for (final r in remoteData.serviceRecords) { recordMap[r.id] = r; } final mergedRecords = recordMap.values.toList() ..sort((a, b) => b.odometer.compareTo(a.odometer)); // Highest base odometer final mergedBaseOdo = math.max(localData.baseOdometer, remoteData.baseOdometer); // Merged custom intervals final mergedIntervals = { ...localData.customIntervals, ...remoteData.customIntervals, }; // Combine base spec map Map baseMap = {}; if (localSpecJson != null && localSpecJson.isNotEmpty) { try { final dec = json.decode(localSpecJson); if (dec is Map) baseMap = Map.from(dec); } catch (_) {} } else if (remoteSpecJson != null && remoteSpecJson.isNotEmpty) { try { final dec = json.decode(remoteSpecJson); if (dec is Map) baseMap = Map.from(dec); } catch (_) {} } baseMap['cloudId'] = cloudId; baseMap['baseOdometer'] = mergedBaseOdo; baseMap['serviceRecords'] = mergedRecords.map((s) => s.toJson()).toList(); baseMap['customIntervals'] = mergedIntervals; return json.encode(baseMap); } /// Generates a globally unique stable cloud document ID for a motorcycle. static String generateCloudMotorcycleId( String uid, int localId, int year, String make, String model, ) { final prefix = uid.length > 8 ? uid.substring(0, 8) : uid; final slugMake = make.toLowerCase().replaceAll(RegExp(r'[^a-z0-9]'), '_'); final slugModel = model.toLowerCase().replaceAll(RegExp(r'[^a-z0-9]'), '_'); return 'moto_${prefix}_${localId}_${year}_${slugMake}_$slugModel'; } DateTime? _parseDateTime(dynamic value) { if (value == null) return null; if (value is Timestamp) return value.toDate(); if (value is String) return DateTime.tryParse(value); if (value is int) return DateTime.fromMillisecondsSinceEpoch(value); return null; } Future syncAccount({DateTime? now}) async { final user = auth.currentUser; if (user == null || user.isAnonymous || !isAvailable) return; final timestamp = (now ?? DateTime.now()).toUtc(); final profile = CloudUserProfile( id: user.uid, displayName: user.displayName, profilePhotoUrl: user.photoUrl, createdAt: user.createdAt?.toUtc() ?? timestamp, updatedAt: timestamp, ); final userRef = _firestore!.collection('users').doc(user.uid); await userRef.set(profile.toJson(), SetOptions(merge: true)); // 1. Fetch remote motorcycles from Firestore and safely reconcile into local Drift DB try { final remoteSnapshot = await userRef.collection('motorcycles').get(); final localBikes = await db.select(db.garageMotorcycles).get(); // Index local motorcycles by stored cloudId and normalized vehicle attributes final localByCloudId = {}; final localByAttributes = {}; for (final b in localBikes) { final cid = extractCloudId(b.specificationJson); if (cid != null && cid.isNotEmpty) { localByCloudId[cid] = b; } final attrKey = '${b.year}_${b.makeName.trim()}_${b.modelName.trim()}'.toLowerCase(); localByAttributes[attrKey] = b; } for (final doc in remoteSnapshot.docs) { final data = doc.data(); final docId = doc.id; final year = (data['year'] as num?)?.toInt() ?? 2000; final makeName = (data['makeName'] ?? '').toString(); final modelName = (data['modelName'] ?? '').toString(); final attrKey = '${year}_${makeName.trim()}_${modelName.trim()}'.toLowerCase(); // Cross-device reconciliation: match by cloudId first, then by vehicle attributes. // Never match different motorcycles that share an identical local integer ID! GarageMotorcycle? existing = localByCloudId[docId] ?? localByAttributes[attrKey]; if (existing == null) { final parsedId = int.tryParse(docId); if (parsedId != null) { final cand = localBikes.where((b) => b.id == parsedId).firstOrNull; if (cand != null) { final candKey = '${cand.year}_${cand.makeName.trim()}_${cand.modelName.trim()}'.toLowerCase(); if (candKey == attrKey) { existing = cand; } } } } if (existing == null) { // Brand new motorcycle from remote device or website: // Insert with auto-increment local ID (never overwrite existing local bike!) final specWithCloudId = attachCloudId(data['specificationJson']?.toString(), docId); await db.into(db.garageMotorcycles).insert( GarageMotorcyclesCompanion( id: const Value.absent(), // Let SQLite assign fresh local ID to prevent collisions! year: Value(year), makeId: Value((data['makeId'] ?? '1').toString()), makeName: Value(makeName), modelId: Value((data['modelId'] ?? '1').toString()), modelName: Value(modelName), variantId: Value(data['variantId']?.toString()), variantName: Value(data['variantName']?.toString()), nickname: Value(data['nickname']?.toString()), isDefault: Value(data['isDefault'] == true && localBikes.isEmpty), dateAdded: Value(_parseDateTime(data['createdAt']) ?? timestamp), lastUpdated: Value(_parseDateTime(data['updatedAt']) ?? timestamp), specificationJson: Value(specWithCloudId), ), ); } else { // Existing motorcycle: link cloudId and merge specifications/maintenance final updatedSpec = mergeSpecificationJson( localSpecJson: existing.specificationJson, remoteSpecJson: data['specificationJson']?.toString(), cloudId: docId, ); await (db.update(db.garageMotorcycles)..where((t) => t.id.equals(existing!.id))) .write(GarageMotorcyclesCompanion( specificationJson: Value(updatedSpec), lastUpdated: Value(timestamp), )); } } } catch (_) { // Remote fetch error should not block local operations } // 2. Upload any local motorcycles to Firestore with stable unique cloud IDs final currentLocal = await db.select(db.garageMotorcycles).get(); for (final motorcycle in currentLocal) { var cloudId = extractCloudId(motorcycle.specificationJson); if (cloudId == null || cloudId.isEmpty) { cloudId = generateCloudMotorcycleId( user.uid, motorcycle.id, motorcycle.year, motorcycle.makeName, motorcycle.modelName, ); // Persist generated cloudId locally in specificationJson final updatedSpec = attachCloudId(motorcycle.specificationJson, cloudId); await (db.update(db.garageMotorcycles)..where((t) => t.id.equals(motorcycle.id))) .write(GarageMotorcyclesCompanion( specificationJson: Value(updatedSpec), )); } final cloud = CloudMotorcycle( id: cloudId, ownerId: user.uid, year: motorcycle.year, makeId: motorcycle.makeId, makeName: motorcycle.makeName, modelId: motorcycle.modelId, modelName: motorcycle.modelName, variantId: motorcycle.variantId, variantName: motorcycle.variantName, nickname: motorcycle.nickname, isDefault: motorcycle.isDefault, createdAt: motorcycle.dateAdded.toUtc(), updatedAt: motorcycle.lastUpdated.toUtc(), specificationJson: motorcycle.specificationJson, ); await userRef.collection('motorcycles').doc(cloud.id).set( cloud.toJson(), SetOptions(merge: true), ); } // 3. Fallback synthesis: If garage is empty but restored rides reference a motorcycle if (currentLocal.isEmpty) { await _synthesizeMotorcycleFromRidesIfEmpty(user.uid, userRef, timestamp); } } Future _synthesizeMotorcycleFromRidesIfEmpty( String uid, DocumentReference userRef, DateTime timestamp, ) async { try { final ridesWithBikes = await (db.select(db.rides) ..where((r) => r.motorcycleMake.isNotNull() & r.motorcycleModel.isNotNull()) ..orderBy([(r) => OrderingTerm(expression: r.startTime, mode: OrderingMode.desc)]) ..limit(1)) .get(); if (ridesWithBikes.isNotEmpty) { final r = ridesWithBikes.first; final bikeId = r.motorcycleId ?? 1; final year = r.motorcycleYear ?? 2001; final make = r.motorcycleMake ?? 'Unknown'; final model = r.motorcycleModel ?? 'Unknown'; final variant = r.motorcycleVariant; final cloudId = generateCloudMotorcycleId(uid, bikeId, year, make, model); final specWithCloudId = attachCloudId(null, cloudId); await db.into(db.garageMotorcycles).insert( GarageMotorcyclesCompanion( id: Value(bikeId), year: Value(year), makeId: Value(bikeId.toString()), makeName: Value(make), modelId: Value(bikeId.toString()), modelName: Value(model), variantName: Value(variant), isDefault: const Value(true), dateAdded: Value(timestamp), lastUpdated: Value(timestamp), specificationJson: Value(specWithCloudId), ), mode: InsertMode.insertOrReplace, ); final cloud = CloudMotorcycle( id: cloudId, ownerId: uid, year: year, makeId: bikeId.toString(), makeName: make, modelId: bikeId.toString(), modelName: model, variantName: variant, isDefault: true, createdAt: timestamp, updatedAt: timestamp, specificationJson: specWithCloudId, ); await userRef.collection('motorcycles').doc(cloud.id).set( cloud.toJson(), SetOptions(merge: true), ); } } catch (_) {} } }