import Foundation import Photos import UIKit import os.log private let log = Logger(subsystem: "com.albert.nasbackup", category: "BackupStatus") @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" // One active reconciliation task at a time — cancelled before starting a new one private var refreshTask: Task? // Separate debounce task for photo library change events private var debounceTask: Task? private var lastFullRefresh: Date? private static let debounceInterval: TimeInterval = 4 // seconds before photo-change refresh runs private static let minRefreshInterval: TimeInterval = 30 // minimum between full NAS reconciliations // MARK: — Init private override init() { super.init() loadCachedSnapshot() PHPhotoLibrary.shared().register(self) NotificationCenter.default.addObserver( self, selector: #selector(handleMemoryWarning), name: UIApplication.didReceiveMemoryWarningNotification, object: nil ) } deinit { PHPhotoLibrary.shared().unregisterChangeObserver(self) NotificationCenter.default.removeObserver(self) } @objc private func handleMemoryWarning() { log.warning("Memory warning — cancelling in-flight refresh") cancelAll() isRefreshing = false } // MARK: — Public API /// Start a reconciliation. `force: true` bypasses the minimum refresh interval. func refresh(force: Bool = false) { cancelAll() refreshTask = Task { [weak self] in await self?.performRefresh(force: force) } } /// Called by BackupEngine after a completed run with newly uploaded entries. /// Applies an optimistic count update, writes the manifest in background, then reconciles. func refreshAfterBackup(entries: [ManifestEntry], connection: NASConnection) { let uploaded = entries.count snapshot.alreadySafe = min(snapshot.phoneTotal, snapshot.alreadySafe + uploaded) snapshot.nasArchiveTotal += uploaded saveSnapshot() log.info("Post-backup optimistic update: +\(uploaded) safe") // Write manifest then force reconcile (entries captured only briefly) Task { await writeManifest(entries: entries, connection: connection) refresh(force: true) } } // MARK: — Refresh lifecycle private func cancelAll() { refreshTask?.cancel() refreshTask = nil debounceTask?.cancel() debounceTask = nil } private func performRefresh(force: Bool) async { guard !isRefreshing else { log.debug("Refresh skipped — already in progress") return } // Minimum interval guard (skip NAS round-trip if recently refreshed) if !force, let last = lastFullRefresh, Date().timeIntervalSince(last) < Self.minRefreshInterval { log.debug("Refresh debounced — updating phone count only") updatePhoneCountOnly() return } lastFullRefresh = Date() isRefreshing = true refreshError = nil log.info("Refresh started (force=\(force))") do { try await reconcile() log.info("Refresh completed — phone=\(self.snapshot.phoneTotal) safe=\(self.snapshot.alreadySafe)") } catch is CancellationError { log.info("Refresh cancelled") } catch { log.error("Refresh failed: \(error.localizedDescription)") refreshError = error.localizedDescription snapshot.connectionState = .offline snapshot.lastCheckedAt = Date() saveSnapshot() } isRefreshing = false } // MARK: — Reconciliation private func reconcile() async throws { let store = ConnectionStore.shared guard let conn = store.savedConnection else { return } // ── Step 1: Phone count — metadata only, no image data, no thumbnails ── let filter = store.backupFilter let fetchResult = photoService.fetchResultForReconciliation(filter: filter) let phoneTotal = fetchResult.count log.info("PHFetchResult count: \(phoneTotal)") try Task.checkCancellation() // ── Step 2: NAS connection ── 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 — keep cached safe count, enforce invariant snapshot.phoneTotal = phoneTotal snapshot.alreadySafe = min(snapshot.alreadySafe, phoneTotal) snapshot.connectionState = .offline snapshot.lastCheckedAt = Date() saveSnapshot() log.warning("NAS offline — cached numbers retained") return } defer { transfer.disconnect() } try Task.checkCancellation() // ── Step 3: Load ManifestIndex — Data + BackupManifest released after this call ── let manifestPath = "\(conn.remotePath)/\(BackupManifest.remoteFilename)" let index = await buildManifestIndex(transfer: transfer, path: manifestPath, conn: conn) log.info("Manifest index built — \(index.totalCount) NAS entries, filename-only=\(index.isFilenameOnly)") try Task.checkCancellation() // ── Step 4: Count safe assets — PHFetchResult enumerated in batches, no [PhotoAsset] kept ── let safe = try await countSafe(in: fetchResult, against: index) log.info("Comparison complete — \(safe)/\(phoneTotal) already safe") // ── Step 5: Commit — enforce invariant (alreadySafe ≤ phoneTotal) ── var updated = BackupStatusSnapshot() updated.phoneTotal = phoneTotal updated.alreadySafe = min(safe, phoneTotal) updated.nasArchiveTotal = index.totalCount updated.connectionState = .connected updated.lastCheckedAt = Date() snapshot = updated saveSnapshot() } /// Builds a ManifestIndex from NAS. Data and BackupManifest are scoped inside /// this function — they are released before the caller's comparison step begins. private func buildManifestIndex( transfer: any NASTransferProtocol, path: String, conn: NASConnection ) async -> ManifestIndex { do { let data = try await transfer.downloadData(at: path) if let manifest = try? JSONDecoder().decode(BackupManifest.self, from: data) { // `data` and `manifest` released when this scope exits return ManifestIndex(manifest: manifest) } } catch { // File not found — fall through to directory listing bootstrap } // Bootstrap from directory listing let items = (try? await transfer.listDirectory(at: conn.remotePath)) ?? [] log.info("Manifest not found — bootstrapping from \(items.count) NAS items") return ManifestIndex(nasListing: items) } /// Enumerates a PHFetchResult in batches using autoreleasepool. /// Never builds a full [PHAsset] or [PhotoAsset] array. private func countSafe( in result: PHFetchResult, against index: ManifestIndex, batchSize: Int = 150 ) async throws -> Int { var safe = 0 let total = result.count var processed = 0 while processed < total { try Task.checkCancellation() let batchEnd = min(processed + batchSize, total) // autoreleasepool releases PHAsset objects and PHAssetResource arrays // created during this batch before moving to the next let batchSafe: Int = autoreleasepool { var count = 0 for i in processed..