import Foundation import Photos @MainActor final class BackupStatusService: NSObject, ObservableObject { static let shared = BackupStatusService() @Published private(set) var snapshot: BackupStatusSnapshot = .empty @Published private(set) var isRefreshing: Bool = false @Published private(set) var refreshError: String? private let photoService = PhotoLibraryService() private let cacheKey = "backupStatusSnapshot_v2" private var refreshTask: Task? private var lastFullRefresh: Date? private override init() { super.init() loadCachedSnapshot() PHPhotoLibrary.shared().register(self) } // MARK: — Public /// Trigger a full reconciliation. `force: true` bypasses the 30-second debounce. func refresh(force: Bool = false) { refreshTask?.cancel() refreshTask = Task { [weak self] in await self?.performRefresh(force: force) } } /// Called by BackupEngine after a completed run. /// Applies an optimistic update immediately, then writes the manifest and reconciles in background. func refreshAfterBackup(entries: [ManifestEntry], connection: NASConnection) { let uploaded = entries.count snapshot.alreadySafe = min(snapshot.phoneTotal, snapshot.alreadySafe + uploaded) snapshot.nasArchiveTotal += uploaded saveSnapshot() Task { await writeManifest(entries: entries, connection: connection) refresh(force: true) } } // MARK: — Core refresh private func performRefresh(force: Bool) async { guard !isRefreshing else { return } // Debounce: non-forced refreshes skip the NAS round-trip if we just reconciled if !force, let last = lastFullRefresh, Date().timeIntervalSince(last) < 30 { updatePhoneCountOnly() return } lastFullRefresh = Date() isRefreshing = true refreshError = nil do { try await reconcile() } catch is CancellationError { // Swallow — a newer refresh superseded this one } catch { refreshError = error.localizedDescription // Keep last cached numbers; mark NAS offline snapshot.connectionState = .offline snapshot.lastCheckedAt = Date() saveSnapshot() } isRefreshing = false } private func reconcile() async throws { let store = ConnectionStore.shared guard let conn = store.savedConnection else { return } // 1. Count phone assets (fast, no network) let filter = store.backupFilter let assets = photoService.fetchAssets(filter: filter) let phoneTotal = assets.count try Task.checkCancellation() // 2. Connect to NAS let transfer: any NASTransferProtocol = conn.nasProtocol == .smb ? SMBService() : SFTPService() do { try await transfer.connect( to: conn.host, port: conn.port, username: conn.username, password: conn.password ) } catch { // NAS offline — update phone count only, enforce invariant on cached safe count snapshot.phoneTotal = phoneTotal snapshot.alreadySafe = min(snapshot.alreadySafe, phoneTotal) snapshot.connectionState = .offline snapshot.lastCheckedAt = Date() saveSnapshot() return } defer { transfer.disconnect() } try Task.checkCancellation() // 3. Load or bootstrap the manifest let manifestPath = "\(conn.remotePath)/\(BackupManifest.remoteFilename)" let manifest: BackupManifest do { let data = try await transfer.downloadData(at: manifestPath) if let decoded = try? JSONDecoder().decode(BackupManifest.self, from: data) { manifest = decoded } else { let items = (try? await transfer.listDirectory(at: conn.remotePath)) ?? [] manifest = BackupManifest.buildFromNASListing(items) } } catch { // Manifest not found — bootstrap from directory listing let items = (try? await transfer.listDirectory(at: conn.remotePath)) ?? [] manifest = BackupManifest.buildFromNASListing(items) } try Task.checkCancellation() // 4. Compare — localIdentifier first, filename fallback for legacy entries var safe = 0 for asset in assets { if manifest.contains(localIdentifier: asset.localIdentifier) || manifest.containsByFilename(asset.filename) { safe += 1 } } // 5. NAS archive count (excludes hidden manifest file) let nasArchive = manifest.entries.filter { !$0.filename.hasPrefix(".") }.count // 6. Commit — enforce invariant var updated = BackupStatusSnapshot() updated.phoneTotal = phoneTotal updated.alreadySafe = min(safe, phoneTotal) // invariant: never > phoneTotal updated.nasArchiveTotal = nasArchive updated.connectionState = .connected updated.lastCheckedAt = Date() snapshot = updated saveSnapshot() } // Fast phone-only update used when debouncing NAS round-trips private func updatePhoneCountOnly() { let filter = ConnectionStore.shared.backupFilter let assets = photoService.fetchAssets(filter: filter) let phoneTotal = assets.count if phoneTotal != snapshot.phoneTotal { snapshot.phoneTotal = phoneTotal snapshot.alreadySafe = min(snapshot.alreadySafe, phoneTotal) saveSnapshot() } } // MARK: — Manifest write (non-blocking background operation) func writeManifest(entries: [ManifestEntry], connection: NASConnection) async { guard !entries.isEmpty else { return } let transfer: any NASTransferProtocol = connection.nasProtocol == .smb ? SMBService() : SFTPService() do { try await transfer.connect( to: connection.host, port: connection.port, username: connection.username, password: connection.password ) defer { transfer.disconnect() } let manifestPath = "\(connection.remotePath)/\(BackupManifest.remoteFilename)" var manifest: BackupManifest do { let data = try await transfer.downloadData(at: manifestPath) manifest = (try? JSONDecoder().decode(BackupManifest.self, from: data)) ?? BackupManifest() } catch { manifest = BackupManifest() } manifest.merge(entries: entries) let encoder = JSONEncoder() encoder.dateEncodingStrategy = .iso8601 let data = try encoder.encode(manifest) try await transfer.writeData(data, to: manifestPath) } catch { // Manifest write failure is non-fatal — reconciliation rebuilds it next time } } // MARK: — Cache private func loadCachedSnapshot() { guard let data = UserDefaults.standard.data(forKey: cacheKey), let cached = try? JSONDecoder().decode(BackupStatusSnapshot.self, from: data) else { return } snapshot = cached } private func saveSnapshot() { let encoder = JSONEncoder() encoder.dateEncodingStrategy = .iso8601 if let data = try? encoder.encode(snapshot) { UserDefaults.standard.set(data, forKey: cacheKey) } } } // MARK: — PHPhotoLibraryChangeObserver extension BackupStatusService: PHPhotoLibraryChangeObserver { nonisolated func photoLibraryDidChange(_ changeInstance: PHChange) { Task { @MainActor [weak self] in self?.refresh() // debounced — won't hit NAS more than once per 30s } } }