import Foundation import CoreData enum BackupLocation: String { case local = "On device" case iCloud = "iCloud" } struct BackupRecord: Identifiable { let id: String let url: URL let date: Date let size: Int64 let location: BackupLocation } class BackupService { static let shared = BackupService() private let fileManager: FileManager private let dateProvider: () -> Date private let localBaseDirectoryProvider: () -> URL? private let iCloudBaseDirectoryProvider: () -> URL? private let exportProvider: () -> String private let dateFormatter: DateFormatter = { let formatter = DateFormatter() formatter.dateFormat = "yyyyMMdd-HHmmss" formatter.locale = Locale(identifier: "en_US_POSIX") return formatter }() private init() { fileManager = .default dateProvider = Date.init localBaseDirectoryProvider = { [fileManager] in fileManager.urls(for: .applicationSupportDirectory, in: .userDomainMask).first } iCloudBaseDirectoryProvider = { [fileManager] in fileManager.url(forUbiquityContainerIdentifier: nil) } exportProvider = { let context = CoreDataStack.shared.viewContext let sources = BackupService.fetchAllSources(in: context) let categories = BackupService.fetchAllCategories(in: context) return ExportService.shared.exportToJSON(sources: sources, categories: categories) } } init( fileManager: FileManager = .default, dateProvider: @escaping () -> Date, localBaseDirectoryProvider: @escaping () -> URL?, iCloudBaseDirectoryProvider: @escaping () -> URL?, exportProvider: @escaping () -> String ) { self.fileManager = fileManager self.dateProvider = dateProvider self.localBaseDirectoryProvider = localBaseDirectoryProvider self.iCloudBaseDirectoryProvider = iCloudBaseDirectoryProvider self.exportProvider = exportProvider } func createBackup(retentionCount: Int, includeICloud: Bool) -> [BackupRecord] { let timestamp = dateFormatter.string(from: dateProvider()) let fileName = "backup-\(timestamp).json" let content = exportProvider() var records: [BackupRecord] = [] if let localDir = backupDirectory() { let localURL = localDir.appendingPathComponent(fileName) write(content: content, to: localURL) pruneBackups(in: localDir, keep: retentionCount, location: .local) records.append(contentsOf: listBackups(in: localDir, location: .local)) } if includeICloud, let iCloudDir = iCloudBackupDirectory() { let iCloudURL = iCloudDir.appendingPathComponent(fileName) write(content: content, to: iCloudURL) pruneBackups(in: iCloudDir, keep: retentionCount, location: .iCloud) records.append(contentsOf: listBackups(in: iCloudDir, location: .iCloud)) } return records.sorted { $0.date > $1.date } } func listAllBackups(includeICloud: Bool) -> [BackupRecord] { var records: [BackupRecord] = [] if let localDir = backupDirectory() { records.append(contentsOf: listBackups(in: localDir, location: .local)) } if includeICloud, let iCloudDir = iCloudBackupDirectory() { records.append(contentsOf: listBackups(in: iCloudDir, location: .iCloud)) } return records.sorted { $0.date > $1.date } } private func backupDirectory() -> URL? { guard let base = localBaseDirectoryProvider() else { return nil } let dir = base.appendingPathComponent("Backups", isDirectory: true) ensureDirectoryExists(dir) return dir } private func iCloudBackupDirectory() -> URL? { guard let base = iCloudBaseDirectoryProvider() else { return nil } let dir = base.appendingPathComponent("Documents/Backups", isDirectory: true) ensureDirectoryExists(dir) return dir } private func ensureDirectoryExists(_ url: URL) { if !fileManager.fileExists(atPath: url.path) { try? fileManager.createDirectory(at: url, withIntermediateDirectories: true, attributes: nil) } } private func write(content: String, to url: URL) { do { try content.write(to: url, atomically: true, encoding: .utf8) } catch { print("Backup write failed: \(error)") } } private func listBackups(in directory: URL, location: BackupLocation) -> [BackupRecord] { guard let files = try? fileManager.contentsOfDirectory(at: directory, includingPropertiesForKeys: [.contentModificationDateKey, .fileSizeKey]) else { return [] } return files.compactMap { url in guard url.lastPathComponent.hasPrefix("backup-"), url.pathExtension.lowercased() == "json" else { return nil } let name = url.deletingPathExtension().lastPathComponent let date = parseDate(from: name) ?? (try? url.resourceValues(forKeys: [.contentModificationDateKey]).contentModificationDate) ?? Date() let size = (try? url.resourceValues(forKeys: [.fileSizeKey]).fileSize).map { Int64($0) } ?? 0 return BackupRecord( id: "\(location.rawValue)-\(name)", url: url, date: date, size: size, location: location ) } } private func pruneBackups(in directory: URL, keep: Int, location: BackupLocation) { guard keep > 0 else { return } let backups = listBackups(in: directory, location: location).sorted { $0.date > $1.date } let toDelete = backups.dropFirst(keep) for backup in toDelete { try? fileManager.removeItem(at: backup.url) } } private func parseDate(from filename: String) -> Date? { let parts = filename.split(separator: "-") guard parts.count >= 3 else { return nil } let dateString = "\(parts[1])-\(parts[2])" return dateFormatter.date(from: dateString) } private static func fetchAllSources(in context: NSManagedObjectContext) -> [InvestmentSource] { let request: NSFetchRequest = InvestmentSource.fetchRequest() request.sortDescriptors = [NSSortDescriptor(keyPath: \InvestmentSource.name, ascending: true)] return (try? context.fetch(request)) ?? [] } private static func fetchAllCategories(in context: NSManagedObjectContext) -> [Category] { let request: NSFetchRequest = Category.fetchRequest() request.sortDescriptors = [NSSortDescriptor(keyPath: \Category.name, ascending: true)] return (try? context.fetch(request)) ?? [] } }