// // GroceryStateSyncManager.swift // Nextcloud Cookbook iOS Client // import Foundation import OSLog @MainActor class GroceryStateSyncManager { private weak var appState: AppState? private weak var groceryManager: GroceryListManager? private var debounceTimers: [String: Task] = [:] private let debounceInterval: TimeInterval = 2.0 private var isReconciling = false private let dataStore = DataStore() private let pendingRemovalPath = "grocery_pending_removals.data" private(set) var pendingRemovalRecipeIds: Set = [] init(appState: AppState, groceryManager: GroceryListManager) { self.appState = appState self.groceryManager = groceryManager } // MARK: - Push Flow /// Debounced sync trigger. Waits `debounceInterval` seconds then pushes state for the recipe. func scheduleSync(forRecipeId recipeId: String) { guard UserSettings.shared.grocerySyncEnabled else { return } guard !isReconciling else { return } debounceTimers[recipeId]?.cancel() debounceTimers[recipeId] = Task { [weak self] in try? await Task.sleep(nanoseconds: UInt64(2_000_000_000)) guard !Task.isCancelled else { return } await self?.pushGroceryState(forRecipeId: recipeId) } } /// Builds local grocery state, fetches server recipe, merges, and PUTs back. func pushGroceryState(forRecipeId recipeId: String) async { guard let appState, let groceryManager else { return } guard let recipeIdInt = Int(recipeId) else { return } // Fetch latest recipe from server first so we can detect deletions guard let serverRecipe = await appState.getRecipe(id: recipeIdInt, fetchMode: .onlyServer) else { Logger.data.error("Grocery sync: failed to fetch recipe \(recipeId) from server") return } let serverState = serverRecipe.groceryState // Build local state, passing server state so deleted items can be marked .removed let localState = buildLocalState(forRecipeId: recipeId, groceryManager: groceryManager, serverState: serverState) // Merge local state with server state let merged = mergeStates(local: localState, server: serverState) // Upload merged state var updatedRecipe = serverRecipe updatedRecipe.groceryState = merged let (_, alert) = await appState.uploadRecipe(recipeDetail: updatedRecipe, createNew: false) if let alert { Logger.data.error("Grocery sync: failed to push state for recipe \(recipeId): \(String(describing: alert))") } } // MARK: - Pull Flow /// Reconciles server grocery state with local grocery data. Called when a recipe is loaded. func reconcileFromServer(serverState: GroceryState?, recipeId: String, recipeName: String) { guard let groceryManager else { return } guard let serverState, !serverState.items.isEmpty else { return } isReconciling = true defer { isReconciling = false } let localItems = Set( groceryManager.groceryDict[recipeId]?.items.map(\.name) ?? [] ) for (itemName, itemState) in serverState.items { switch itemState.status { case .added: if !localItems.contains(itemName) { groceryManager.addItem(itemName, toRecipe: recipeId, recipeName: recipeName) } case .removed: if localItems.contains(itemName) { groceryManager.deleteItem(itemName, fromRecipe: recipeId) } case .completed: // Don't re-add completed items; leave local state as-is break } } } // MARK: - Initial Sync /// Pushes any local-only items and reconciles server items on app launch. func performInitialSync() async { guard let appState, let groceryManager else { return } await loadPendingRemovals() let recipeIds = Array(groceryManager.groceryDict.keys) for recipeId in recipeIds { guard let recipeIdInt = Int(recipeId) else { continue } // Push local state to server await pushGroceryState(forRecipeId: recipeId) // Fetch back and reconcile if let serverRecipe = await appState.getRecipe(id: recipeIdInt, fetchMode: .onlyServer) { let recipeName = groceryManager.groceryDict[recipeId]?.name ?? serverRecipe.name reconcileFromServer( serverState: serverRecipe.groceryState, recipeId: recipeId, recipeName: recipeName ) } } // Push deletion state for recipes whose items were fully removed for recipeId in pendingRemovalRecipeIds { guard !recipeIds.contains(recipeId) else { // Recipe was re-added locally since removal was tracked; clear it pendingRemovalRecipeIds.remove(recipeId) continue } await pushGroceryState(forRecipeId: recipeId) pendingRemovalRecipeIds.remove(recipeId) } savePendingRemovals() } // MARK: - Merge Logic /// Merges local and server states using per-item last-writer-wins on `modifiedAt`. private func mergeStates(local: GroceryState, server: GroceryState?) -> GroceryState { guard let server else { return local } var merged = local.items for (itemName, serverItem) in server.items { if let localItem = merged[itemName] { // Both have the item — keep the one with the later modifiedAt let localDate = GroceryStateDate.date(from: localItem.modifiedAt) ?? .distantPast let serverDate = GroceryStateDate.date(from: serverItem.modifiedAt) ?? .distantPast if serverDate > localDate { merged[itemName] = serverItem } } else { // Only server has this item — keep it merged[itemName] = serverItem } } // Garbage collection: remove items that are removed/completed and older than 30 days let thirtyDaysAgo = Date().addingTimeInterval(-30 * 24 * 60 * 60) merged = merged.filter { _, item in if item.status == .added { return true } guard let modDate = GroceryStateDate.date(from: item.modifiedAt) else { return true } return modDate > thirtyDaysAgo } return GroceryState( lastModified: GroceryStateDate.now(), items: merged ) } // MARK: - Build Local State /// Builds a `GroceryState` from the current local grocery data for a recipe. /// When `serverState` is provided, any server item with `.added` status that is /// absent locally is emitted as `.removed` so the deletion propagates to the server. private func buildLocalState(forRecipeId recipeId: String, groceryManager: GroceryListManager, serverState: GroceryState?) -> GroceryState { var items: [String: GroceryItemState] = [:] let now = GroceryStateDate.now() // Existing local items if let groceryRecipe = groceryManager.groceryDict[recipeId] { for item in groceryRecipe.items { let status: GroceryItemState.Status = item.isChecked ? .completed : .added items[item.name] = GroceryItemState(status: status, addedAt: now, modifiedAt: now) } } // Mark items that exist on server as .added but are absent locally as .removed if let serverState { for (itemName, serverItem) in serverState.items { if items[itemName] == nil && serverItem.status == .added { items[itemName] = GroceryItemState( status: .removed, addedAt: serverItem.addedAt, modifiedAt: now ) } } } return GroceryState(lastModified: now, items: items) } // MARK: - Pending Removal Tracking /// Records a recipe ID whose grocery items were fully removed, so that /// `performInitialSync` can push the deletion even after the key disappears /// from `groceryDict`. func trackPendingRemoval(recipeId: String) { pendingRemovalRecipeIds.insert(recipeId) savePendingRemovals() } func clearPendingRemoval(recipeId: String) { guard pendingRemovalRecipeIds.remove(recipeId) != nil else { return } savePendingRemovals() } private func loadPendingRemovals() async { guard let loaded: Set = try? await dataStore.load(fromPath: pendingRemovalPath) else { return } pendingRemovalRecipeIds = loaded } private func savePendingRemovals() { Task { await dataStore.save(data: pendingRemovalRecipeIds, toPath: pendingRemovalPath) } } }