import 'dart:convert'; import 'dart:io'; import 'package:drift/drift.dart'; import 'package:flutter/foundation.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:http/http.dart' as http; import 'package:intl/intl.dart'; import 'package:shared_preferences/shared_preferences.dart'; import '../../core/version/app_identity.dart'; import '../../database/database.dart'; import '../../features/records/records_service.dart'; import '../auth/auth_providers.dart'; import '../auth/auth_service.dart'; import '../export_service.dart'; import '../field_test/field_test_export_service.dart'; import '../sync/firebase_sync_provider.dart'; import '../sync/sync_models.dart'; import 'github_auth_service.dart'; enum GitHubUploadState { idle, queued, uploading, success, failed, authRequired } class GitHubUploadResult { final bool success; final String message; final String? commitSha; final String? targetPath; final DateTime timestamp; GitHubUploadResult({ required this.success, required this.message, this.commitSha, this.targetPath, DateTime? timestamp, }) : timestamp = timestamp ?? DateTime.now(); } /// Compatibility facade for the historical ride-detail sync service. /// /// Normal MotorSignal cloud backup is Firebase Authentication + Firestore + R2. /// The historical GitHub PAT exporter remains available only through the /// explicit `uploadRideToGitHub` developer/export path and is never used by /// the normal History -> Upload Now action. class GitHubSyncService extends ChangeNotifier { final AppDatabase _db; final GitHubAuthService _githubAuth; final AuthService _authService; final SharedPreferences _prefs; final http.Client _client; GitHubUploadState _state = GitHubUploadState.idle; String _statusMessage = ''; final Set _uploadQueue = {}; /// Keeps the historical constructor compatible with tests/callers that pass /// an HTTP client as the optional fourth argument. Production additionally /// supplies the shared Firebase AuthService as the fifth argument. GitHubSyncService( this._db, this._githubAuth, this._prefs, [ http.Client? client, AuthService? authService, ]) : _authService = authService ?? FirebaseAuthService(), _client = client ?? http.Client(); GitHubUploadState get state => _state; String get statusMessage => _statusMessage; Set get queuedRideIds => Set.unmodifiable(_uploadQueue); String? _userScopedKey(String key, int rideId) { final uid = _authService.currentUser?.uid; if (uid == null || uid.isEmpty) return null; return '${key}_${uid}_$rideId'; } bool isRideUploaded(int rideId) { final key = _userScopedKey('motorcloud_uploaded_ride', rideId); if (key != null) return _prefs.getBool(key) ?? false; // Legacy unscoped key – used when no authenticated user is available. return _prefs.getBool('motorcloud_uploaded_ride_$rideId') ?? false; } String? getRideCommitSha(int rideId) { final key = _userScopedKey('motorcloud_ride_remote_id', rideId); if (key != null) return _prefs.getString(key); return _prefs.getString('motorcloud_ride_remote_id_$rideId'); } String? getRideUploadTimestamp(int rideId) { final key = _userScopedKey('motorcloud_uploaded_at_ride', rideId); return key == null ? null : _prefs.getString(key); } void queueRideForUpload(int rideId) { if (isRideUploaded(rideId)) return; _uploadQueue.add(rideId); _state = GitHubUploadState.queued; _statusMessage = 'Ride #$rideId queued for cloud synchronization'; notifyListeners(); processQueue(); } Future processQueue() async { if (_uploadQueue.isEmpty || _state == GitHubUploadState.uploading) return; final toProcess = List.from(_uploadQueue); for (final rideId in toProcess) { await uploadRide(rideId); } } /// Normal History -> Upload Now operation. /// /// Deliberately ignores any configured GitHub PAT. A user's ride must be /// written to that user's MotorCloud account, never to the developer's /// public repository. Future uploadRide(int rideId, {bool isManual = false}) async { return _uploadToMotorCloud(rideId); } /// Explicit legacy/developer export. Not used by the normal ride UI. Future uploadRideToGitHub(int rideId) async { final token = _githubAuth.getToken(); if (token == null || token.isEmpty) { return GitHubUploadResult( success: false, message: 'GitHub export requires an explicitly configured GitHub token.', ); } return _uploadToGitHub(rideId, token); } Future _uploadToMotorCloud(int rideId) async { final user = _authService.currentUser; if (user == null || user.isAnonymous) { _state = GitHubUploadState.authRequired; _statusMessage = 'Sign in to MotorCloud before uploading a ride.'; notifyListeners(); return GitHubUploadResult(success: false, message: _statusMessage); } if (isRideUploaded(rideId)) { _state = GitHubUploadState.success; _statusMessage = 'Ride #$rideId is already backed up to your MotorCloud account.'; notifyListeners(); return GitHubUploadResult( success: true, message: _statusMessage, commitSha: getRideCommitSha(rideId), targetPath: 'MotorCloud', ); } _state = GitHubUploadState.uploading; _statusMessage = 'Backing up Ride #$rideId to MotorCloud...'; notifyListeners(); try { final provider = FirebaseSyncProvider(db: _db, authService: _authService); final result = await provider.uploadRide(SyncItem( localRideId: rideId, providerId: 'firebase', createdAt: DateTime.now(), )); if (result.success) { await _prefs.setBool(_userScopedKey('motorcloud_uploaded_ride', rideId)!, true); await _prefs.setString( _userScopedKey('motorcloud_uploaded_at_ride', rideId)!, DateTime.now().toIso8601String(), ); if (result.remoteId != null) { await _prefs.setString( _userScopedKey('motorcloud_ride_remote_id', rideId)!, result.remoteId!, ); } _uploadQueue.remove(rideId); _state = GitHubUploadState.success; _statusMessage = 'Ride #$rideId backed up to your MotorCloud account'; notifyListeners(); return GitHubUploadResult( success: true, message: _statusMessage, commitSha: result.remoteId, targetPath: 'MotorCloud', ); } _state = result.requiresAuth ? GitHubUploadState.authRequired : GitHubUploadState.failed; _statusMessage = result.errorMessage ?? 'MotorCloud backup failed'; notifyListeners(); return GitHubUploadResult(success: false, message: _statusMessage); } catch (e) { _state = GitHubUploadState.failed; _statusMessage = 'MotorCloud backup failed: $e'; notifyListeners(); return GitHubUploadResult(success: false, message: _statusMessage); } } Future _uploadToGitHub(int rideId, String token) async { _state = GitHubUploadState.uploading; _statusMessage = 'Uploading Ride #$rideId to GitHub...'; notifyListeners(); try { final ride = await (_db.select(_db.rides)..where((r) => r.id.equals(rideId))).getSingleOrNull(); if (ride == null) throw HttpException('Ride #$rideId not found in local database'); final dateStr = DateFormat('yyyy-MM-dd-HHmmss').format(ride.startTime.toLocal()); final folderName = 'field_test_session_${rideId}_$dateStr/field_test_session'; final repo = _githubAuth.getRepository(); final branch = _githubAuth.getBranch(); final fieldTestExport = FieldTestExportService(_db); final exportService = ExportService(_db); final sessionJson = await fieldTestExport.generateFieldTestSessionJson(rideId); final eventsCsv = await fieldTestExport.generateFieldTestEventsCsv(rideId); final telemetryCsv = await exportService.exportRideCsv(rideId); final gpxContent = await exportService.exportRideGpx(rideId); final samples = await (_db.select(_db.telemetrySamples) ..where((t) => t.rideId.equals(rideId)) ..orderBy([(t) => OrderingTerm(expression: t.timestamp)])).get(); final events = await (_db.select(_db.fieldTestEvents) ..where((e) => e.rideId.equals(rideId)) ..orderBy([(e) => OrderingTerm(expression: e.timestamp)])).get(); final summaryRecord = await (_db.select(_db.fieldTestSummaries) ..where((s) => s.rideId.equals(rideId))).getSingleOrNull(); final appIdentity = AppIdentity.current(); final readme = StringBuffer() ..writeln('=====================================================') ..writeln('MOTORCYCLE DASH — FORENSIC FIELD TEST SESSION REPORT') ..writeln('=====================================================') ..writeln('Application Version: ${appIdentity.semanticVersion}') ..writeln('Build Number: ${appIdentity.buildNumber}') ..writeln('Git Revision: ${appIdentity.gitCommit}') ..writeln('Git Branch: ${appIdentity.gitBranch}') ..writeln('Release Channel: ${appIdentity.releaseChannel}') ..writeln('Telemetry Schema Version: ${appIdentity.telemetrySchemaVersion}') ..writeln('Export Format Version: ${appIdentity.exportSchemaVersion}') ..writeln('Build Timestamp: ${appIdentity.buildTimestamp}') ..writeln('Field Test Session / Ride ID: $rideId') ..writeln('Session Start: ${ride.startTime.toIso8601String()}') ..writeln('Session End: ${ride.endTime?.toIso8601String() ?? "IN_PROGRESS"}') ..writeln('Export Timestamp: ${DateTime.now().toIso8601String()}') ..writeln('Summary Result: ${summaryRecord?.rideQualityRating ?? "COMPLETED"}') ..writeln('Ride Quality Score: ${summaryRecord?.rideQualityScore ?? 100} / 100') ..writeln('Total Telemetry Samples: ${samples.length}') ..writeln('Total Forensic Events: ${events.length}'); final rideSummaryJson = const JsonEncoder.withIndent(' ').convert({ 'rideId': ride.id, 'startTime': ride.startTime.toIso8601String(), 'endTime': ride.endTime?.toIso8601String(), 'distanceMiles': ride.distanceMiles, 'topSpeedMph': ride.topSpeedMph, 'maxLeanLeft': ride.maxLeanLeft, 'maxLeanRight': ride.maxLeanRight, 'maxAccelG': ride.maxAccelG, 'maxBrakingG': ride.maxBrakingG, 'averageSpeed': ride.averageSpeed, 'movingTime': ride.movingTime, 'stoppedTime': ride.stoppedTime, 'qualityScore': summaryRecord?.rideQualityScore ?? 100, 'qualityRating': summaryRecord?.rideQualityRating ?? 'EXCELLENT', 'software': appIdentity.toJson(), }); final filesToUpload = { '$folderName/README.txt': readme.toString(), '$folderName/ride_summary.json': rideSummaryJson, '$folderName/field_test_session.json': sessionJson, '$folderName/field_test_events.csv': eventsCsv, '$folderName/telemetry.csv': telemetryCsv, }; if (gpxContent.isNotEmpty) filesToUpload['$folderName/telemetry.gpx'] = gpxContent; String? lastCommitSha; for (final entry in filesToUpload.entries) { final filePath = entry.key; final getRes = await _client.get( Uri.parse('https://api.github.com/repos/$repo/contents/$filePath?ref=$branch'), headers: { 'Authorization': 'Bearer $token', 'Accept': 'application/vnd.github.v3+json', 'User-Agent': 'MotorSignal-Telemetry/1.0.2', }, ).timeout(const Duration(seconds: 10)); String? existingSha; if (getRes.statusCode == 200) existingSha = jsonDecode(getRes.body)['sha']; final payload = { 'message': 'feat(telemetry): upload ride #$rideId forensic session data [v1.0.2+102]', 'content': base64.encode(utf8.encode(entry.value)), 'branch': branch, }; if (existingSha != null) payload['sha'] = existingSha; final putRes = await _client.put( Uri.parse('https://api.github.com/repos/$repo/contents/$filePath'), headers: { 'Authorization': 'Bearer $token', 'Accept': 'application/vnd.github.v3+json', 'Content-Type': 'application/json', 'User-Agent': 'MotorSignal-Telemetry/1.0.2', }, body: jsonEncode(payload), ).timeout(const Duration(seconds: 15)); if (putRes.statusCode == 200 || putRes.statusCode == 201) { lastCommitSha = jsonDecode(putRes.body)['commit']?['sha']; } else if (putRes.statusCode == 401 || putRes.statusCode == 403) { throw HttpException('GitHub authorization rejected the upload (HTTP ${putRes.statusCode}).'); } else { throw HttpException('Failed uploading $filePath (Status: ${putRes.statusCode})'); } } _state = GitHubUploadState.success; _statusMessage = 'Ride #$rideId exported to $repo ($branch)'; notifyListeners(); return GitHubUploadResult(success: true, message: _statusMessage, commitSha: lastCommitSha, targetPath: folderName); } catch (e) { _state = GitHubUploadState.failed; _statusMessage = 'GitHub export failed for Ride #$rideId: $e'; notifyListeners(); return GitHubUploadResult(success: false, message: _statusMessage); } } } final githubSyncServiceProvider = ChangeNotifierProvider((ref) { final db = ref.watch(databaseProvider); final githubAuth = ref.watch(githubAuthServiceProvider); final prefs = ref.watch(sharedPreferencesProvider); final authService = ref.watch(authServiceProvider); return GitHubSyncService(db, githubAuth, prefs, null, authService); });