import 'dart:async'; import 'dart:math' as math; import 'package:cloud_firestore/cloud_firestore.dart'; import 'package:firebase_auth/firebase_auth.dart'; import 'package:firebase_core/firebase_core.dart'; import 'package:flutter/foundation.dart'; import 'package:geolocator/geolocator.dart'; import 'road_alert_models.dart'; import 'swarm_road_alert_models.dart'; enum SwarmCloudStatus { unavailable, confirmed, pending, failed } class RoadAlertRateLimitException implements Exception { final int retryAfterSeconds; RoadAlertRateLimitException(this.retryAfterSeconds); @override String toString() => 'Please wait $retryAfterSeconds seconds before submitting another road alert.'; } class SwarmRoadAlertService { final FirebaseFirestore? _firestore; final FirebaseAuth? _auth; final Duration minReportInterval; final double duplicateDistanceMeters; final Duration duplicateWindow; static int _idCounter = 0; DateTime? _lastReportTime; final Set _userVotes = {}; // Key format: '$alertId:$uid' // Local cache for offline resilience and immediate zero-latency feedback final Map _localSwarmCache = {}; SwarmRoadAlertService({ FirebaseFirestore? firestore, FirebaseAuth? auth, this.minReportInterval = Duration.zero, this.duplicateDistanceMeters = 200.0, this.duplicateWindow = const Duration(minutes: 15), }) : _firestore = firestore ?? _resolveFirestore(), _auth = auth ?? _resolveAuth(); static FirebaseFirestore? _resolveFirestore() { try { if (Firebase.apps.isNotEmpty) return FirebaseFirestore.instance; } catch (_) {} return null; } static FirebaseAuth? _resolveAuth() { try { if (Firebase.apps.isNotEmpty) return FirebaseAuth.instance; } catch (_) {} return null; } bool get isCloudAvailable => _firestore != null; String? get _currentUid => _auth?.currentUser?.uid; /// Returns a cached report by alert ID if available. SwarmReport? getCachedReport(String alertId) => _localSwarmCache[alertId]; static Duration defaultTtlForCategory(RoadAlertCategory category) { switch (category) { case RoadAlertCategory.police: return const Duration(minutes: 60); case RoadAlertCategory.traffic: return const Duration(minutes: 45); case RoadAlertCategory.accident: case RoadAlertCategory.roadClosure: return const Duration(hours: 3); case RoadAlertCategory.weatherHazard: return const Duration(hours: 4); case RoadAlertCategory.hazard: case RoadAlertCategory.pothole: case RoadAlertCategory.debris: case RoadAlertCategory.disabledVehicle: case RoadAlertCategory.construction: case RoadAlertCategory.speedCamera: case RoadAlertCategory.other: return const Duration(hours: 2); } } static String defaultDescriptionForCategory(RoadAlertCategory category) { switch (category) { case RoadAlertCategory.police: return 'Police speed trap reported by rider'; case RoadAlertCategory.accident: return 'Traffic accident reported'; case RoadAlertCategory.hazard: return 'Road hazard reported'; case RoadAlertCategory.pothole: return 'Severe pothole reported on road'; case RoadAlertCategory.debris: return 'Road debris reported'; case RoadAlertCategory.disabledVehicle: return 'Disabled vehicle reported on roadway'; case RoadAlertCategory.roadClosure: return 'Road closure reported'; case RoadAlertCategory.construction: return 'Road construction reported'; case RoadAlertCategory.traffic: return 'Heavy traffic reported'; case RoadAlertCategory.weatherHazard: return 'Adverse weather road hazard reported'; case RoadAlertCategory.speedCamera: return 'Speed enforcement camera reported'; case RoadAlertCategory.other: return 'Rider safety report'; } } bool _isCategoryMatch(RoadAlertCategory a, RoadAlertCategory b) { if (a == b) return true; final hazardGroup = { RoadAlertCategory.hazard, RoadAlertCategory.pothole, RoadAlertCategory.debris, RoadAlertCategory.disabledVehicle, }; return hazardGroup.contains(a) && hazardGroup.contains(b); } SwarmReport? _findDuplicateAlert({ required RoadAlertCategory category, required double latitude, required double longitude, required DateTime reportedAt, }) { for (final report in _localSwarmCache.values) { if (report.isExpired) continue; if (report.status == RoadAlertStatus.cleared || report.status == RoadAlertStatus.expired) { continue; } final age = reportedAt.difference(report.reportedAt).abs(); if (age > duplicateWindow) continue; if (!_isCategoryMatch(report.category, category)) continue; final distance = Geolocator.distanceBetween( latitude, longitude, report.latitude, report.longitude, ); if (distance <= duplicateDistanceMeters) { return report; } } return null; } /// Checks if the given user (or current user) has already voted on this alert. bool hasUserVoted(String alertId, [String? voterUid]) { final uid = voterUid ?? _currentUid ?? 'local_rider'; return _userVotes.contains('$alertId:$uid'); } /// Checks if the given user is eligible to vote on this alert. bool canUserVote(String alertId, [String? voterUid]) { final uid = voterUid ?? _currentUid ?? 'local_rider'; final cached = _localSwarmCache[alertId]; if (cached != null && cached.reporterUid == uid) { return false; // Reporter cannot vote on their own alert } return !_userVotes.contains('$alertId:$uid'); } /// Reports a new road alert. /// Saves to Firestore `/road_alerts` and caches locally. /// Automatically checks for duplicate alerts nearby (< 200m within 15min) /// and confirms existing alerts instead of duplicating. Future reportAlert({ required RoadAlertCategory category, required double latitude, required double longitude, String? subtype, String? source, double? heading, String? roadName, String? description, String? groupRideId, String? reporterUid, Duration? ttl, bool ignoreRateLimit = false, void Function(SwarmCloudStatus)? onCloudStatus, }) async { final now = DateTime.now(); final uid = reporterUid ?? _currentUid ?? 'local_rider'; // Rate Limiting if (!ignoreRateLimit && minReportInterval > Duration.zero && _lastReportTime != null) { final elapsed = now.difference(_lastReportTime!); if (elapsed < minReportInterval) { final remaining = (minReportInterval - elapsed).inSeconds + 1; throw RoadAlertRateLimitException(remaining); } } // Duplicate detection and auto-merging final duplicate = _findDuplicateAlert( category: category, latitude: latitude, longitude: longitude, reportedAt: now, ); if (duplicate != null) { if (kDebugMode) { debugPrint('[SwarmRoadAlertService] Duplicate alert detected: ${duplicate.id}. Auto-confirming.'); } if (duplicate.reporterUid != uid) { await confirmAlert(duplicate.id, voterUid: uid); return _localSwarmCache[duplicate.id] ?? duplicate; } return duplicate; } _lastReportTime = now; final uidSuffix = uid.length >= 5 ? uid.substring(0, 5) : uid; final count = ++_idCounter; final alertId = 'swarm_${now.millisecondsSinceEpoch}_${count}_$uidSuffix'; final effectiveTtl = ttl ?? defaultTtlForCategory(category); final expiresAt = now.add(effectiveTtl); final desc = description ?? defaultDescriptionForCategory(category); final effectiveSource = source ?? (groupRideId != null ? 'Pack Rider Report' : 'MotorSignal Swarm'); final report = SwarmReport( id: alertId, category: category, subtype: subtype, latitude: latitude, longitude: longitude, heading: heading, roadName: roadName, description: desc, reporterUid: uid, reportedAt: now, expiresAt: expiresAt, confirmations: 1, dismissals: 0, groupRideId: groupRideId, source: effectiveSource, status: RoadAlertStatus.active, ); // Cache locally immediately _localSwarmCache[alertId] = report; // Persist to Cloud Firestore if connected final firestore = _firestore; if (firestore != null) { try { await firestore .collection('road_alerts') .doc(alertId) .set(report.toJson()) .timeout(const Duration(seconds: 6)); onCloudStatus?.call(SwarmCloudStatus.confirmed); } on TimeoutException { // A timed-out Firestore write may still finish. Do not retry here: // this report already has a stable document ID. onCloudStatus?.call(SwarmCloudStatus.pending); } catch (e) { onCloudStatus?.call(SwarmCloudStatus.failed); if (kDebugMode) { debugPrint('[SwarmRoadAlertService] Firestore write failed (operating offline): $e'); } } } else { onCloudStatus?.call(SwarmCloudStatus.unavailable); } return report; } /// Trailing rider confirms an existing alert (increases confidence). /// Enforces per-user vote restrictions (no repeat votes, no self-voting). /// Uses a Firestore transaction to authoritatively update confirmation count /// and vote subcollection atomically across all devices. Future confirmAlert(String alertId, {String? voterUid}) async { final uid = voterUid ?? _currentUid ?? 'local_rider'; final cached = _localSwarmCache[alertId]; if (cached != null && cached.reporterUid == uid) { if (kDebugMode) { debugPrint('[SwarmRoadAlertService] Self-voting rejected for alert $alertId'); } return false; } final voteKey = '$alertId:$uid'; if (_userVotes.contains(voteKey)) { if (kDebugMode) { debugPrint('[SwarmRoadAlertService] Repeat voting rejected for alert $alertId'); } return false; } _userVotes.add(voteKey); final previousCached = cached; final now = DateTime.now(); // Optimistically update local cache if (cached != null) { final updatedConfirmations = cached.confirmations + 1; final newStatus = updatedConfirmations >= 2 ? RoadAlertStatus.confirmed : cached.status; _localSwarmCache[alertId] = cached.copyWith( confirmations: updatedConfirmations, status: newStatus, ); } final firestore = _firestore; if (firestore != null) { try { final alertRef = firestore.collection('road_alerts').doc(alertId); final voteRef = alertRef.collection('votes').doc(uid); await firestore.runTransaction((transaction) async { final snapshot = await transaction.get(alertRef); if (!snapshot.exists) { throw Exception('Alert does not exist'); } final data = snapshot.data(); if (data == null) { throw Exception('Alert data is null'); } if (data['reporterUid'] == uid) { throw Exception('Self-voting is not permitted'); } final expiresAtMillis = (data['expiresAt'] as num?)?.toInt() ?? 0; if (expiresAtMillis <= now.millisecondsSinceEpoch) { throw Exception('Alert has already expired'); } final statusStr = data['status']?.toString(); if (statusStr == 'cleared' || statusStr == 'expired') { throw Exception('Alert is already $statusStr'); } final currentConfirmations = (data['confirmations'] as num?)?.toInt() ?? 1; final updatedConfirmations = currentConfirmations + 1; final newStatus = updatedConfirmations >= 2 ? 'confirmed' : (statusStr ?? 'active'); transaction.set(voteRef, { 'voterUid': uid, 'type': 'confirm', 'votedAt': now.millisecondsSinceEpoch, }); transaction.update(alertRef, { 'confirmations': updatedConfirmations, 'status': newStatus, }); }); } catch (e) { if (kDebugMode) { debugPrint('[SwarmRoadAlertService] Firestore confirm error: $e'); } // Rollback optimistic cache and vote record on failure if (previousCached != null) { _localSwarmCache[alertId] = previousCached; } else { _localSwarmCache.remove(alertId); } _userVotes.remove(voteKey); return false; } } return true; } /// Trailing rider reports alert is clear / no longer present. /// Automatically marks alert cleared/expired when dismissals reach threshold. /// Uses a Firestore transaction to authoritatively update dismissal count /// and vote subcollection atomically across all devices. Future clearAlert(String alertId, {String? voterUid}) async { final uid = voterUid ?? _currentUid ?? 'local_rider'; final cached = _localSwarmCache[alertId]; if (cached != null && cached.reporterUid == uid) { if (kDebugMode) { debugPrint('[SwarmRoadAlertService] Self-clearing rejected for alert $alertId'); } return false; } final voteKey = '$alertId:$uid'; if (_userVotes.contains(voteKey)) { if (kDebugMode) { debugPrint('[SwarmRoadAlertService] Repeat dismissal rejected for alert $alertId'); } return false; } _userVotes.add(voteKey); final previousCached = cached; final now = DateTime.now(); // Optimistically update local cache if (cached != null) { final updatedDismissals = cached.dismissals + 1; if (updatedDismissals >= 2 && updatedDismissals >= cached.confirmations) { _localSwarmCache[alertId] = cached.copyWith( dismissals: updatedDismissals, expiresAt: now, status: RoadAlertStatus.cleared, ); } else { _localSwarmCache[alertId] = cached.copyWith(dismissals: updatedDismissals); } } final firestore = _firestore; if (firestore != null) { try { final alertRef = firestore.collection('road_alerts').doc(alertId); final voteRef = alertRef.collection('votes').doc(uid); await firestore.runTransaction((transaction) async { final snapshot = await transaction.get(alertRef); if (!snapshot.exists) { throw Exception('Alert does not exist'); } final data = snapshot.data(); if (data == null) { throw Exception('Alert data is null'); } if (data['reporterUid'] == uid) { throw Exception('Self-clearing is not permitted'); } final expiresAtMillis = (data['expiresAt'] as num?)?.toInt() ?? 0; final nowMillis = now.millisecondsSinceEpoch; if (expiresAtMillis <= nowMillis) { throw Exception('Alert has already expired'); } final statusStr = data['status']?.toString(); if (statusStr == 'cleared' || statusStr == 'expired') { throw Exception('Alert is already $statusStr'); } final currentConfirmations = (data['confirmations'] as num?)?.toInt() ?? 1; final currentDismissals = (data['dismissals'] as num?)?.toInt() ?? 0; final updatedDismissals = currentDismissals + 1; final isCleared = updatedDismissals >= 2 && updatedDismissals >= currentConfirmations; final newStatus = isCleared ? 'cleared' : (statusStr ?? 'active'); final newExpiresAt = isCleared ? nowMillis : expiresAtMillis; transaction.set(voteRef, { 'voterUid': uid, 'type': 'dismiss', 'votedAt': nowMillis, }); transaction.update(alertRef, { 'dismissals': updatedDismissals, 'status': newStatus, 'expiresAt': newExpiresAt, }); }); } catch (e) { if (kDebugMode) { debugPrint('[SwarmRoadAlertService] Firestore clear error: $e'); } // Rollback optimistic cache and vote record on failure if (previousCached != null) { _localSwarmCache[alertId] = previousCached; } else { _localSwarmCache.remove(alertId); } _userVotes.remove(voteKey); return false; } } return true; } /// Deletes alert if caller is the reporter. Future deleteAlert(String alertId) async { final uid = _currentUid ?? 'local_rider'; final cached = _localSwarmCache[alertId]; if (cached != null && cached.reporterUid != uid) { return false; } _localSwarmCache.remove(alertId); final firestore = _firestore; if (firestore != null) { try { await firestore.collection('road_alerts').doc(alertId).delete(); } catch (e) { if (kDebugMode) { debugPrint('[SwarmRoadAlertService] Firestore delete error: $e'); } } } return true; } /// Queries all live alerts within [radiusMeters] of [latitude, longitude]. Future> getAlertsNear({ required double latitude, required double longitude, required double radiusMeters, }) async { final now = DateTime.now(); final Map candidates = {}; // 1. Ingest local in-memory cache for (var entry in _localSwarmCache.entries) { if (!entry.value.isExpired && entry.value.status != RoadAlertStatus.cleared && entry.value.status != RoadAlertStatus.expired) { candidates[entry.key] = entry.value; } } // 2. Ingest from Firestore if connected final firestore = _firestore; if (firestore != null) { try { final latOffset = radiusMeters / 111320.0; final cosLat = math.cos(latitude * math.pi / 180.0); final lngOffset = radiusMeters / (111320.0 * (cosLat.abs() > 0.001 ? cosLat.abs() : 0.001)); final minLat = latitude - latOffset; final maxLat = latitude + latOffset; final minLng = longitude - lngOffset; final maxLng = longitude + lngOffset; final querySnap = await firestore .collection('road_alerts') .where('expiresAt', isGreaterThan: now.millisecondsSinceEpoch) .get(); for (var doc in querySnap.docs) { try { final report = SwarmReport.fromJson(doc.data()); // Filter out cleared/expired documents if (report.status == RoadAlertStatus.cleared || report.status == RoadAlertStatus.expired || report.isExpired) { continue; } // Honor local in-memory vote states and clearance final cached = _localSwarmCache[report.id]; if (cached != null && (cached.status == RoadAlertStatus.cleared || cached.status == RoadAlertStatus.expired || cached.isExpired)) { continue; } final effectiveReport = cached != null ? report.copyWith( confirmations: math.max(cached.confirmations, report.confirmations), dismissals: math.max(cached.dismissals, report.dismissals), status: cached.status == RoadAlertStatus.confirmed ? RoadAlertStatus.confirmed : report.status, ) : report; // Client-side bounding box filter if (effectiveReport.latitude >= minLat && effectiveReport.latitude <= maxLat && effectiveReport.longitude >= minLng && effectiveReport.longitude <= maxLng) { candidates[effectiveReport.id] = effectiveReport; _localSwarmCache[effectiveReport.id] = effectiveReport; } } catch (_) {} } } catch (e) { if (kDebugMode) { debugPrint('[SwarmRoadAlertService] Query error: $e'); } } } // 3. Compute exact distances and convert to RoadAlert models final List results = []; for (var report in candidates.values) { if (report.isExpired) continue; if (report.status == RoadAlertStatus.cleared || report.status == RoadAlertStatus.expired) { continue; } final dist = Geolocator.distanceBetween( latitude, longitude, report.latitude, report.longitude, ); if (dist <= radiusMeters) { results.add(report.toRoadAlert(distanceFromUser: dist)); } } results.sort((a, b) => (a.distanceFromUser ?? 0).compareTo(b.distanceFromUser ?? 0)); return results; } /// Clears local in-memory cache and voting records (for testing or reset) void clearLocalCache() { _localSwarmCache.clear(); _userVotes.clear(); _lastReportTime = null; } }