import Foundation import Combine import UIKit import os.log private let log = Logger(subsystem: "com.albert.nasbackup", category: "AutoBackup") /// Decides when to start backup automatically and tracks the high-level app phase. /// /// Phase model: /// idle — no pending work, or destination not configured /// checking — reconciliation running, awaiting the pending-queue size /// autoBackingUp — coordinator-triggered backup running /// disconnected — NAS offline while items are queued; will retry when reachable /// /// Active-backup states (running/paused/completed/failed) are owned by BackupEngine.job /// and are read directly by BackupView — the coordinator does not duplicate them. @MainActor final class AutoBackupCoordinator: ObservableObject { static let shared = AutoBackupCoordinator() enum Phase: Equatable { case idle case checking case autoBackingUp case disconnected } @Published private(set) var phase: Phase = .idle @Published private(set) var pendingAtDisconnect: Int = 0 private let engine = BackupEngine.shared private let statusService = BackupStatusService.shared private let store = ConnectionStore.shared private let lanMonitor = LANMonitor.shared private var cancellables = Set() // MARK: — Init private init() { setupObservation() } private func setupObservation() { // Fires when reconciliation transitions from running → done. // dropFirst() skips the initial subscription value so we don't fire on // startup before the first real reconciliation has run. statusService.$isRefreshing .dropFirst() .filter { !$0 } .receive(on: RunLoop.main) .sink { [weak self] _ in self?.handleReconciliationComplete() } .store(in: &cancellables) // Fires when NAS transitions to reachable — only acts when in .disconnected phase // so we don't duplicate the trigger on initial startup or during active backup. lanMonitor.$nasReachable .dropFirst() .compactMap { $0 } .filter { $0 } .receive(on: RunLoop.main) .sink { [weak self] _ in self?.handleNASReachable() } .store(in: &cancellables) } // MARK: — External triggers /// Call when the app becomes active: on first launch, foreground resume, or session unlock. /// Safe to call repeatedly — guards prevent redundant reconciliation. func onActive() { guard !engine.job.status.isActive else { return } guard store.savedConnection != nil else { return } guard phase == .idle else { return } // Don't interrupt an in-progress check phase = .checking // force: false — BackupStatusService's 30-second debounce prevents expensive // rescans on rapid foreground/background cycles. The cached snapshot is shown // immediately; reconciliation only runs when truly stale. statusService.refresh(force: false) } // MARK: — Combine handlers private func handleReconciliationComplete() { let snapshot = statusService.snapshot // No destination configured yet guard let conn = store.savedConnection, conn.remotePath != "/" else { phase = .idle return } // Nothing to back up — clear any stale state guard snapshot.needBackup > 0 else { phase = .idle pendingAtDisconnect = 0 return } // NAS offline — queue pending count and wait for reachability if snapshot.connectionState == .offline { pendingAtDisconnect = snapshot.needBackup phase = .disconnected log.info("NAS offline — \(snapshot.needBackup) items queued") return } // Auto-backup setting is off — show count but don't start guard store.autoBackupEnabled else { phase = .idle return } triggerAutoBackupIfNeeded(conn: conn) } private func handleNASReachable() { guard case .disconnected = phase else { return } log.info("NAS reachable again — rescheduling auto-check") onActive() } // MARK: — Auto-backup trigger private func triggerAutoBackupIfNeeded(conn: NASConnection) { guard canAutoBackup else { return } let pending = statusService.snapshot.needBackup log.info("Auto-backup triggered: \(pending) pending items → \(conn.remotePath)") phase = .autoBackingUp pendingAtDisconnect = 0 Task { do { _ = try await engine.run(connection: conn, filter: store.backupFilter) } catch { log.error("Auto-backup error: \(error.localizedDescription)") } // engine.run() calls refreshAfterBackup() which triggers reconciliation. // handleReconciliationComplete() will fire again and reset phase. if case .autoBackingUp = phase { phase = .idle } } } // MARK: — Guards private var canAutoBackup: Bool { // Already running guard !engine.job.status.isActive else { return false } // Quiet hours guard !store.isInQuietHours else { log.info("Auto-backup skipped — quiet hours active") return false } // Charging-only mode guard isBatteryOK else { log.info("Auto-backup skipped — not charging (chargingOnlyMode enabled)") return false } return true } private var isBatteryOK: Bool { guard store.chargingOnlyMode else { return true } let state = UIDevice.current.batteryState // .unknown covers simulator and unavailable reads — allow in those cases return state == .charging || state == .full || state == .unknown } }