import Foundation import Photos import UIKit import os.log private let log = Logger(subsystem: "com.albert.nasbackup", category: "BackupStatus") private let signposter = OSSignposter(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? @Published private(set) var lastManifest: BackupManifest? 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? private var debounceTask: Task? private var lastFullRefresh: Date? private static let debounceInterval: TimeInterval = 4 private static let minRefreshInterval: TimeInterval = 30 // 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 func refresh(force: Bool = false) { cancelAll() refreshTask = Task { [weak self] in await self?.performRefresh(force: force) } } func refreshAndWait(force: Bool = true) async { cancelAll() await performRefresh(force: force) } func refreshAfterBackup(entries: [ManifestEntry], connection: NASConnection) { let uploaded = entries.count snapshot.alreadySafe = min(snapshot.phoneTotal, snapshot.alreadySafe + uploaded) // nasArchiveTotal is NOT updated optimistically — writeManifest will update // NASManifestCache with the full merged count, and refresh(force: true) reads it. saveSnapshot() log.info("Post-backup optimistic update: +\(uploaded) safe") 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 } 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 spReconcile = signposter.beginInterval("Reconcile") defer { signposter.endInterval("Reconcile", spReconcile) } let store = ConnectionStore.shared guard let conn = store.savedConnection else { return } let filter = store.backupFilter // ── Fast path: use local caches when both are warm ──────────────── // Skips NAS connection + PHFetchResult batch enumeration entirely. // Triggered when: manifest cache < 5 min old AND LocalPhotoIndex has data. let manifestCacheIsStale = await NASManifestCache.shared.isStale let localIndexCount = await LocalPhotoIndex.shared.totalCount if !manifestCacheIsStale, let cached = await NASManifestCache.shared.manifest, localIndexCount > 0 { let index = await Task.detached(priority: .utility) { ManifestIndex(manifest: cached) }.value let safe = await LocalPhotoIndex.shared.countSafe(against: index, filter: filter) let phoneTotal = await LocalPhotoIndex.shared.totalCount var updated = BackupStatusSnapshot() updated.phoneTotal = phoneTotal updated.alreadySafe = min(safe, phoneTotal) updated.nasArchiveTotal = index.totalCount updated.connectionState = .connected updated.lastCheckedAt = Date() snapshot = updated saveSnapshot() log.info("Reconcile (cached): \(phoneTotal) phone, \(safe) safe, \(index.totalCount) NAS") return } // ── Full path: connect to NAS and download fresh manifest ───────── // Step 1: Phone count (O(1) PHFetchResult.count) 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 { 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 ──────────────────────────────────── let spManifest = signposter.beginInterval("ManifestLoad") let manifestPath = "\(conn.remotePath)/\(BackupManifest.remoteFilename)" let index = await buildManifestIndex(transfer: transfer, path: manifestPath, conn: conn) signposter.endInterval("ManifestLoad", spManifest, "\(index.totalCount) entries, filename-only=\(index.isFilenameOnly)") log.info("Manifest index built — \(index.totalCount) NAS entries") try Task.checkCancellation() // ── Step 4: Count safe assets ───────────────────────────────────── // Prefer LocalPhotoIndex (pure in-memory, no CoreData) if populated; // otherwise fall back to batch PHFetchResult enumeration. let spCount = signposter.beginInterval("CountSafe") let localCount = await LocalPhotoIndex.shared.totalCount let safe: Int if localCount > 0 { safe = await LocalPhotoIndex.shared.countSafe(against: index, filter: filter) } else { safe = try await Task.detached(priority: .userInitiated) { [weak self, fetchResult, index] in guard let self else { throw CancellationError() } return try await self.countSafe(in: fetchResult, against: index) }.value } signposter.endInterval("CountSafe", spCount, "\(safe)/\(phoneTotal) safe") log.info("Comparison complete — \(safe)/\(phoneTotal) already safe") // ── Step 5: Commit ──────────────────────────────────────────────── 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. /// - The download suspends the main actor. /// - JSON decode and Set construction run in a background task. private func buildManifestIndex( transfer: any NASTransferProtocol, path: String, conn: NASConnection ) async -> ManifestIndex { do { let data = try await transfer.downloadData(at: path) // Decode and build index off main actor — JSONDecoder is not cheap for large manifests. let decoded: (ManifestIndex, BackupManifest)? = await Task.detached(priority: .utility) { guard let manifest = try? JSONDecoder.kisani.decode(BackupManifest.self, from: data) else { return nil } return (ManifestIndex(manifest: manifest), manifest) }.value if let (index, manifest) = decoded { lastManifest = manifest // Persist to local cache so future launches skip the NAS round-trip. Task { await NASManifestCache.shared.update(manifest) } return index } } catch { // File not found — fall through to directory listing bootstrap } // Bootstrap: list NAS directory and build filename-only index in background. let items = (try? await transfer.listDirectory(at: conn.remotePath)) ?? [] log.info("Manifest not found — bootstrapping from \(items.count) NAS items") return await Task.detached(priority: .utility) { ManifestIndex(nasListing: items) }.value } /// Enumerates a PHFetchResult in batches using autoreleasepool. /// Marked nonisolated so it can be called from a background Task without /// requiring main-actor scheduling — it accesses no @MainActor state. private nonisolated 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..