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; /// Free tier recommended stop cap. static const int freeStopCap = 8; /// Validates route stops invariants. static void validateStops(List stops, {bool isPro = false}) { if (stops.length > maxTechnicalStops) { throw ArgumentError( 'Route cannot exceed $maxTechnicalStops stops.', ); } if (!isPro && stops.length > freeStopCap) { throw ArgumentError( 'Free tier routes cannot exceed $freeStopCap stops. Upgrade to Pro for up to $maxTechnicalStops stops.', ); } final seenIds = {}; for (int i = 0; i < stops.length; i++) { final s = stops[i]; if (s.id.isEmpty) { throw ArgumentError('Route stop at index $i is missing an ID.'); } 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.'); } } } /// Saves or creates a route (offline-safe when Firestore is unconfigured). Future saveRoute({ String? id, required String title, String description = '', required String ownerUid, String ownerHandle = '', RouteVisibility visibility = RouteVisibility.private, required List stops, bool isPro = false, String? attachedGroupRideId, }) async { final cleanTitle = title.trim(); if (cleanTitle.length < 3 || cleanTitle.length > 80) { throw ArgumentError('Route title must be between 3 and 80 characters.'); } validateStops(stops, isPro: isPro); final now = DateTime.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: now, updatedAt: now, ); final firestore = _firestore; if (firestore != null) { 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, bool isPro = false, String? attachedGroupRideId, }) async { return saveRoute( ownerUid: ownerUid, ownerHandle: ownerHandle, title: title, description: description, visibility: visibility, stops: stops, isPro: isPro, 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. Future updateRoute({ required MotorSignalRoute route, required String callerUid, bool isPro = false, }) async { 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: callerUid, ownerHandle: route.ownerHandle, visibility: route.visibility, stops: route.stops, isPro: isPro, attachedGroupRideId: route.attachedGroupRideId, ); } /// Deletes a route safely. /// /// If the route is attached to a Group Ride, it cleanly clears the /// attachment reference without destructive cascades. Future deleteRoute( dynamic routeIdOrNamed, { String? routeId, String? callerUid, }) async { final effectiveRouteId = routeIdOrNamed is String ? routeIdOrNamed : (routeId ?? ''); if (effectiveRouteId.isEmpty) return; final firestore = _firestore; if (firestore == null) return; final docRef = firestore.collection('routes').doc(effectiveRouteId); final snapshot = await docRef.get(); if (!snapshot.exists || snapshot.data() == null) return; final data = snapshot.data()!; if (callerUid != null && 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) { await rideRef.update({ 'routeId': FieldValue.delete(), 'routeTitle': FieldValue.delete(), 'routeDistanceMeters': FieldValue.delete(), 'routeStopCount': FieldValue.delete(), 'updatedAt': DateTime.now().toIso8601String(), }); } } await docRef.delete(); } /// Attaches a saved route to a Group Ride. /// /// Caller must be the Group Ride organizer. Future attachRouteToGroupRide({ String? routeId, MotorSignalRoute? route, required String groupRideId, String? callerUid, }) async { final firestore = _firestore; if (firestore == null) return; 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 (callerUid != null && rideData['organizerUid'] != null && 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.'); } 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. Future detachRouteFromGroupRide( String groupRideId, { String? callerUid, }) async { final firestore = _firestore; if (firestore == null) return; 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 (callerUid != null && rideData['organizerUid'] != null && 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(), 'updatedAt': now.toIso8601String(), }); } } await batch.commit(); } }