// lib/services/retry_service.dart import 'dart:convert'; import 'dart:io'; import 'package:flutter/foundation.dart'; import 'package:path/path.dart' as p; import 'package:environment_monitoring_app/models/in_situ_sampling_data.dart'; import 'package:environment_monitoring_app/services/marine_in_situ_sampling_service.dart'; import 'package:environment_monitoring_app/models/river_in_situ_sampling_data.dart'; import 'package:environment_monitoring_app/services/river_in_situ_sampling_service.dart'; // *** ADDED: Import River Investigative Model and Service *** import 'package:environment_monitoring_app/models/river_inves_manual_sampling_data.dart'; import 'package:environment_monitoring_app/services/river_investigative_sampling_service.dart'; // *** END ADDED *** import 'package:environment_monitoring_app/models/marine_inves_manual_sampling_data.dart'; import 'package:environment_monitoring_app/services/marine_investigative_sampling_service.dart'; import 'package:environment_monitoring_app/models/tarball_data.dart'; import 'package:environment_monitoring_app/services/marine_tarball_sampling_service.dart'; import 'package:environment_monitoring_app/services/api_service.dart'; import 'package:environment_monitoring_app/services/base_api_service.dart'; import 'package:environment_monitoring_app/services/ftp_service.dart'; import 'package:environment_monitoring_app/services/server_config_service.dart'; import 'package:environment_monitoring_app/auth_provider.dart'; /// A dedicated service to manage the queue of failed API, FTP, and complex submission tasks. class RetryService { final DatabaseHelper _dbHelper = DatabaseHelper(); final BaseApiService _baseApiService = BaseApiService(); final FtpService _ftpService = FtpService(); final ServerConfigService _serverConfigService = ServerConfigService(); bool _isProcessing = false; MarineInSituSamplingService? _marineInSituService; RiverInSituSamplingService? _riverInSituService; MarineInvestigativeSamplingService? _marineInvestigativeService; MarineTarballSamplingService? _marineTarballService; // *** ADDED: River Investigative Service member *** RiverInvestigativeSamplingService? _riverInvestigativeService; // *** END ADDED *** AuthProvider? _authProvider; // *** MODIFIED: Added riverInvestigativeService to initialize *** void initialize({ required MarineInSituSamplingService marineInSituService, required RiverInSituSamplingService riverInSituService, required MarineInvestigativeSamplingService marineInvestigativeService, required RiverInvestigativeSamplingService riverInvestigativeService, // <-- Added parameter required MarineTarballSamplingService marineTarballService, required AuthProvider authProvider, }) { _marineInSituService = marineInSituService; _riverInSituService = riverInSituService; _marineInvestigativeService = marineInvestigativeService; _riverInvestigativeService = riverInvestigativeService; // <-- Assign parameter _marineTarballService = marineTarballService; _authProvider = authProvider; } // *** END MODIFIED *** /// Adds a generic, complex task to the queue, to be handled by a background processor. Future queueTask({ required String type, required Map payload, }) async { await _dbHelper.queueFailedRequest({ 'type': type, 'endpoint_or_path': 'N/A', // Not applicable for complex tasks initially 'payload': jsonEncode(payload), 'timestamp': DateTime.now().toIso8601String(), 'status': 'pending', }); debugPrint("Task of type '$type' has been queued for background processing."); } /// Adds a failed API request to the local database queue. Future addApiToQueue({ required String endpoint, required String method, Map? body, Map? fields, Map? files, }) async { // Convert File objects to paths for JSON serialization final serializableFiles = files?.map((key, value) => MapEntry(key, value.path)); final payload = { 'method': method, 'body': body, 'fields': fields, 'files': serializableFiles, // Store paths instead of File objects }; await _dbHelper.queueFailedRequest({ 'type': 'api', 'endpoint_or_path': endpoint, 'payload': jsonEncode(payload), 'timestamp': DateTime.now().toIso8601String(), 'status': 'pending', }); debugPrint("API request for endpoint '$endpoint' has been queued for retry."); } /// Adds a failed FTP upload to the local database queue. Future addFtpToQueue({ required String localFilePath, required String remotePath, required int ftpConfigId, // Added to specify which destination failed }) async { final payload = { 'localFilePath': localFilePath, 'ftpConfigId': ftpConfigId, // Store the specific config ID }; await _dbHelper.queueFailedRequest({ 'type': 'ftp', 'endpoint_or_path': remotePath, 'payload': jsonEncode(payload), 'timestamp': DateTime.now().toIso8601String(), 'status': 'pending', }); debugPrint("FTP upload for file '$localFilePath' to config ID $ftpConfigId has been queued for retry."); } /// Retrieves all tasks currently in the 'pending' state from the queue. Future>> getPendingTasks() { return _dbHelper.getPendingRequests(); } /// Processes the entire queue of pending tasks. Future processRetryQueue() async { if (_isProcessing) { debugPrint("[RetryService] ⏳ Queue is already being processed. Skipping."); return; } _isProcessing = true; debugPrint("[RetryService] ▶️ Starting to process main retry queue..."); final pendingTasks = await getPendingTasks(); if (pendingTasks.isEmpty) { debugPrint("[RetryService] ⏹️ Queue is empty. Nothing to process."); _isProcessing = false; return; } // Check internet connection *before* processing if (_authProvider == null || !await _authProvider!.isConnected()) { debugPrint("[RetryService] ❌ No internet connection. Aborting queue processing."); _isProcessing = false; return; } debugPrint("[RetryService] 🔎 Found ${pendingTasks.length} pending tasks."); // Process tasks one by one for (final task in pendingTasks) { // Add safety check in case a task is deleted mid-processing by another call if (await _dbHelper.getRequestById(task['id'] as int) != null) { await retryTask(task['id'] as int); } // Optional: Add a small delay between tasks if needed // await Future.delayed(Duration(seconds: 1)); } debugPrint("[RetryService] ⏹️ Finished processing retry queue."); _isProcessing = false; } /// Attempts to re-execute a single failed task from the queue. /// Returns `true` on success, `false` on failure. Future retryTask(int taskId) async { final task = await _dbHelper.getRequestById(taskId); if (task == null) { debugPrint("Retry failed: Task with ID $taskId not found in the queue (might have been processed already)."); return false; // Task doesn't exist or was processed elsewhere } bool success = false; Map payload; // Declare outside try-catch final String taskType = task['type'] as String; // Get type early for logging try { payload = jsonDecode(task['payload'] as String); // Decode payload inside try } catch (e) { debugPrint("Error decoding payload for task $taskId (Type: $taskType): $e. Removing invalid task."); await _dbHelper.deleteRequestFromQueue(taskId); return false; // Cannot process without valid payload } try { // Ensure AuthProvider is initialized and we are online (checked in processRetryQueue) if (_authProvider == null) { debugPrint("RetryService has not been initialized. Cannot process task $taskId."); return false; } // --- Complex Task Handlers --- if (taskType == 'insitu_submission') { debugPrint("Retrying complex task 'insitu_submission' with ID $taskId."); if (_marineInSituService == null) { debugPrint("Retry failed: MarineInSituSamplingService not initialized."); return false; } final String logDirectoryPath = payload['localLogPath']; // Path to the directory final jsonFilePath = p.join(logDirectoryPath, 'data.json'); final file = File(jsonFilePath); if (!await file.exists()) { debugPrint("Retry failed: Source log file no longer exists at $jsonFilePath"); await _dbHelper.deleteRequestFromQueue(taskId); // Remove invalid task return false; } final content = await file.readAsString(); final jsonData = jsonDecode(content) as Map; final InSituSamplingData dataToResubmit = InSituSamplingData.fromJson(jsonData); // Re-run the original submission logic, passing the log directory final result = await _marineInSituService!.submitInSituSample( data: dataToResubmit, appSettings: _authProvider!.appSettings, // Get current settings authProvider: _authProvider!, logDirectory: logDirectoryPath, // Pass directory to update log ); success = result['success']; } else if (taskType == 'river_insitu_submission') { debugPrint("Retrying complex task 'river_insitu_submission' with ID $taskId."); if (_riverInSituService == null) { debugPrint("Retry failed: RiverInSituSamplingService not initialized."); return false; } final String jsonFilePath = payload['localLogPath']; // Path to the JSON file final file = File(jsonFilePath); if (!await file.exists()) { debugPrint("Retry failed: Source log file no longer exists at $jsonFilePath"); await _dbHelper.deleteRequestFromQueue(taskId); return false; } final String logDirectoryPath = p.dirname(jsonFilePath); // Get directory from file path final content = await file.readAsString(); final jsonData = jsonDecode(content) as Map; final RiverInSituSamplingData dataToResubmit = RiverInSituSamplingData.fromJson(jsonData); final result = await _riverInSituService!.submitData( data: dataToResubmit, appSettings: _authProvider!.appSettings, authProvider: _authProvider!, logDirectory: logDirectoryPath, ); success = result['success']; // *** ADDED: Handler for river_investigative_submission *** } else if (taskType == 'river_investigative_submission') { debugPrint("Retrying complex task 'river_investigative_submission' with ID $taskId."); if (_riverInvestigativeService == null) { debugPrint("Retry failed: RiverInvestigativeSamplingService not initialized."); return false; } final String jsonFilePath = payload['localLogPath']; // Path to the JSON file final file = File(jsonFilePath); if (!await file.exists()) { debugPrint("Retry failed: Source log file no longer exists at $jsonFilePath"); await _dbHelper.deleteRequestFromQueue(taskId); return false; } final String logDirectoryPath = p.dirname(jsonFilePath); // Get directory from file path final content = await file.readAsString(); final jsonData = jsonDecode(content) as Map; // Use the correct Investigative data model final RiverInvesManualSamplingData dataToResubmit = RiverInvesManualSamplingData.fromJson(jsonData); // Call the submitData method from the Investigative service final result = await _riverInvestigativeService!.submitData( data: dataToResubmit, appSettings: _authProvider!.appSettings, authProvider: _authProvider!, logDirectory: logDirectoryPath, ); success = result['success']; // *** END ADDED *** } else if (taskType == 'investigative_submission') { debugPrint("Retrying complex task 'investigative_submission' with ID $taskId."); if (_marineInvestigativeService == null) { debugPrint("Retry failed: MarineInvestigativeSamplingService not initialized."); return false; } final String logDirectoryPath = payload['localLogPath']; // Path to the directory final jsonFilePath = p.join(logDirectoryPath, 'data.json'); final file = File(jsonFilePath); if (!await file.exists()) { debugPrint("Retry failed: Source log file no longer exists at $jsonFilePath"); await _dbHelper.deleteRequestFromQueue(taskId); return false; } final content = await file.readAsString(); final jsonData = jsonDecode(content) as Map; final MarineInvesManualSamplingData dataToResubmit = MarineInvesManualSamplingData.fromJson(jsonData); final result = await _marineInvestigativeService!.submitInvestigativeSample( data: dataToResubmit, appSettings: _authProvider!.appSettings, authProvider: _authProvider!, logDirectory: logDirectoryPath, ); success = result['success']; } else if (taskType == 'tarball_submission') { debugPrint("Retrying complex task 'tarball_submission' with ID $taskId."); if (_marineTarballService == null) { debugPrint("Retry failed: MarineTarballSamplingService not initialized."); return false; } final String logDirectoryPath = payload['localLogPath']; // Path to the directory final jsonFilePath = p.join(logDirectoryPath, 'data.json'); final file = File(jsonFilePath); if (!await file.exists()) { debugPrint("Retry failed: Source log file no longer exists at $jsonFilePath"); await _dbHelper.deleteRequestFromQueue(taskId); return false; } final content = await file.readAsString(); final jsonData = jsonDecode(content) as Map; // Recreate File objects from paths File? fileFromJson(dynamic path) => (path is String && path.isNotEmpty) ? File(path) : null; final TarballSamplingData dataToResubmit = TarballSamplingData() // Reconstruct the object from JSON data ..firstSampler = jsonData['firstSampler'] ..firstSamplerUserId = jsonData['firstSamplerUserId'] ..secondSampler = jsonData['secondSampler'] ..samplingDate = jsonData['samplingDate'] ..samplingTime = jsonData['samplingTime'] ..selectedStateName = jsonData['selectedStateName'] ..selectedCategoryName = jsonData['selectedCategoryName'] ..selectedStation = jsonData['selectedStation'] ..stationLatitude = jsonData['stationLatitude'] ..stationLongitude = jsonData['stationLongitude'] ..currentLatitude = jsonData['currentLatitude'] ..currentLongitude = jsonData['currentLongitude'] ..distanceDifference = jsonData['distanceDifference'] is num ? (jsonData['distanceDifference'] as num).toDouble() : null // Safe cast ..distanceDifferenceRemarks = jsonData['distanceDifferenceRemarks'] ..classificationId = jsonData['classificationId'] is num ? (jsonData['classificationId'] as num).toInt() : null // Safe cast ..selectedClassification = jsonData['selectedClassification'] ..leftCoastalViewImage = fileFromJson(jsonData['leftCoastalViewImage']) ..rightCoastalViewImage = fileFromJson(jsonData['rightCoastalViewImage']) ..verticalLinesImage = fileFromJson(jsonData['verticalLinesImage']) ..horizontalLineImage = fileFromJson(jsonData['horizontalLineImage']) ..optionalImage1 = fileFromJson(jsonData['optionalImage1']) ..optionalRemark1 = jsonData['optionalRemark1'] ..optionalImage2 = fileFromJson(jsonData['optionalImage2']) ..optionalRemark2 = jsonData['optionalRemark2'] ..optionalImage3 = fileFromJson(jsonData['optionalImage3']) ..optionalRemark3 = jsonData['optionalRemark3'] ..optionalImage4 = fileFromJson(jsonData['optionalImage4']) ..optionalRemark4 = jsonData['optionalRemark4'] ..reportId = jsonData['reportId'] // Preserve reportId if it exists ..submissionStatus = jsonData['submissionStatus'] // Preserve status info ..submissionMessage = jsonData['submissionMessage']; debugPrint("Retrying Tarball submission..."); // Pass null for BuildContext, and the logDirectory path final result = await _marineTarballService!.submitTarballSample( data: dataToResubmit, appSettings: _authProvider!.appSettings, context: null, // Pass null for BuildContext during retry logDirectory: logDirectoryPath, // Pass the directory path for potential update ); success = result['success']; // --- Simple Task Handlers --- } else if (taskType == 'api') { final endpoint = task['endpoint_or_path'] as String; final method = payload['method'] as String; final baseUrl = await _serverConfigService.getActiveApiUrl(); // Get current active URL debugPrint("Retrying API task $taskId: $method to $baseUrl/$endpoint"); Map result; if (method == 'POST_MULTIPART') { final Map fields = Map.from(payload['fields'] ?? {}); // Recreate File objects from paths stored in the payload final Map files = (payload['files'] as Map?) ?.map((key, value) => MapEntry(key, File(value as String))) ?? {}; // Check if files still exist before attempting upload bool allFilesExist = true; List missingFiles = []; // Keep track of missing files for (var entry in files.entries) { File file = entry.value; if (!await file.exists()) { debugPrint("Retry failed for API task $taskId: File ${file.path} (key: ${entry.key}) no longer exists."); allFilesExist = false; missingFiles.add(entry.key); // break; // Stop checking further if one is missing } } // If some files are missing, fail the entire task. if (!allFilesExist) { debugPrint("API Multipart retry failed for task $taskId because files are missing: ${missingFiles.join(', ')}. Removing task."); await _dbHelper.deleteRequestFromQueue(taskId); // Remove invalid task return false; } result = await _baseApiService.postMultipart(baseUrl: baseUrl, endpoint: endpoint, fields: fields, files: files); } else { // Standard POST final Map body = Map.from(payload['body'] ?? {}); result = await _baseApiService.post(baseUrl, endpoint, body); } success = result['success']; } else if (taskType == 'ftp') { final remotePath = task['endpoint_or_path'] as String; final localFile = File(payload['localFilePath'] as String); final int? ftpConfigId = payload['ftpConfigId'] as int?; debugPrint("Retrying FTP task $taskId: Uploading ${localFile.path} to $remotePath using config ID $ftpConfigId"); if (ftpConfigId == null) { debugPrint("Retry failed for FTP task $taskId: Missing FTP configuration ID in payload."); await _dbHelper.deleteRequestFromQueue(taskId); // Remove invalid task return false; } if (await localFile.exists()) { final ftpConfigs = await _dbHelper.loadFtpConfigs() ?? []; final config = ftpConfigs.firstWhere((c) => c['ftp_config_id'] == ftpConfigId, orElse: () => {}); // Use explicit type if (config.isEmpty) { debugPrint("Retry failed for FTP task $taskId: FTP configuration with ID $ftpConfigId not found."); return false; // Fail the retry attempt, keep in queue } // Attempt upload using the specific config final result = await _ftpService.uploadFile(config: config, fileToUpload: localFile, remotePath: remotePath); success = result['success']; } else { debugPrint("Retry failed for FTP task $taskId: Source file no longer exists at ${localFile.path}"); await _dbHelper.deleteRequestFromQueue(taskId); // Remove task if file is gone return false; // Explicitly return false as success is false } } else { debugPrint("Unknown task type '$taskType' for task ID $taskId. Cannot retry. Removing task."); await _dbHelper.deleteRequestFromQueue(taskId); } } on SessionExpiredException catch (e) { debugPrint("Session expired during retry attempt for task $taskId (Type: $taskType): $e. Task remains in queue."); success = false; // Session expiry during retry means failure for this attempt } catch (e, stacktrace) { // Catch potential exceptions during processing debugPrint("A critical error occurred while retrying task $taskId (Type: $taskType): $e"); debugPrint("Stacktrace: $stacktrace"); // Log stacktrace for detailed debugging success = false; // Ensure success is false on exception } // Post-processing: Remove successful tasks from queue if (success) { debugPrint("Task $taskId (Type: $taskType) completed successfully. Removing from queue."); await _dbHelper.deleteRequestFromQueue(taskId); // If it was a complex task involving temporary ZIP files, attempt to delete them if (taskType.endsWith('_submission') && payload['localLogPath'] != null) { // Assume localLogPath points to the JSON file, get directory for cleanup String pathToCheck = payload['localLogPath']; // Check if it's a directory path already (for older marine insitu logs) bool isDirectory = await Directory(pathToCheck).exists(); if (!isDirectory && pathToCheck.endsWith('.json')) { pathToCheck = p.dirname(pathToCheck); // Get directory if it's a file path isDirectory = true; // Now we are checking the directory } _cleanUpTemporaryZipFiles(pathToCheck, isDirectory: isDirectory); } // If it was an FTP task, attempt to delete the temporary ZIP file if (taskType == 'ftp' && payload['localFilePath'] != null && (payload['localFilePath'] as String).endsWith('.zip')) { _cleanUpTemporaryZipFiles(payload['localFilePath'], isDirectory: false); } } else { debugPrint("Retry attempt for task $taskId (Type: $taskType) failed. It will remain in the queue."); // Optional: Implement a retry limit here. If retries > X, mark task as 'failed' instead of 'pending'. // e.g., await _dbHelper.updateTaskStatus(taskId, 'failed'); } return success; } /// Helper function to delete temporary zip files after successful retry. void _cleanUpTemporaryZipFiles(String path, {required bool isDirectory}) async { try { if (isDirectory) { final dir = Directory(path); if (await dir.exists()) { final filesInDir = dir.listSync(); for (var entity in filesInDir) { // Delete only ZIP files within the log directory if (entity is File && entity.path.endsWith('.zip')) { debugPrint("Deleting temporary zip file from directory: ${entity.path}"); await entity.delete(); } } // Optional: Delete the directory itself if now empty, ONLY if safe. // Be cautious as data.json might still be needed or other files exist. // if (await dir.listSync().isEmpty) { // await dir.delete(); // debugPrint("Deleted empty log directory: ${dir.path}"); // } } else { debugPrint("Log directory not found for cleanup: $path"); } } else { // If it's a specific file path (like from FTP task) final file = File(path); if (await file.exists() && path.endsWith('.zip')) { // Ensure it's a zip file debugPrint("Deleting temporary zip file: ${file.path}"); await file.delete(); } else if (!path.endsWith('.zip')) { debugPrint("Skipping cleanup for non-zip file path: $path"); } else { debugPrint("Temporary zip file not found for cleanup: $path"); } } } catch (e) { debugPrint("Error cleaning up temporary zip file(s) for path $path: $e"); } } } // End of RetryService class