// // 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 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 } 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 } // Build local state from current grocery data let localState = buildLocalState(forRecipeId: recipeId, groceryManager: groceryManager) // Fetch latest recipe from server guard let serverRecipe = await appState.getRecipe(id: recipeIdInt, fetchMode: .onlyServer) else { Logger.data.error("Grocery sync: failed to fetch recipe \(recipeId) from server") return } // Merge local state with server state let serverState = serverRecipe.groceryState 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 } 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 } 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 ) } } } // 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. private func buildLocalState(forRecipeId recipeId: String, groceryManager: GroceryListManager) -> GroceryState { guard let groceryRecipe = groceryManager.groceryDict[recipeId] else { return GroceryState() } var items: [String: GroceryItemState] = [:] let now = GroceryStateDate.now() for item in groceryRecipe.items { let status: GroceryItemState.Status = item.isChecked ? .completed : .added items[item.name] = GroceryItemState(status: status, addedAt: now, modifiedAt: now) } return GroceryState(lastModified: now, items: items) } }