// lib/services/marine_tarball_sampling_service.dart import 'dart:io'; import 'dart:convert'; import 'dart:async'; // Added for TimeoutException import 'package:flutter/foundation.dart'; import 'package:path/path.dart' as p; import 'package:connectivity_plus/connectivity_plus.dart'; import 'package:provider/provider.dart'; import 'package:flutter/material.dart'; import 'package:environment_monitoring_app/models/tarball_data.dart'; import 'package:environment_monitoring_app/services/local_storage_service.dart'; import 'package:environment_monitoring_app/services/server_config_service.dart'; import 'package:environment_monitoring_app/services/zipping_service.dart'; import 'package:environment_monitoring_app/services/api_service.dart'; import 'package:environment_monitoring_app/services/submission_api_service.dart'; import 'package:environment_monitoring_app/services/submission_ftp_service.dart'; import 'package:environment_monitoring_app/services/telegram_service.dart'; import 'package:environment_monitoring_app/services/retry_service.dart'; import 'package:environment_monitoring_app/auth_provider.dart'; import 'package:environment_monitoring_app/services/base_api_service.dart'; // Import for SessionExpiredException /// A dedicated service to handle all business logic for the Marine Tarball Sampling feature. class MarineTarballSamplingService { final SubmissionApiService _submissionApiService = SubmissionApiService(); final SubmissionFtpService _submissionFtpService = SubmissionFtpService(); final ZippingService _zippingService = ZippingService(); final LocalStorageService _localStorageService = LocalStorageService(); final ServerConfigService _serverConfigService = ServerConfigService(); final DatabaseHelper _dbHelper = DatabaseHelper(); final RetryService _retryService = RetryService(); final TelegramService _telegramService; MarineTarballSamplingService(this._telegramService); Future> submitTarballSample({ required TarballSamplingData data, required List>? appSettings, // --- START FIX: Make BuildContext nullable --- required BuildContext? context, // --- END FIX --- String? logDirectory, // Added for retry consistency }) async { const String moduleName = 'marine_tarball'; // --- START FIX: Handle nullable context --- final authProvider = context != null ? Provider.of(context, listen: false) : null; // Need a fallback mechanism if context is null (e.g., during retry) // One option is to ensure AuthProvider is always accessible, maybe via a singleton or passed differently. // For now, we'll proceed assuming authProvider might be null during retry, // which could affect session checks. Consider injecting AuthProvider if needed globally. if (authProvider == null && context != null) { // If context was provided but provider failed, log error debugPrint("Error: AuthProvider not found in context for Tarball submission."); return {'success': false, 'message': 'Internal error: AuthProvider not available.'}; } // --- END FIX --- final connectivityResult = await Connectivity().checkConnectivity(); bool isOnline = !connectivityResult.contains(ConnectivityResult.none); // --- START FIX: Handle potentially null authProvider --- bool isOfflineSession = authProvider != null && authProvider.isLoggedIn && (authProvider.profileData?['token']?.startsWith("offline-session-") ?? false); if (isOnline && isOfflineSession && authProvider != null) { debugPrint("Tarball submission online during an offline session. Attempting auto-relogin..."); try { final bool transitionSuccess = await authProvider.checkAndTransitionToOnlineSession(); if (transitionSuccess) { isOfflineSession = false; } else { isOnline = false; // Auto-relogin failed, treat as offline } } on SessionExpiredException catch (_) { debugPrint("Session expired during auto-relogin check. Treating as offline."); isOnline = false; } } // --- END FIX --- if (isOnline && !isOfflineSession) { debugPrint("Proceeding with direct ONLINE Tarball submission..."); return await _performOnlineSubmission( data: data, appSettings: appSettings, moduleName: moduleName, authProvider: authProvider, // Pass potentially null provider logDirectory: logDirectory, ); } else { debugPrint("Proceeding with OFFLINE Tarball queuing mechanism..."); return await _performOfflineQueuing( data: data, moduleName: moduleName, logDirectory: logDirectory, // Pass logDirectory for potential update ); } } Future> _performOnlineSubmission({ required TarballSamplingData data, required List>? appSettings, required String moduleName, required AuthProvider? authProvider, // Accept potentially null provider String? logDirectory, // Added for retry consistency }) async { final serverName = (await _serverConfigService.getActiveApiConfig())?['config_name'] as String? ?? 'Default'; final imageFiles = data.toImageFiles()..removeWhere((key, value) => value == null); final finalImageFiles = imageFiles.cast(); bool anyApiSuccess = false; Map apiDataResult = {}; Map apiImageResult = {}; String finalMessage = ''; String finalStatus = ''; bool isSessionKnownToBeExpired = false; try { // 1. Submit Form Data apiDataResult = await _submissionApiService.submitPost( moduleName: moduleName, endpoint: 'marine/tarball/sample', // Correct endpoint body: data.toFormData(), // Use specific method for tarball form data ); if (apiDataResult['success'] == true) { anyApiSuccess = true; data.reportId = apiDataResult['data']?['autoid']?.toString(); // Correct ID key if (data.reportId != null) { if (finalImageFiles.isNotEmpty) { // 2. Submit Images apiImageResult = await _submissionApiService.submitMultipart( moduleName: moduleName, endpoint: 'marine/tarball/images', // Correct endpoint fields: {'autoid': data.reportId!}, // Correct field key files: finalImageFiles, ); if (apiImageResult['success'] != true) { anyApiSuccess = false; // Downgrade success if images fail } } // If data succeeded but no images, API part is still successful } else { anyApiSuccess = false; apiDataResult['message'] = 'API Error: Submission succeeded but did not return a record ID.'; } } // If apiDataResult['success'] is false, SubmissionApiService queued it. } on SessionExpiredException catch (_) { debugPrint("API submission failed with SessionExpiredException during online submission."); isSessionKnownToBeExpired = true; anyApiSuccess = false; apiDataResult = {'success': false, 'message': 'Session expired. API submission queued.'}; // Manually queue API calls await _retryService.addApiToQueue(endpoint: 'marine/tarball/sample', method: 'POST', body: data.toFormData()); if (finalImageFiles.isNotEmpty && data.reportId != null) { // Queue images if data might have partially succeeded await _retryService.addApiToQueue(endpoint: 'marine/tarball/images', method: 'POST_MULTIPART', fields: {'autoid': data.reportId!}, files: finalImageFiles); } } // 3. Submit FTP Files Map ftpResults = {'statuses': []}; bool anyFtpSuccess = false; if (isSessionKnownToBeExpired) { debugPrint("Skipping FTP attempt due to known expired session. Manually queuing FTP tasks."); final baseFileNameForQueue = _generateBaseFileName(data); // Use helper // --- START FIX: Add ftpConfigId when queuing --- final ftpConfigs = await _dbHelper.loadFtpConfigs() ?? []; final dataZip = await _zippingService.createDataZip( jsonDataMap: { // Use specific JSON structures for Tarball FTP 'data.json': jsonEncode(data.toDbJson()), 'basic_form.json': jsonEncode(data.toBasicFormJson()), 'reading.json': jsonEncode(data.toReadingJson()), 'manual_info.json': jsonEncode(data.toManualInfoJson()), }, baseFileName: baseFileNameForQueue, destinationDir: null, ); if (dataZip != null) { // Queue for each config separately for (final config in ftpConfigs) { final configId = config['ftp_config_id']; if (configId != null) { await _retryService.addFtpToQueue( localFilePath: dataZip.path, remotePath: '/${p.basename(dataZip.path)}', ftpConfigId: configId // Provide the specific config ID ); } } } if (finalImageFiles.isNotEmpty) { final imageZip = await _zippingService.createImageZip( imageFiles: finalImageFiles.values.toList(), baseFileName: baseFileNameForQueue, destinationDir: null, ); if (imageZip != null) { // Queue for each config separately for (final config in ftpConfigs) { final configId = config['ftp_config_id']; if (configId != null) { await _retryService.addFtpToQueue( localFilePath: imageZip.path, remotePath: '/${p.basename(imageZip.path)}', ftpConfigId: configId // Provide the specific config ID ); } } } } // --- END FIX --- ftpResults = {'statuses': [{'status': 'Queued', 'message': 'FTP upload queued due to API session issue.', 'success': false}]}; anyFtpSuccess = false; } else { try { ftpResults = await _generateAndUploadFtpFiles(data, finalImageFiles, serverName, moduleName); anyFtpSuccess = !(ftpResults['statuses'] as List).any((status) => status['success'] == false && status['status'] != 'Not Configured'); } catch (e) { debugPrint("Unexpected FTP submission error: $e"); anyFtpSuccess = false; } } // 4. Determine Final Status final bool overallSuccess = anyApiSuccess || anyFtpSuccess; if (anyApiSuccess && anyFtpSuccess) { finalMessage = 'Data submitted successfully to all destinations.'; finalStatus = 'S4'; } else if (anyApiSuccess && !anyFtpSuccess) { finalMessage = 'Data sent to API, but some FTP uploads failed or were queued.'; finalStatus = 'S3'; } else if (!anyApiSuccess && anyFtpSuccess) { finalMessage = 'API submission failed and was queued, but files were sent to FTP successfully.'; finalStatus = 'L4'; } else { finalMessage = apiDataResult['message'] ?? 'All submission attempts failed and have been queued for retry.'; finalStatus = 'L1'; } // 5. Log Locally await _logAndSave( data: data, status: finalStatus, message: finalMessage, apiResults: [apiDataResult, apiImageResult], ftpStatuses: ftpResults['statuses'], serverName: serverName, finalImageFiles: finalImageFiles, logDirectory: logDirectory, // Pass logDirectory for potential update ); // 6. Send Alert if (overallSuccess) { _handleTarballSuccessAlert(data, appSettings, isDataOnly: finalImageFiles.isEmpty, isSessionExpired: isSessionKnownToBeExpired); } return {'success': overallSuccess, 'message': finalMessage, 'reportId': data.reportId}; } Future> _performOfflineQueuing({ required TarballSamplingData data, required String moduleName, String? logDirectory, // Added for potential update }) async { final serverConfig = await _serverConfigService.getActiveApiConfig(); final serverName = serverConfig?['config_name'] as String? ?? 'Default'; // Set status before saving/updating data.submissionStatus = 'L1'; // Logged locally or Queued data.submissionMessage = 'Submission queued for later retry.'; String? savedLogPath = logDirectory; // Use existing path if provided // Save/Update local log first if (savedLogPath != null && savedLogPath.isNotEmpty) { await _localStorageService.updateTarballLog(data.toDbJson()..['logDirectory'] = savedLogPath); debugPrint("Updated existing Tarball log for queuing: $savedLogPath"); } else { savedLogPath = await _localStorageService.saveTarballSamplingData(data, serverName: serverName); debugPrint("Saved new Tarball log for queuing: $savedLogPath"); } if (savedLogPath == null) { const message = "Failed to save submission to local device storage."; // Log failure state if saving fails await _logAndSave(data: data, status: 'Error', message: message, apiResults: [], ftpStatuses: [], serverName: serverName, finalImageFiles: {}, logDirectory: logDirectory); return {'success': false, 'message': message}; } // Queue a single task for the RetryService await _retryService.queueTask( type: 'tarball_submission', // Use specific type payload: { 'module': moduleName, 'localLogPath': savedLogPath, // Point retry service to the saved log *directory* 'serverConfig': serverConfig, }, ); const successMessage = "Device offline. Submission has been saved locally and queued for automatic retry when connection is restored."; // Log final queued state to central DB // await _logAndSave(data: data, status: 'Queued', message: successMessage, apiResults: [], ftpStatuses: [], serverName: serverName, finalImageFiles: {}, logDirectory: savedLogPath); return {'success': true, 'message': successMessage, 'reportId': null}; // No report ID yet } /// Helper to generate the base filename for ZIP files. String _generateBaseFileName(TarballSamplingData data) { final stationCode = data.selectedStation?['tbl_station_code'] ?? 'NA'; final fileTimestamp = "${data.samplingDate}_${data.samplingTime}".replaceAll(':', '-').replaceAll(' ', '_'); return '${stationCode}_$fileTimestamp'; } /// Generates data and image ZIP files and uploads them using SubmissionFtpService. Future> _generateAndUploadFtpFiles(TarballSamplingData data, Map imageFiles, String serverName, String moduleName) async { final baseFileName = _generateBaseFileName(data); final Directory? logDirectory = await _localStorageService.getLogDirectory( serverName: serverName, module: 'marine', subModule: 'marine_tarball_sampling', // Correct sub-module ); final folderName = data.reportId ?? baseFileName; final Directory? localSubmissionDir = logDirectory != null ? Directory(p.join(logDirectory.path, folderName)) : null; if (localSubmissionDir != null && !await localSubmissionDir.exists()) { await localSubmissionDir.create(recursive: true); } // Create and upload data ZIP (with multiple JSON files specific to Tarball) final dataZip = await _zippingService.createDataZip( jsonDataMap: { 'data.json': jsonEncode(data.toDbJson()), // Use specific method 'basic_form.json': jsonEncode(data.toBasicFormJson()), // Use specific method 'reading.json': jsonEncode(data.toReadingJson()), // Use specific method 'manual_info.json': jsonEncode(data.toManualInfoJson()), // Use specific method }, baseFileName: baseFileName, destinationDir: localSubmissionDir, ); Map ftpDataResult = {'success': true, 'statuses': []}; if (dataZip != null) { ftpDataResult = await _submissionFtpService.submit( moduleName: moduleName, fileToUpload: dataZip, remotePath: '/${p.basename(dataZip.path)}', ); } // Create and upload image ZIP final imageZip = await _zippingService.createImageZip( imageFiles: imageFiles.values.toList(), baseFileName: baseFileName, destinationDir: localSubmissionDir, ); Map ftpImageResult = {'success': true, 'statuses': []}; if (imageZip != null) { ftpImageResult = await _submissionFtpService.submit( moduleName: moduleName, fileToUpload: imageZip, remotePath: '/${p.basename(imageZip.path)}', ); } return { 'statuses': >[ ...(ftpDataResult['statuses'] as List? ?? []), ...(ftpImageResult['statuses'] as List? ?? []), ], }; } /// Saves or updates the local log file and saves a record to the central DB log. Future _logAndSave({ required TarballSamplingData data, required String status, required String message, required List> apiResults, required List> ftpStatuses, required String serverName, required Map finalImageFiles, String? logDirectory, // Added for potential update }) async { data.submissionStatus = status; data.submissionMessage = message; final baseFileName = _generateBaseFileName(data); // Use helper // Prepare log data map including file paths Map logMapData = data.toDbJson(); final imageFileMap = data.toImageFiles(); imageFileMap.forEach((key, file) { logMapData[key] = file?.path; // Store path or null }); // Add submission metadata logMapData['submissionStatus'] = status; logMapData['submissionMessage'] = message; logMapData['reportId'] = data.reportId; logMapData['serverConfigName'] = serverName; logMapData['api_status'] = jsonEncode(apiResults.where((r) => r.isNotEmpty).toList()); logMapData['ftp_status'] = jsonEncode(ftpStatuses); // Update or save the specific local JSON log file if (logDirectory != null && logDirectory.isNotEmpty) { logMapData['logDirectory'] = logDirectory; // Ensure logDirectory path is in the map await _localStorageService.updateTarballLog(logMapData); // Use specific update method } else { await _localStorageService.saveTarballSamplingData(data, serverName: serverName); // Use specific save method } // Save a record to the central SQLite submission log table final logData = { 'submission_id': data.reportId ?? baseFileName, // Use helper result 'module': 'marine', // Correct module 'type': 'Tarball', // Correct type 'status': status, 'message': message, 'report_id': data.reportId, 'created_at': DateTime.now().toIso8601String(), 'form_data': jsonEncode(logMapData), // Log the comprehensive map with paths 'image_data': jsonEncode(finalImageFiles.values.map((f) => f.path).toList()), 'server_name': serverName, 'api_status': jsonEncode(apiResults), 'ftp_status': jsonEncode(ftpStatuses), }; try { await _dbHelper.saveSubmissionLog(logData); } catch (e) { debugPrint("Error saving Tarball submission log to DB: $e"); } } /// Handles sending or queuing the Telegram alert for Tarball submissions. Future _handleTarballSuccessAlert(TarballSamplingData data, List>? appSettings, {required bool isDataOnly, bool isSessionExpired = false}) async { // --- START: Logic moved from data model --- String generateTarballTelegramAlertMessage(TarballSamplingData data, {required bool isDataOnly}) { final submissionType = isDataOnly ? "(Data Only)" : "(Data & Images)"; final stationName = data.selectedStation?['tbl_station_name'] ?? 'N/A'; final stationCode = data.selectedStation?['tbl_station_code'] ?? 'N/A'; final classification = data.selectedClassification?['classification_name'] ?? data.classificationId?.toString() ?? 'N/A'; final buffer = StringBuffer() ..writeln('✅ *Tarball Sample $submissionType Submitted:*') ..writeln() ..writeln('*Station Name & Code:* $stationName ($stationCode)') ..writeln('*Date of Submission:* ${data.samplingDate}') ..writeln('*Submitted by User:* ${data.firstSampler}') // Use firstSampler from data model ..writeln('*Classification:* $classification') ..writeln('*Status of Submission:* Successful'); final distanceKm = data.distanceDifference ?? 0; // Use distanceDifference from data model final distanceRemarks = data.distanceDifferenceRemarks ?? ''; if (distanceKm * 1000 > 50) { // Check distance > 50m buffer ..writeln() ..writeln('🔔 *Distance Alert:*') ..writeln('*Distance from station:* ${(distanceKm * 1000).toStringAsFixed(0)} meters'); if (distanceRemarks.isNotEmpty) { buffer.writeln('*Remarks for distance:* $distanceRemarks'); } } return buffer.toString(); } // --- END: Logic moved from data model --- try { final message = generateTarballTelegramAlertMessage(data, isDataOnly: isDataOnly); // Call local function final alertKey = 'marine_tarball'; // Correct key if (isSessionExpired) { debugPrint("Session is expired; queuing Telegram alert directly for $alertKey."); await _telegramService.queueMessage(alertKey, message, appSettings); } else { final bool wasSent = await _telegramService.sendAlertImmediately(alertKey, message, appSettings); if (!wasSent) { await _telegramService.queueMessage(alertKey, message, appSettings); } } } catch (e) { debugPrint("Failed to handle Tarball Telegram alert: $e"); } } }