import Combine import Foundation @MainActor class SyncService: ObservableObject { @Published var isSyncing = false @Published var lastSyncTime: Date? @Published var syncError: String? @Published var isConnected = false @Published var isTesting = false private let userDefaults = UserDefaults.standard private enum Keys { static let serverURL = "sync_server_url" static let authToken = "sync_auth_token" static let lastSyncTime = "last_sync_time" static let isConnected = "sync_is_connected" static let autoSyncEnabled = "auto_sync_enabled" } var serverURL: String { get { userDefaults.string(forKey: Keys.serverURL) ?? "" } set { userDefaults.set(newValue, forKey: Keys.serverURL) } } var authToken: String { get { userDefaults.string(forKey: Keys.authToken) ?? "" } set { userDefaults.set(newValue, forKey: Keys.authToken) } } var isConfigured: Bool { return !serverURL.isEmpty && !authToken.isEmpty } var isAutoSyncEnabled: Bool { get { userDefaults.bool(forKey: Keys.autoSyncEnabled) } set { userDefaults.set(newValue, forKey: Keys.autoSyncEnabled) } } init() { if let lastSync = userDefaults.object(forKey: Keys.lastSyncTime) as? Date { self.lastSyncTime = lastSync } self.isConnected = userDefaults.bool(forKey: Keys.isConnected) } func downloadData() async throws -> ClimbDataBackup { guard isConfigured else { throw SyncError.notConfigured } guard let url = URL(string: "\(serverURL)/sync") else { throw SyncError.invalidURL } var request = URLRequest(url: url) request.httpMethod = "GET" request.setValue("Bearer \(authToken)", forHTTPHeaderField: "Authorization") request.setValue("application/json", forHTTPHeaderField: "Accept") let (data, response) = try await URLSession.shared.data(for: request) guard let httpResponse = response as? HTTPURLResponse else { throw SyncError.invalidResponse } switch httpResponse.statusCode { case 200: break case 401: throw SyncError.unauthorized default: throw SyncError.serverError(httpResponse.statusCode) } do { let backup = try JSONDecoder().decode(ClimbDataBackup.self, from: data) return backup } catch { throw SyncError.decodingError(error) } } func uploadData(_ backup: ClimbDataBackup) async throws -> ClimbDataBackup { guard isConfigured else { throw SyncError.notConfigured } guard let url = URL(string: "\(serverURL)/sync") else { throw SyncError.invalidURL } let encoder = JSONEncoder() encoder.dateEncodingStrategy = .iso8601 let jsonData = try encoder.encode(backup) var request = URLRequest(url: url) request.httpMethod = "PUT" request.setValue("Bearer \(authToken)", forHTTPHeaderField: "Authorization") request.setValue("application/json", forHTTPHeaderField: "Content-Type") request.setValue("application/json", forHTTPHeaderField: "Accept") request.httpBody = jsonData let (data, response) = try await URLSession.shared.data(for: request) guard let httpResponse = response as? HTTPURLResponse else { throw SyncError.invalidResponse } switch httpResponse.statusCode { case 200: break case 401: throw SyncError.unauthorized case 400: throw SyncError.badRequest default: throw SyncError.serverError(httpResponse.statusCode) } do { let responseBackup = try JSONDecoder().decode(ClimbDataBackup.self, from: data) return responseBackup } catch { throw SyncError.decodingError(error) } } func uploadImage(filename: String, imageData: Data) async throws { guard isConfigured else { throw SyncError.notConfigured } guard let url = URL(string: "\(serverURL)/images/upload?filename=\(filename)") else { throw SyncError.invalidURL } var request = URLRequest(url: url) request.httpMethod = "POST" request.setValue("Bearer \(authToken)", forHTTPHeaderField: "Authorization") request.setValue("application/octet-stream", forHTTPHeaderField: "Content-Type") request.httpBody = imageData let (_, response) = try await URLSession.shared.data(for: request) guard let httpResponse = response as? HTTPURLResponse else { throw SyncError.invalidResponse } switch httpResponse.statusCode { case 200: break case 401: throw SyncError.unauthorized default: throw SyncError.serverError(httpResponse.statusCode) } } func downloadImage(filename: String) async throws -> Data { guard isConfigured else { throw SyncError.notConfigured } guard let url = URL(string: "\(serverURL)/images/download?filename=\(filename)") else { throw SyncError.invalidURL } var request = URLRequest(url: url) request.httpMethod = "GET" request.setValue("Bearer \(authToken)", forHTTPHeaderField: "Authorization") let (data, response) = try await URLSession.shared.data(for: request) guard let httpResponse = response as? HTTPURLResponse else { throw SyncError.invalidResponse } switch httpResponse.statusCode { case 200: return data case 401: throw SyncError.unauthorized case 404: throw SyncError.imageNotFound default: throw SyncError.serverError(httpResponse.statusCode) } } func syncWithServer(dataManager: ClimbingDataManager) async throws { guard isConfigured else { throw SyncError.notConfigured } guard isConnected else { throw SyncError.notConnected } isSyncing = true syncError = nil defer { isSyncing = false } do { // Get local backup data let localBackup = createBackupFromDataManager(dataManager) // Download server data let serverBackup = try await downloadData() // Check if we have any local data let hasLocalData = !dataManager.gyms.isEmpty || !dataManager.problems.isEmpty || !dataManager.sessions.isEmpty || !dataManager.attempts.isEmpty let hasServerData = !serverBackup.gyms.isEmpty || !serverBackup.problems.isEmpty || !serverBackup.sessions.isEmpty || !serverBackup.attempts.isEmpty if !hasLocalData && hasServerData { // Case 1: No local data - do full restore from server print("🔄 iOS SYNC: Case 1 - No local data, performing full restore from server") print("Syncing images from server first...") let imagePathMapping = try await syncImagesFromServer( backup: serverBackup, dataManager: dataManager) print("Importing data after images...") try importBackupToDataManager( serverBackup, dataManager: dataManager, imagePathMapping: imagePathMapping) print("Full restore completed") } else if hasLocalData && !hasServerData { // Case 2: No server data - upload local data to server print("🔄 iOS SYNC: Case 2 - No server data, uploading local data to server") let currentBackup = createBackupFromDataManager(dataManager) _ = try await uploadData(currentBackup) print("Uploading local images to server...") try await syncImagesToServer(dataManager: dataManager) print("Initial upload completed") } else if hasLocalData && hasServerData { // Case 3: Both have data - compare timestamps (last writer wins) let localTimestamp = parseISO8601ToMillis(timestamp: localBackup.exportedAt) let serverTimestamp = parseISO8601ToMillis(timestamp: serverBackup.exportedAt) print("🕐 DEBUG iOS Timestamp Comparison:") print(" Local exportedAt: '\(localBackup.exportedAt)' -> \(localTimestamp)") print(" Server exportedAt: '\(serverBackup.exportedAt)' -> \(serverTimestamp)") print( " DataStateManager last modified: '\(DataStateManager.shared.getLastModified())'" ) print(" Comparison result: local=\(localTimestamp), server=\(serverTimestamp)") if localTimestamp > serverTimestamp { // Local is newer - replace server with local data print("🔄 iOS SYNC: Case 3a - Local data is newer, replacing server content") let currentBackup = createBackupFromDataManager(dataManager) _ = try await uploadData(currentBackup) try await syncImagesToServer(dataManager: dataManager) print("Server replaced with local data") } else if serverTimestamp > localTimestamp { // Server is newer - replace local with server data print("🔄 iOS SYNC: Case 3b - Server data is newer, replacing local content") let imagePathMapping = try await syncImagesFromServer( backup: serverBackup, dataManager: dataManager) try importBackupToDataManager( serverBackup, dataManager: dataManager, imagePathMapping: imagePathMapping) print("Local data replaced with server data") } else { // Timestamps are equal - no sync needed print( "🔄 iOS SYNC: Case 3c - Data is in sync (timestamps equal), no action needed" ) } } else { print("No data to sync") } // Update last sync time lastSyncTime = Date() userDefaults.set(lastSyncTime, forKey: Keys.lastSyncTime) } catch { syncError = error.localizedDescription throw error } } /// Parses ISO8601 timestamp to milliseconds for comparison private func parseISO8601ToMillis(timestamp: String) -> Int64 { let formatter = ISO8601DateFormatter() if let date = formatter.date(from: timestamp) { return Int64(date.timeIntervalSince1970 * 1000) } print("Failed to parse timestamp: \(timestamp), using 0") return 0 } private func syncImagesFromServer(backup: ClimbDataBackup, dataManager: ClimbingDataManager) async throws -> [String: String] { var imagePathMapping: [String: String] = [:] // Process images by problem to maintain consistent naming for problem in backup.problems { guard let imagePaths = problem.imagePaths, !imagePaths.isEmpty else { continue } for (index, imagePath) in imagePaths.enumerated() { let serverFilename = URL(fileURLWithPath: imagePath).lastPathComponent do { let imageData = try await downloadImage(filename: serverFilename) // Generate consistent filename if needed let consistentFilename = ImageNamingUtils.isValidImageFilename(serverFilename) ? serverFilename : ImageNamingUtils.generateImageFilename( problemId: problem.id, imageIndex: index) // Save image with consistent filename let imageManager = ImageManager.shared _ = try imageManager.saveImportedImage( imageData, filename: consistentFilename) // Map server filename to consistent local filename imagePathMapping[serverFilename] = consistentFilename print("Downloaded and mapped image: \(serverFilename) -> \(consistentFilename)") } catch SyncError.imageNotFound { print("Image not found on server: \(serverFilename)") continue } catch { print("Failed to download image \(serverFilename): \(error)") continue } } } return imagePathMapping } private func syncImagesToServer(dataManager: ClimbingDataManager) async throws { // Process images by problem to ensure consistent naming for problem in dataManager.problems { guard !problem.imagePaths.isEmpty else { continue } for (index, imagePath) in problem.imagePaths.enumerated() { let filename = URL(fileURLWithPath: imagePath).lastPathComponent // Ensure filename follows consistent naming convention let consistentFilename = ImageNamingUtils.isValidImageFilename(filename) ? filename : ImageNamingUtils.generateImageFilename( problemId: problem.id.uuidString, imageIndex: index) // Load image data let imageManager = ImageManager.shared let fullPath = imageManager.imagesDirectory.appendingPathComponent(filename).path if let imageData = imageManager.loadImageData(fromPath: fullPath) { do { // If filename changed, rename local file if filename != consistentFilename { let newPath = imageManager.imagesDirectory.appendingPathComponent( consistentFilename ).path do { try FileManager.default.moveItem(atPath: fullPath, toPath: newPath) print("Renamed local image: \(filename) -> \(consistentFilename)") // Update problem's image path in memory for consistency // Note: This would require updating the problem in the data manager } catch { print("Failed to rename local image, using original: \(error)") } } try await uploadImage(filename: consistentFilename, imageData: imageData) print("Successfully uploaded image: \(consistentFilename)") } catch { print("Failed to upload image \(consistentFilename): \(error)") // Continue with other images even if one fails } } } } } private func createBackupFromDataManager(_ dataManager: ClimbingDataManager) -> ClimbDataBackup { return ClimbDataBackup( exportedAt: DataStateManager.shared.getLastModified(), gyms: dataManager.gyms.map { BackupGym(from: $0) }, problems: dataManager.problems.map { BackupProblem(from: $0) }, sessions: dataManager.sessions.map { BackupClimbSession(from: $0) }, attempts: dataManager.attempts.map { BackupAttempt(from: $0) } ) } private func importBackupToDataManager( _ backup: ClimbDataBackup, dataManager: ClimbingDataManager, imagePathMapping: [String: String] = [:] ) throws { do { // Update problem image paths to point to downloaded images let updatedBackup: ClimbDataBackup if !imagePathMapping.isEmpty { let updatedProblems = backup.problems.map { problem in let updatedImagePaths = problem.imagePaths?.compactMap { oldPath in imagePathMapping[oldPath] ?? oldPath } return BackupProblem( id: problem.id, gymId: problem.gymId, name: problem.name, description: problem.description, climbType: problem.climbType, difficulty: problem.difficulty, tags: problem.tags, location: problem.location, imagePaths: updatedImagePaths, isActive: problem.isActive, dateSet: problem.dateSet, notes: problem.notes, createdAt: problem.createdAt, updatedAt: problem.updatedAt ) } updatedBackup = ClimbDataBackup( exportedAt: backup.exportedAt, version: backup.version, formatVersion: backup.formatVersion, gyms: backup.gyms, problems: updatedProblems, sessions: backup.sessions, attempts: backup.attempts ) } else { updatedBackup = backup } // Create a minimal ZIP with just the JSON data for existing import mechanism let zipData = try createMinimalZipFromBackup(updatedBackup) // Use existing import method which properly handles data restoration try dataManager.importData(from: zipData, showSuccessMessage: false) // Update local data state to match imported data timestamp DataStateManager.shared.setLastModified(backup.exportedAt) print("Data state synchronized to imported timestamp: \(backup.exportedAt)") } catch { throw SyncError.importFailed(error) } } private func createMinimalZipFromBackup(_ backup: ClimbDataBackup) throws -> Data { // Create JSON data let encoder = JSONEncoder() encoder.outputFormatting = .prettyPrinted encoder.dateEncodingStrategy = .custom { date, encoder in let formatter = DateFormatter() formatter.dateFormat = "yyyy-MM-dd'T'HH:mm:ss.SSSSSS" var container = encoder.singleValueContainer() try container.encode(formatter.string(from: date)) } let jsonData = try encoder.encode(backup) // Collect all downloaded images from ImageManager let imageManager = ImageManager.shared var imageFiles: [(filename: String, data: Data)] = [] let imagePaths = Set(backup.problems.flatMap { $0.imagePaths ?? [] }) for imagePath in imagePaths { let filename = URL(fileURLWithPath: imagePath).lastPathComponent let fullPath = imageManager.imagesDirectory.appendingPathComponent(filename).path if let imageData = imageManager.loadImageData(fromPath: fullPath) { imageFiles.append((filename: filename, data: imageData)) } } // Create ZIP with data.json, metadata, and images var zipData = Data() var fileEntries: [(name: String, data: Data, offset: UInt32)] = [] var currentOffset: UInt32 = 0 // Add data.json to ZIP try addFileToMinimalZip( filename: "data.json", fileData: jsonData, zipData: &zipData, fileEntries: &fileEntries, currentOffset: ¤tOffset ) // Add metadata with correct image count let metadata = "export_version=2.0\nformat_version=2.0\nimage_count=\(imageFiles.count)" let metadataData = metadata.data(using: .utf8) ?? Data() try addFileToMinimalZip( filename: "metadata.txt", fileData: metadataData, zipData: &zipData, fileEntries: &fileEntries, currentOffset: ¤tOffset ) // Add images to ZIP in images/ directory for imageFile in imageFiles { try addFileToMinimalZip( filename: "images/\(imageFile.filename)", fileData: imageFile.data, zipData: &zipData, fileEntries: &fileEntries, currentOffset: ¤tOffset ) } // Add central directory var centralDirectory = Data() for entry in fileEntries { centralDirectory.append(createCentralDirectoryHeader(entry: entry)) } // Add end of central directory record let endOfCentralDir = createEndOfCentralDirectoryRecord( fileCount: UInt16(fileEntries.count), centralDirSize: UInt32(centralDirectory.count), centralDirOffset: currentOffset ) zipData.append(centralDirectory) zipData.append(endOfCentralDir) return zipData } private func addFileToMinimalZip( filename: String, fileData: Data, zipData: inout Data, fileEntries: inout [(name: String, data: Data, offset: UInt32)], currentOffset: inout UInt32 ) throws { let localFileHeader = createLocalFileHeader( filename: filename, fileSize: UInt32(fileData.count)) fileEntries.append((name: filename, data: fileData, offset: currentOffset)) zipData.append(localFileHeader) zipData.append(fileData) currentOffset += UInt32(localFileHeader.count + fileData.count) } private func createLocalFileHeader(filename: String, fileSize: UInt32) -> Data { var header = Data() // Local file header signature header.append(Data([0x50, 0x4b, 0x03, 0x04])) // Version needed to extract (2.0) header.append(Data([0x14, 0x00])) // General purpose bit flag header.append(Data([0x00, 0x00])) // Compression method (no compression) header.append(Data([0x00, 0x00])) // Last mod file time & date (dummy values) header.append(Data([0x00, 0x00, 0x00, 0x00])) // CRC-32 (dummy - we're not compressing) header.append(Data([0x00, 0x00, 0x00, 0x00])) // Compressed size withUnsafeBytes(of: fileSize.littleEndian) { header.append(Data($0)) } // Uncompressed size withUnsafeBytes(of: fileSize.littleEndian) { header.append(Data($0)) } // File name length let filenameData = filename.data(using: .utf8) ?? Data() let filenameLength = UInt16(filenameData.count) withUnsafeBytes(of: filenameLength.littleEndian) { header.append(Data($0)) } // Extra field length header.append(Data([0x00, 0x00])) // File name header.append(filenameData) return header } private func createCentralDirectoryHeader(entry: (name: String, data: Data, offset: UInt32)) -> Data { var header = Data() // Central directory signature header.append(Data([0x50, 0x4b, 0x01, 0x02])) // Version made by header.append(Data([0x14, 0x00])) // Version needed to extract header.append(Data([0x14, 0x00])) // General purpose bit flag header.append(Data([0x00, 0x00])) // Compression method header.append(Data([0x00, 0x00])) // Last mod file time & date header.append(Data([0x00, 0x00, 0x00, 0x00])) // CRC-32 header.append(Data([0x00, 0x00, 0x00, 0x00])) // Compressed size let compressedSize = UInt32(entry.data.count) withUnsafeBytes(of: compressedSize.littleEndian) { header.append(Data($0)) } // Uncompressed size withUnsafeBytes(of: compressedSize.littleEndian) { header.append(Data($0)) } // File name length let filenameData = entry.name.data(using: .utf8) ?? Data() let filenameLength = UInt16(filenameData.count) withUnsafeBytes(of: filenameLength.littleEndian) { header.append(Data($0)) } // Extra field length header.append(Data([0x00, 0x00])) // File comment length header.append(Data([0x00, 0x00])) // Disk number start header.append(Data([0x00, 0x00])) // Internal file attributes header.append(Data([0x00, 0x00])) // External file attributes header.append(Data([0x00, 0x00, 0x00, 0x00])) // Relative offset of local header withUnsafeBytes(of: entry.offset.littleEndian) { header.append(Data($0)) } // File name header.append(filenameData) return header } private func createEndOfCentralDirectoryRecord( fileCount: UInt16, centralDirSize: UInt32, centralDirOffset: UInt32 ) -> Data { var record = Data() // End of central dir signature record.append(Data([0x50, 0x4b, 0x05, 0x06])) // Number of this disk record.append(Data([0x00, 0x00])) // Number of the disk with the start of the central directory record.append(Data([0x00, 0x00])) // Total number of entries in the central directory on this disk withUnsafeBytes(of: fileCount.littleEndian) { record.append(Data($0)) } // Total number of entries in the central directory withUnsafeBytes(of: fileCount.littleEndian) { record.append(Data($0)) } // Size of the central directory withUnsafeBytes(of: centralDirSize.littleEndian) { record.append(Data($0)) } // Offset of start of central directory withUnsafeBytes(of: centralDirOffset.littleEndian) { record.append(Data($0)) } // ZIP file comment length record.append(Data([0x00, 0x00])) return record } func testConnection() async throws { guard isConfigured else { throw SyncError.notConfigured } isTesting = true defer { isTesting = false } guard let url = URL(string: "\(serverURL)/health") else { throw SyncError.invalidURL } var request = URLRequest(url: url) request.httpMethod = "GET" request.setValue("application/json", forHTTPHeaderField: "Accept") request.timeoutInterval = 10 let (_, response) = try await URLSession.shared.data(for: request) guard let httpResponse = response as? HTTPURLResponse else { throw SyncError.invalidResponse } guard httpResponse.statusCode == 200 else { throw SyncError.serverError(httpResponse.statusCode) } // Connection successful, mark as connected isConnected = true userDefaults.set(true, forKey: Keys.isConnected) } func triggerAutoSync(dataManager: ClimbingDataManager) { // Early exit if sync cannot proceed - don't set isSyncing guard isConnected && isConfigured && isAutoSyncEnabled else { // Ensure isSyncing is false when sync is not possible if isSyncing { isSyncing = false } return } // Prevent multiple simultaneous syncs guard !isSyncing else { return } Task { do { try await syncWithServer(dataManager: dataManager) } catch { await MainActor.run { self.isSyncing = false } } } } func disconnect() { isConnected = false lastSyncTime = nil syncError = nil userDefaults.set(false, forKey: Keys.isConnected) userDefaults.removeObject(forKey: Keys.lastSyncTime) } func clearConfiguration() { serverURL = "" authToken = "" lastSyncTime = nil isConnected = false isAutoSyncEnabled = true userDefaults.removeObject(forKey: Keys.lastSyncTime) userDefaults.removeObject(forKey: Keys.isConnected) userDefaults.removeObject(forKey: Keys.autoSyncEnabled) } } enum SyncError: LocalizedError { case notConfigured case notConnected case invalidURL case invalidResponse case unauthorized case badRequest case serverError(Int) case decodingError(Error) case exportFailed case importFailed(Error) case imageNotFound case imageUploadFailed var errorDescription: String? { switch self { case .notConfigured: return "Sync server not configured. Please set server URL and auth token." case .notConnected: return "Not connected to sync server. Please test connection first." case .invalidURL: return "Invalid server URL." case .invalidResponse: return "Invalid response from server." case .unauthorized: return "Authentication failed. Check your auth token." case .badRequest: return "Bad request. Check your data format." case .serverError(let code): return "Server error (code \(code))." case .decodingError(let error): return "Failed to decode response: \(error.localizedDescription)" case .exportFailed: return "Failed to export local data." case .importFailed(let error): return "Failed to import data: \(error.localizedDescription)" case .imageNotFound: return "Image not found on server." case .imageUploadFailed: return "Failed to upload image to server." } } }