import 'dart:convert'; import 'dart:math' as math; import 'package:flutter/foundation.dart'; import 'package:http/http.dart' as http; import 'road_alert_models.dart'; import 'road_alert_provider_interface.dart'; /// Client provider for Zyla Labs Waze Data API #4814 via MotorSignal FastAPI backend. /// /// Credential isolation: The client NEVER holds or transmits the Zyla API key. /// All requests route through the MotorSignal backend adapter. class ZylaRoadAlertProvider implements RoadAlertProvider { final http.Client _client; final String backendBaseUrl; final Future Function()? getAuthToken; ZylaRoadAlertProvider({ http.Client? client, String? backendBaseUrl, this.getAuthToken, }) : _client = client ?? http.Client(), backendBaseUrl = backendBaseUrl ?? _resolveDefaultBaseUrl(); static String _resolveDefaultBaseUrl() { const apiUrl = String.fromEnvironment('MOTORSIGNAL_API_URL'); if (apiUrl.isNotEmpty) return apiUrl; const apiBaseUrl = String.fromEnvironment('MOTORSIGNAL_API_BASE_URL'); if (apiBaseUrl.isNotEmpty) return apiBaseUrl; return 'https://api.motorsignal.app'; } @override String get id => 'zyla_waze_4814'; @override String get name => 'Zyla Waze Data #4814'; @override Set get supportedCategories => const { RoadAlertCategory.police, RoadAlertCategory.accident, RoadAlertCategory.hazard, RoadAlertCategory.roadClosure, RoadAlertCategory.construction, RoadAlertCategory.traffic, RoadAlertCategory.pothole, RoadAlertCategory.debris, RoadAlertCategory.weatherHazard, RoadAlertCategory.other, }; int _lastRawCount = 0; int get lastRawCount => _lastRawCount; RoadAlertProviderStatus _lastStatus = RoadAlertProviderStatus.notConfigured; RoadAlertProviderStatus get lastStatus => _lastStatus; String? _lastError; String? get lastError => _lastError; bool _lastWasLive = true; bool get lastWasLive => _lastWasLive; Future> _buildHeaders({String? apiKey}) async { final headers = { 'Accept': 'application/json', }; if (getAuthToken != null) { try { final token = await getAuthToken!(); if (token != null && token.isNotEmpty) { headers['Authorization'] = 'Bearer $token'; return headers; } } catch (e) { if (kDebugMode) { debugPrint('[ZylaRoadAlertProvider] Error resolving auth token: $e'); } } } if (apiKey != null && apiKey.isNotEmpty) { headers['Authorization'] = 'Bearer $apiKey'; } return headers; } @override Future testConnection(String apiKey) async { try { final headers = await _buildHeaders(apiKey: apiKey); if (getAuthToken != null && !headers.containsKey('Authorization')) { if (kDebugMode) { debugPrint('[ZylaRoadAlertProvider] testConnection: Missing Authorization token (auth token returned null/empty and no API key provided)'); } _lastStatus = RoadAlertProviderStatus.authError; _lastError = 'Firebase authentication required: please sign in'; return RoadAlertProviderStatus.authError; } final uri = Uri.parse('$backendBaseUrl/api/v1/alerts/ops/health'); final res = await _client.get(uri, headers: headers).timeout(const Duration(seconds: 4)); if (res.statusCode == 200) { final data = json.decode(res.body); final providers = data['providers'] as List? ?? []; for (final p in providers) { if (p['provider_id'] == id) { final status = p['status']; if (status == 'operational') { _lastStatus = RoadAlertProviderStatus.connected; _lastError = null; return RoadAlertProviderStatus.connected; } if (status == 'circuit_open') { _lastStatus = RoadAlertProviderStatus.rateLimited; _lastError = 'Upstream circuit open / quota budget exhausted'; return RoadAlertProviderStatus.rateLimited; } if (status == 'auth_missing') { _lastStatus = RoadAlertProviderStatus.authError; _lastError = 'Upstream gateway credentials not configured on backend'; return RoadAlertProviderStatus.authError; } _lastStatus = RoadAlertProviderStatus.notConfigured; _lastError = 'Provider status: $status'; return RoadAlertProviderStatus.notConfigured; } } _lastStatus = RoadAlertProviderStatus.connected; _lastError = null; return RoadAlertProviderStatus.connected; } if (res.statusCode == 401 || res.statusCode == 403) { if (kDebugMode) { debugPrint('[ZylaRoadAlertProvider] testConnection auth rejected: ${res.statusCode} ${res.body}'); } _lastStatus = RoadAlertProviderStatus.authError; _lastError = 'Firebase authorization token rejected (HTTP ${res.statusCode})'; return RoadAlertProviderStatus.authError; } if (res.statusCode == 429) { _lastStatus = RoadAlertProviderStatus.rateLimited; _lastError = 'Rate limit or monthly quota exceeded (HTTP 429)'; return RoadAlertProviderStatus.rateLimited; } _lastStatus = RoadAlertProviderStatus.networkError; _lastError = 'Backend returned HTTP ${res.statusCode}'; return RoadAlertProviderStatus.networkError; } catch (e) { if (kDebugMode) { debugPrint('[ZylaRoadAlertProvider] testConnection error: $e'); } _lastStatus = RoadAlertProviderStatus.networkError; _lastError = 'Connection failed: $e'; return RoadAlertProviderStatus.networkError; } } @override Future> getAlerts({ required double latitude, required double longitude, required double radiusMeters, String? apiKey, String? fixtureOverride, }) async { _lastWasLive = fixtureOverride == null || fixtureOverride.isEmpty; // Convert center + radius to bounding box const metersPerDegreeLat = 111320.0; final latDelta = radiusMeters / metersPerDegreeLat; final metersPerDegreeLng = metersPerDegreeLat * math.cos(latitude * math.pi / 180.0).abs(); final lngDelta = metersPerDegreeLng > 0 ? radiusMeters / metersPerDegreeLng : latDelta; final minLat = (latitude - latDelta).clamp(-90.0, 90.0); final maxLat = (latitude + latDelta).clamp(-90.0, 90.0); final minLng = (longitude - lngDelta).clamp(-180.0, 180.0); final maxLng = (longitude + lngDelta).clamp(-180.0, 180.0); final queryParams = { 'min_lat': minLat.toStringAsFixed(4), 'min_lng': minLng.toStringAsFixed(4), 'max_lat': maxLat.toStringAsFixed(4), 'max_lng': maxLng.toStringAsFixed(4), if (fixtureOverride != null && fixtureOverride.isNotEmpty) 'fixture_override': fixtureOverride, }; final uri = Uri.parse('$backendBaseUrl/api/v1/alerts/zyla').replace( queryParameters: queryParams, ); try { final headers = await _buildHeaders(apiKey: apiKey); if (getAuthToken != null && !headers.containsKey('Authorization')) { _lastStatus = RoadAlertProviderStatus.authError; _lastError = 'Firebase authentication required: please sign in'; return []; } final response = await _client.get(uri, headers: headers).timeout(const Duration(seconds: 5)); if (response.statusCode == 401 || response.statusCode == 403) { if (kDebugMode) { debugPrint('[ZylaRoadAlertProvider] Backend rejected request with auth status ${response.statusCode}'); } _lastStatus = RoadAlertProviderStatus.authError; _lastError = 'Firebase token expired or unauthorized (${response.statusCode})'; return []; } if (response.statusCode == 429) { _lastStatus = RoadAlertProviderStatus.rateLimited; _lastError = 'Monthly API budget (500 reqs) or rate limit exceeded (429)'; return []; } if (response.statusCode != 200) { if (kDebugMode) { debugPrint('[ZylaRoadAlertProvider] Backend returned status ${response.statusCode}'); } _lastStatus = RoadAlertProviderStatus.networkError; _lastError = 'Backend returned HTTP ${response.statusCode}'; return []; } final data = json.decode(response.body); final rawAlerts = data['alerts'] as List? ?? []; _lastRawCount = rawAlerts.length; _lastStatus = RoadAlertProviderStatus.connected; _lastError = null; final List alerts = []; for (final raw in rawAlerts) { final alert = _parseNormalizedAlert(raw, userLat: latitude, userLng: longitude); if (alert != null) { alerts.add(alert); } } return alerts; } catch (e) { if (kDebugMode) { debugPrint('[ZylaRoadAlertProvider] Failed to fetch alerts: $e'); } _lastStatus = RoadAlertProviderStatus.networkError; _lastError = 'Network connection failed: $e'; return []; // Graceful degradation } } RoadAlert? _parseNormalizedAlert( dynamic raw, { required double userLat, required double userLng, }) { if (raw is! Map) return null; final id = raw['id']?.toString() ?? ''; final catStr = raw['category']?.toString() ?? 'other'; final category = RoadAlertCategory.values.firstWhere( (c) => c.name.toLowerCase() == catStr.toLowerCase(), orElse: () => RoadAlertCategory.other, ); final lat = (raw['latitude'] as num?)?.toDouble() ?? 0.0; final lng = (raw['longitude'] as num?)?.toDouble() ?? 0.0; if (lat < -90.0 || lat > 90.0 || lng < -180.0 || lng > 180.0) return null; final reportedAtStr = raw['reported_at_utc']?.toString(); final reportedAt = reportedAtStr != null ? (DateTime.tryParse(reportedAtStr) ?? DateTime.now()) : DateTime.now(); final expiresAtStr = raw['expires_at_utc']?.toString(); final expiresAt = expiresAtStr != null ? DateTime.tryParse(expiresAtStr) : null; final confidence = (raw['confidence'] as num?)?.toDouble(); // Approximate distance final dLat = (lat - userLat) * 111320.0; final dLng = (lng - userLng) * 111320.0 * math.cos(userLat * math.pi / 180.0); final distance = math.sqrt(dLat * dLat + dLng * dLng); return RoadAlert( id: id, providerId: this.id, category: category, subtype: raw['subtype']?.toString(), latitude: lat, longitude: lng, roadName: raw['road_name']?.toString(), description: raw['description']?.toString() ?? 'Waze Traffic Report', reportedAt: reportedAt, expiresAt: expiresAt, lastUpdated: DateTime.now(), confidence: confidence, severity: category == RoadAlertCategory.police || category == RoadAlertCategory.accident ? RoadAlertSeverity.high : RoadAlertSeverity.medium, source: raw['source']?.toString() ?? name, sourceType: RoadAlertSourceType.providerReported, status: RoadAlertStatus.active, distanceFromUser: distance, confirmations: 1, dismissals: 0, ); } }