import 'dart:async'; import 'package:cloud_firestore/cloud_firestore.dart'; import 'package:firebase_core/firebase_core.dart'; import 'route_models.dart'; /// Service managing route creation, persistence, reordering, retrieval, and attachment. class RouteService { final FirebaseFirestore? _firestore; RouteService({FirebaseFirestore? firestore}) : _firestore = firestore ?? _resolveFirestore(); static FirebaseFirestore? _resolveFirestore() { try { if (Firebase.apps.isNotEmpty) return FirebaseFirestore.instance; } catch (_) {} return null; } bool get isAvailable => _firestore != null; /// Technical safety bound for maximum stops in a single route. static const int maxTechnicalStops = 25; /// Validates route stops invariants. /// /// Note on stop validation: /// Client-side and domain validation strictly enforces unique stop IDs and sequential 0..N-1 ordering. /// Firestore Security Rules validate stop schema, bounding, and technical capacity (<= 25), /// but exhaustive O(N^2) duplicate-ID comparison is impractical in Firestore Rules for embedded arrays. static void validateStops(List stops) { if (stops.length > maxTechnicalStops) { throw ArgumentError( 'Route cannot exceed $maxTechnicalStops stops.', ); } final seenIds = {}; for (int i = 0; i < stops.length; i++) { final s = stops[i]; if (s.id.trim().isEmpty) { throw ArgumentError('Route stop at index $i is missing an ID.'); } if (s.id.length > 64) { throw ArgumentError('Route stop ID exceeds 64 characters.'); } if (seenIds.contains(s.id)) { throw ArgumentError('Duplicate route stop ID: ${s.id}'); } seenIds.add(s.id); if (s.latitude.isNaN || s.longitude.isNaN) { throw ArgumentError('Route stop contains invalid coordinates (NaN).'); } if (s.latitude < -90.0 || s.latitude > 90.0) { throw ArgumentError( 'Route stop contains invalid coordinates: latitude ${s.latitude} out of bounds.', ); } if (s.longitude < -180.0 || s.longitude > 180.0) { throw ArgumentError( 'Route stop contains invalid coordinates: longitude ${s.longitude} out of bounds.', ); } if (s.displayName.trim().isEmpty) { throw ArgumentError('Stop name cannot be empty.'); } if (s.displayName.length > 80) { throw ArgumentError('Stop name cannot exceed 80 characters.'); } if (s.address.length > 200) { throw ArgumentError('Stop address cannot exceed 200 characters.'); } if (s.notes.length > 500) { throw ArgumentError('Stop notes cannot exceed 500 characters.'); } } } /// Saves or creates a route in persistent cloud storage. /// /// Fails explicitly if persistence is unavailable. Future saveRoute({ String? id, required String title, String description = '', required String ownerUid, String ownerHandle = '', RouteVisibility visibility = RouteVisibility.private, required List stops, String? attachedGroupRideId, DateTime? createdAt, }) async { final firestore = _firestore; if (firestore == null) { throw StateError( 'Route persistence unavailable: cloud database service is not configured.', ); } if (ownerUid.trim().isEmpty) { throw StateError('Sign-in required to save routes.'); } final cleanTitle = title.trim(); if (cleanTitle.length < 3 || cleanTitle.length > 80) { throw ArgumentError('Route title must be between 3 and 80 characters.'); } if (visibility == RouteVisibility.groupOnly && (attachedGroupRideId == null || attachedGroupRideId.isEmpty)) { throw ArgumentError( 'Group-only route visibility requires an attached group ride ID.', ); } validateStops(stops); final now = DateTime.now(); final effectiveCreatedAt = createdAt ?? now; final effectiveUpdatedAt = now; final routeId = id ?? 'route_${now.millisecondsSinceEpoch}_${ownerUid.substring(0, ownerUid.length.clamp(0, 6))}'; // Re-index stops 0..N-1 final sortedStops = List.from(stops); sortedStops.sort((a, b) => a.order.compareTo(b.order)); final normalizedStops = []; for (int i = 0; i < sortedStops.length; i++) { normalizedStops.add(sortedStops[i].copyWith(order: i, orderIndex: i)); } final totalMeters = MotorSignalRoute.calculateTotalDistanceMeters(normalizedStops); final route = MotorSignalRoute( id: routeId, ownerUid: ownerUid, ownerHandle: ownerHandle, title: cleanTitle, description: description.trim(), visibility: visibility, stops: normalizedStops, straightLineDistanceMeters: totalMeters, source: RouteSource.planner, version: 1, attachedGroupRideId: attachedGroupRideId, createdAt: effectiveCreatedAt, updatedAt: effectiveUpdatedAt, ); await firestore.collection('routes').doc(routeId).set(route.toJson()); return route; } /// Legacy alias for saveRoute. Future createRoute({ required String ownerUid, required String ownerHandle, required String title, String description = '', RouteVisibility visibility = RouteVisibility.private, required List stops, String? attachedGroupRideId, }) async { return saveRoute( ownerUid: ownerUid, ownerHandle: ownerHandle, title: title, description: description, visibility: visibility, stops: stops, attachedGroupRideId: attachedGroupRideId, ); } /// Retrieves a route by ID. Future getRoute(String routeId) async { final firestore = _firestore; if (firestore == null) return null; final doc = await firestore.collection('routes').doc(routeId).get(); if (!doc.exists || doc.data() == null) return null; return MotorSignalRoute.fromJson(doc.data()!); } /// Streams real-time updates for a single route. Stream routeStream(String routeId) { final firestore = _firestore; if (firestore == null) return Stream.value(null); return firestore.collection('routes').doc(routeId).snapshots().map((doc) { if (!doc.exists || doc.data() == null) return null; return MotorSignalRoute.fromJson(doc.data()!); }); } /// Streams saved routes owned by [ownerUid]. Stream> userRoutesStream(String ownerUid) { final firestore = _firestore; if (firestore == null || ownerUid.isEmpty) return Stream.value(const []); return firestore .collection('routes') .where('ownerUid', isEqualTo: ownerUid) .snapshots() .map((snapshot) { final routes = snapshot.docs .map((doc) => MotorSignalRoute.fromJson(doc.data())) .toList(); routes.sort((a, b) => b.updatedAt.compareTo(a.updatedAt)); return routes; }); } /// Updates an existing route, preserving original creation provenance. Future updateRoute({ required MotorSignalRoute route, required String callerUid, }) async { if (callerUid.trim().isEmpty) { throw StateError('Caller identity required to update route.'); } if (route.ownerUid.isNotEmpty && route.ownerUid != callerUid) { throw StateError('Only the route owner can update this route.'); } return saveRoute( id: route.id, title: route.title, description: route.description, ownerUid: route.ownerUid.isNotEmpty ? route.ownerUid : callerUid, ownerHandle: route.ownerHandle, visibility: route.visibility, stops: route.stops, attachedGroupRideId: route.attachedGroupRideId, createdAt: route.createdAt, ); } /// Deletes a route safely and atomically. /// /// Caller must be the route owner. If the route is attached to a Group Ride: /// - If caller is also the Group Ride organizer, clears attachment and deletes atomically. /// - If caller is not the organizer, refuses deletion and requires organizer detach first. Future deleteRoute( dynamic routeIdOrNamed, { String? routeId, required String callerUid, }) async { if (callerUid.trim().isEmpty) { throw StateError('Caller identity required to delete route.'); } final effectiveRouteId = routeIdOrNamed is String ? routeIdOrNamed : (routeId ?? ''); if (effectiveRouteId.isEmpty) return; final firestore = _firestore; if (firestore == null) { throw StateError( 'Route persistence unavailable: cloud database service is not configured.', ); } final docRef = firestore.collection('routes').doc(effectiveRouteId); final snapshot = await docRef.get(); if (!snapshot.exists || snapshot.data() == null) return; final data = snapshot.data()!; if (data['ownerUid'] != callerUid) { throw StateError('Only the route owner can delete this route.'); } final attachedRideId = data['attachedGroupRideId'] as String?; if (attachedRideId != null && attachedRideId.isNotEmpty) { final rideRef = firestore.collection('group_rides').doc(attachedRideId); final rideSnap = await rideRef.get(); if (rideSnap.exists && rideSnap.data() != null) { final rideData = rideSnap.data()!; if (rideData['organizerUid'] != callerUid) { throw StateError( 'Cannot delete a route attached to a Group Ride you do not organize. Organizer must detach the route first.', ); } final batch = firestore.batch(); batch.update(rideRef, { 'routeId': FieldValue.delete(), 'routeTitle': FieldValue.delete(), 'routeDistanceMeters': FieldValue.delete(), 'routeStopCount': FieldValue.delete(), 'updatedAt': DateTime.now().toIso8601String(), }); batch.delete(docRef); await batch.commit(); return; } } await docRef.delete(); } /// Attaches a saved route to a Group Ride. /// /// Caller must be the Group Ride organizer AND own the route being attached. Future attachRouteToGroupRide({ String? routeId, MotorSignalRoute? route, required String groupRideId, required String callerUid, }) async { if (callerUid.trim().isEmpty) { throw StateError('Caller identity required to attach route.'); } final firestore = _firestore; if (firestore == null) { throw StateError( 'Route persistence unavailable: cloud database service is not configured.', ); } final targetRouteId = route?.id ?? routeId; if (targetRouteId == null || targetRouteId.isEmpty) { throw ArgumentError('Route or routeId must be provided.'); } final rideRef = firestore.collection('group_rides').doc(groupRideId); final rideSnap = await rideRef.get(); if (!rideSnap.exists || rideSnap.data() == null) { throw ArgumentError('Group Ride not found.'); } final rideData = rideSnap.data()!; if (rideData['organizerUid'] != callerUid) { throw StateError('Only the Group Ride organizer can attach a route.'); } final effectiveRoute = route ?? await getRoute(targetRouteId); if (effectiveRoute == null) { throw ArgumentError('Route not found.'); } if (effectiveRoute.ownerUid != callerUid) { throw StateError('Organizer can only attach a route they own.'); } final routeRef = firestore.collection('routes').doc(targetRouteId); final now = DateTime.now(); final batch = firestore.batch(); // 1. Update Group Ride metadata batch.update(rideRef, { 'routeId': effectiveRoute.id, 'routeTitle': effectiveRoute.title, 'routeDistanceMeters': effectiveRoute.straightLineDistanceMeters, 'routeStopCount': effectiveRoute.stops.length, 'updatedAt': now.toIso8601String(), }); // 2. Update Route document attachment batch.update(routeRef, { 'attachedGroupRideId': groupRideId, 'visibility': RouteVisibility.groupOnly.name, 'updatedAt': now.toIso8601String(), }); await batch.commit(); } /// Detaches an attached route from a Group Ride. /// /// Caller must be the Group Ride organizer. /// Detaching clears the Group Ride route metadata and restores route visibility to private. Future detachRouteFromGroupRide( String groupRideId, { required String callerUid, }) async { if (callerUid.trim().isEmpty) { throw StateError('Caller identity required to detach route.'); } final firestore = _firestore; if (firestore == null) { throw StateError( 'Route persistence unavailable: cloud database service is not configured.', ); } final rideRef = firestore.collection('group_rides').doc(groupRideId); final rideSnap = await rideRef.get(); if (!rideSnap.exists || rideSnap.data() == null) return; final rideData = rideSnap.data()!; if (rideData['organizerUid'] != callerUid) { throw StateError('Only the organizer can detach a route.'); } final routeId = rideData['routeId'] as String?; final now = DateTime.now(); final batch = firestore.batch(); batch.update(rideRef, { 'routeId': FieldValue.delete(), 'routeTitle': FieldValue.delete(), 'routeDistanceMeters': FieldValue.delete(), 'routeStopCount': FieldValue.delete(), 'updatedAt': now.toIso8601String(), }); if (routeId != null && routeId.isNotEmpty) { final routeRef = firestore.collection('routes').doc(routeId); final routeSnap = await routeRef.get(); if (routeSnap.exists) { batch.update(routeRef, { 'attachedGroupRideId': FieldValue.delete(), 'visibility': RouteVisibility.private.name, 'updatedAt': now.toIso8601String(), }); } } await batch.commit(); } }