package com.atridad.openclimb.data.repository import android.content.Context import com.atridad.openclimb.data.database.OpenClimbDatabase import com.atridad.openclimb.data.format.ClimbDataBackup import com.atridad.openclimb.data.model.* import com.atridad.openclimb.utils.ZipExportImportUtils import java.io.File import java.time.LocalDateTime import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.first import kotlinx.serialization.json.Json class ClimbRepository(database: OpenClimbDatabase, private val context: Context) { private val gymDao = database.gymDao() private val problemDao = database.problemDao() private val sessionDao = database.climbSessionDao() private val attemptDao = database.attemptDao() private val json = Json { prettyPrint = true ignoreUnknownKeys = true } // Gym operations fun getAllGyms(): Flow> = gymDao.getAllGyms() suspend fun getGymById(id: String): Gym? = gymDao.getGymById(id) suspend fun insertGym(gym: Gym) = gymDao.insertGym(gym) suspend fun updateGym(gym: Gym) = gymDao.updateGym(gym) suspend fun deleteGym(gym: Gym) = gymDao.deleteGym(gym) fun searchGyms(query: String): Flow> = gymDao.searchGyms(query) // Problem operations fun getAllProblems(): Flow> = problemDao.getAllProblems() suspend fun getProblemById(id: String): Problem? = problemDao.getProblemById(id) fun getProblemsByGym(gymId: String): Flow> = problemDao.getProblemsByGym(gymId) suspend fun insertProblem(problem: Problem) = problemDao.insertProblem(problem) suspend fun updateProblem(problem: Problem) = problemDao.updateProblem(problem) suspend fun deleteProblem(problem: Problem) = problemDao.deleteProblem(problem) fun searchProblems(query: String): Flow> = problemDao.searchProblems(query) // Session operations fun getAllSessions(): Flow> = sessionDao.getAllSessions() suspend fun getSessionById(id: String): ClimbSession? = sessionDao.getSessionById(id) fun getSessionsByGym(gymId: String): Flow> = sessionDao.getSessionsByGym(gymId) suspend fun getActiveSession(): ClimbSession? = sessionDao.getActiveSession() fun getActiveSessionFlow(): Flow = sessionDao.getActiveSessionFlow() suspend fun insertSession(session: ClimbSession) = sessionDao.insertSession(session) suspend fun updateSession(session: ClimbSession) = sessionDao.updateSession(session) suspend fun deleteSession(session: ClimbSession) = sessionDao.deleteSession(session) suspend fun getLastUsedGym(): Gym? { val recentSessions = sessionDao.getRecentSessions(1).first() return if (recentSessions.isNotEmpty()) { getGymById(recentSessions.first().gymId) } else { null } } // Attempt operations fun getAllAttempts(): Flow> = attemptDao.getAllAttempts() fun getAttemptsBySession(sessionId: String): Flow> = attemptDao.getAttemptsBySession(sessionId) fun getAttemptsByProblem(problemId: String): Flow> = attemptDao.getAttemptsByProblem(problemId) suspend fun insertAttempt(attempt: Attempt) = attemptDao.insertAttempt(attempt) suspend fun updateAttempt(attempt: Attempt) = attemptDao.updateAttempt(attempt) suspend fun deleteAttempt(attempt: Attempt) = attemptDao.deleteAttempt(attempt) // ZIP Export with images - Single format for reliability suspend fun exportAllDataToZip(directory: File? = null): File { return try { // Collect all data with proper error handling val allGyms = gymDao.getAllGyms().first() val allProblems = problemDao.getAllProblems().first() val allSessions = sessionDao.getAllSessions().first() val allAttempts = attemptDao.getAllAttempts().first() // Validate data integrity before export validateDataIntegrity(allGyms, allProblems, allSessions, allAttempts) // Create backup data using platform-neutral format val backupData = ClimbDataBackup( exportedAt = LocalDateTime.now().toString(), version = "2.0", formatVersion = "2.0", gyms = allGyms.map { com.atridad.openclimb.data.format.BackupGym.fromGym(it) }, problems = allProblems.map { com.atridad.openclimb.data.format.BackupProblem.fromProblem( it ) }, sessions = allSessions.map { com.atridad.openclimb.data.format.BackupClimbSession .fromClimbSession(it) }, attempts = allAttempts.map { com.atridad.openclimb.data.format.BackupAttempt.fromAttempt( it ) } ) // Collect all referenced image paths and validate they exist val referencedImagePaths = allProblems.flatMap { it.imagePaths }.toSet() val validImagePaths = referencedImagePaths .filter { imagePath -> try { val imageFile = com.atridad.openclimb.utils.ImageUtils.getImageFile( context, imagePath ) imageFile.exists() && imageFile.length() > 0 } catch (e: Exception) { false } } .toSet() // Log any missing images for debugging val missingImages = referencedImagePaths - validImagePaths if (missingImages.isNotEmpty()) { android.util.Log.w( "ClimbRepository", "Some referenced images are missing: $missingImages" ) } ZipExportImportUtils.createExportZip( context = context, exportData = backupData, referencedImagePaths = validImagePaths, directory = directory ) } catch (e: Exception) { throw Exception("Export failed: ${e.message}") } } suspend fun exportAllDataToZipUri(uri: android.net.Uri) { try { // Collect all data val allGyms = gymDao.getAllGyms().first() val allProblems = problemDao.getAllProblems().first() val allSessions = sessionDao.getAllSessions().first() val allAttempts = attemptDao.getAllAttempts().first() // Validate data integrity before export validateDataIntegrity(allGyms, allProblems, allSessions, allAttempts) // Create backup data using platform-neutral format val backupData = ClimbDataBackup( exportedAt = LocalDateTime.now().toString(), version = "2.0", formatVersion = "2.0", gyms = allGyms.map { com.atridad.openclimb.data.format.BackupGym.fromGym(it) }, problems = allProblems.map { com.atridad.openclimb.data.format.BackupProblem.fromProblem( it ) }, sessions = allSessions.map { com.atridad.openclimb.data.format.BackupClimbSession .fromClimbSession(it) }, attempts = allAttempts.map { com.atridad.openclimb.data.format.BackupAttempt.fromAttempt( it ) } ) // Collect all referenced image paths and validate they exist val referencedImagePaths = allProblems.flatMap { it.imagePaths }.toSet() val validImagePaths = referencedImagePaths .filter { imagePath -> try { val imageFile = com.atridad.openclimb.utils.ImageUtils.getImageFile( context, imagePath ) imageFile.exists() && imageFile.length() > 0 } catch (e: Exception) { false } } .toSet() ZipExportImportUtils.createExportZipToUri( context = context, uri = uri, exportData = backupData, referencedImagePaths = validImagePaths ) } catch (e: Exception) { throw Exception("Export failed: ${e.message}") } } suspend fun importDataFromZip(file: File) { try { // Validate the ZIP file if (!file.exists() || file.length() == 0L) { throw Exception("Invalid ZIP file: file is empty or doesn't exist") } // Extract and validate the ZIP contents val importResult = ZipExportImportUtils.extractImportZip(context, file) // Validate JSON content if (importResult.jsonContent.isBlank()) { throw Exception("Invalid ZIP file: no data.json found or empty content") } // Parse and validate the data structure val importData = try { json.decodeFromString(importResult.jsonContent) } catch (e: Exception) { throw Exception("Invalid data format: ${e.message}") } // Validate data integrity validateImportData(importData) // Clear existing data to avoid conflicts attemptDao.deleteAllAttempts() sessionDao.deleteAllSessions() problemDao.deleteAllProblems() gymDao.deleteAllGyms() // Import gyms first (problems depend on gyms) importData.gyms.forEach { backupGym -> try { gymDao.insertGym(backupGym.toGym()) } catch (e: Exception) { throw Exception("Failed to import gym '${backupGym.name}': ${e.message}") } } // Import problems with updated image paths val updatedBackupProblems = ZipExportImportUtils.updateProblemImagePaths( importData.problems, importResult.importedImagePaths ) // Import problems (depends on gyms) updatedBackupProblems.forEach { backupProblem -> try { problemDao.insertProblem(backupProblem.toProblem()) } catch (e: Exception) { throw Exception( "Failed to import problem '${backupProblem.name}': ${e.message}" ) } } // Import sessions importData.sessions.forEach { backupSession -> try { sessionDao.insertSession(backupSession.toClimbSession()) } catch (e: Exception) { throw Exception("Failed to import session '${backupSession.id}': ${e.message}") } } // Import attempts last (depends on problems and sessions) importData.attempts.forEach { backupAttempt -> try { attemptDao.insertAttempt(backupAttempt.toAttempt()) } catch (e: Exception) { throw Exception("Failed to import attempt '${backupAttempt.id}': ${e.message}") } } } catch (e: Exception) { throw Exception("Import failed: ${e.message}") } } private fun validateDataIntegrity( gyms: List, problems: List, sessions: List, attempts: List ) { // Validate that all problems reference valid gyms val gymIds = gyms.map { it.id }.toSet() val invalidProblems = problems.filter { it.gymId !in gymIds } if (invalidProblems.isNotEmpty()) { throw Exception( "Data integrity error: ${invalidProblems.size} problems reference non-existent gyms" ) } // Validate that all sessions reference valid gyms val invalidSessions = sessions.filter { it.gymId !in gymIds } if (invalidSessions.isNotEmpty()) { throw Exception( "Data integrity error: ${invalidSessions.size} sessions reference non-existent gyms" ) } // Validate that all attempts reference valid problems and sessions val problemIds = problems.map { it.id }.toSet() val sessionIds = sessions.map { it.id }.toSet() val invalidAttempts = attempts.filter { it.problemId !in problemIds || it.sessionId !in sessionIds } if (invalidAttempts.isNotEmpty()) { throw Exception( "Data integrity error: ${invalidAttempts.size} attempts reference non-existent problems or sessions" ) } } private fun validateImportData(importData: ClimbDataBackup) { if (importData.gyms.isEmpty()) { throw Exception("Import data is invalid: no gyms found") } if (importData.version.isBlank()) { throw Exception("Import data is invalid: no version information") } // Check for reasonable data sizes to prevent malicious imports if (importData.gyms.size > 1000 || importData.problems.size > 10000 || importData.sessions.size > 10000 || importData.attempts.size > 100000 ) { throw Exception("Import data is too large: possible corruption or malicious file") } } suspend fun resetAllData() { try { // Clear all data from database attemptDao.deleteAllAttempts() sessionDao.deleteAllSessions() problemDao.deleteAllProblems() gymDao.deleteAllGyms() // Clear all images from storage clearAllImages() } catch (e: Exception) { throw Exception("Reset failed: ${e.message}") } } private fun clearAllImages() { try { // Get the images directory val imagesDir = File(context.filesDir, "images") if (imagesDir.exists() && imagesDir.isDirectory) { val deletedCount = imagesDir.listFiles()?.size ?: 0 imagesDir.deleteRecursively() android.util.Log.i("ClimbRepository", "Cleared $deletedCount image files") } } catch (e: Exception) { android.util.Log.w("ClimbRepository", "Failed to clear some images: ${e.message}") } } }