import Foundation import SwiftData @MainActor final class ICloudSyncService { static let shared = ICloudSyncService() private let store = NSUbiquitousKeyValueStore.default private let payloadKey = "mealmood_sync_payload_v1" private let lastAppliedKey = "mealmood_sync_last_applied" private var isApplyingRemote = false private init() {} func pullRemoteIfNeeded(context: ModelContext) async { // 2.0: CloudKit syncs the store directly — applying legacy KV snapshots // on top would duplicate/fight with it. KV stays for 1.x devices only. guard !CloudSyncRuntime.isCloudKitActive else { return } store.synchronize() guard let data = store.data(forKey: payloadKey), let snapshot = try? JSONDecoder().decode(SyncSnapshot.self, from: data) else { return } let lastApplied = UserDefaults.standard.double(forKey: lastAppliedKey) let remoteTs = snapshot.updatedAt.timeIntervalSince1970 if remoteTs <= lastApplied { return } isApplyingRemote = true defer { isApplyingRemote = false } apply(snapshot: snapshot, context: context) UserDefaults.standard.set(remoteTs, forKey: lastAppliedKey) } func pushLocalSnapshot(context: ModelContext) async { guard !CloudSyncRuntime.isCloudKitActive else { return } if isApplyingRemote { return } guard let snapshot = makeSnapshot(context: context) else { return } guard let data = try? JSONEncoder().encode(snapshot) else { return } store.set(data, forKey: payloadKey) store.synchronize() UserDefaults.standard.set(snapshot.updatedAt.timeIntervalSince1970, forKey: lastAppliedKey) } private func makeSnapshot(context: ModelContext) -> SyncSnapshot? { let settings = (try? context.fetch(FetchDescriptor()))?.first let tags = (try? context.fetch(FetchDescriptor())) ?? [] let dishes = (try? context.fetch(FetchDescriptor())) ?? [] let plans = (try? context.fetch(FetchDescriptor())) ?? [] guard settings != nil || !tags.isEmpty || !dishes.isEmpty || !plans.isEmpty else { return nil } let snapshot = SyncSnapshot( updatedAt: Date(), settings: settings.map { SettingsPayload( mealWindows: $0.mealWindows, enabledMealTypesRaw: $0.enabledMealTypesRaw, includeWeekends: $0.includeWeekends, language: $0.language, calendarId: $0.calendarId, syncEnabled: $0.syncEnabled, syncMode: $0.syncMode, lunchTime: $0.lunchTime, dinnerTime: $0.dinnerTime, eventDuration: $0.eventDuration, eventPrefix: $0.eventPrefix, reminderMinutesBefore: $0.reminderMinutesBefore, iCloudSyncEnabled: $0.iCloudSyncEnabled, weekExportStyle: $0.weekExportStyle, onboardingCompleted: $0.onboardingCompleted ) }, tags: tags.map { TagPayload( id: $0.id, name: $0.name, nameEN: $0.nameEN, color: $0.color, maxPerWeek: $0.maxPerWeek, noConsecutive: $0.noConsecutive, noDuplicateInDay: $0.noDuplicateInDay, mealTypeRestriction: $0.mealTypeRestriction, isDefault: $0.isDefault, sortOrder: $0.sortOrder ) }, dishes: dishes.map { DishPayload( id: $0.id, name: $0.name, descriptionText: $0.descriptionText, tagIds: $0.tagIds, createdAt: $0.createdAt, ingredients: $0.ingredients ) }, weekPlans: plans.map { plan in WeekPlanPayload( id: plan.id, weekStartDate: plan.weekStartDate, createdAt: plan.createdAt, updatedAt: plan.updatedAt, slots: plan.slotList.map { MealSlotPayload( id: $0.id, dayOfWeek: $0.dayOfWeek, mealType: $0.mealType, dishId: $0.dishId, secondaryDishId: $0.secondaryDishId, calendarEventId: $0.calendarEventId, isRuleOverridden: $0.isRuleOverridden ) } ) } ) return snapshot } private func apply(snapshot: SyncSnapshot, context: ModelContext) { guard snapshot.settings != nil || !snapshot.tags.isEmpty || !snapshot.dishes.isEmpty || !snapshot.weekPlans.isEmpty else { return } let existingSlots = (try? context.fetch(FetchDescriptor())) ?? [] let existingPlans = (try? context.fetch(FetchDescriptor())) ?? [] let existingDishes = (try? context.fetch(FetchDescriptor())) ?? [] let existingTags = (try? context.fetch(FetchDescriptor())) ?? [] let existingSettings = (try? context.fetch(FetchDescriptor())) ?? [] existingSlots.forEach { context.delete($0) } existingPlans.forEach { context.delete($0) } existingDishes.forEach { context.delete($0) } existingTags.forEach { context.delete($0) } existingSettings.forEach { context.delete($0) } if let settingsPayload = snapshot.settings { let settings = AppSettings() settings.mealWindows = settingsPayload.mealWindows settings.enabledMealTypesRaw = settingsPayload.enabledMealTypesRaw settings.includeWeekends = settingsPayload.includeWeekends settings.language = settingsPayload.language settings.calendarId = settingsPayload.calendarId settings.syncEnabled = settingsPayload.syncEnabled settings.syncMode = settingsPayload.syncMode settings.lunchTime = settingsPayload.lunchTime settings.dinnerTime = settingsPayload.dinnerTime settings.eventDuration = settingsPayload.eventDuration settings.eventPrefix = settingsPayload.eventPrefix settings.reminderMinutesBefore = settingsPayload.reminderMinutesBefore settings.iCloudSyncEnabled = settingsPayload.iCloudSyncEnabled settings.weekExportStyle = settingsPayload.weekExportStyle // isPremium is intentionally NOT synced via iCloud. // It is determined exclusively by StoreKit to avoid stale state // overwriting a freshly completed purchase. settings.onboardingCompleted = settingsPayload.onboardingCompleted context.insert(settings) } snapshot.tags.forEach { payload in let tag = Tag( id: payload.id, name: payload.name, nameEN: payload.nameEN, color: payload.color, maxPerWeek: payload.maxPerWeek, noConsecutive: payload.noConsecutive, noDuplicateInDay: payload.noDuplicateInDay, mealTypeRestriction: payload.mealTypeRestriction, isDefault: payload.isDefault, sortOrder: payload.sortOrder ) context.insert(tag) } snapshot.dishes.forEach { payload in let dish = Dish( id: payload.id, name: payload.name, descriptionText: payload.descriptionText, tagIds: payload.tagIds, createdAt: payload.createdAt, ingredients: payload.ingredients ?? [] ) context.insert(dish) } snapshot.weekPlans.forEach { payload in let plan = WeekPlan( id: payload.id, weekStartDate: payload.weekStartDate, slots: [], createdAt: payload.createdAt, updatedAt: payload.updatedAt ) context.insert(plan) payload.slots.forEach { slotPayload in let slot = MealSlot( id: slotPayload.id, dayOfWeek: slotPayload.dayOfWeek, mealType: slotPayload.mealType, dishId: slotPayload.dishId, secondaryDishId: slotPayload.secondaryDishId, calendarEventId: slotPayload.calendarEventId, isRuleOverridden: slotPayload.isRuleOverridden ) slot.weekPlan = plan plan.slotList.append(slot) context.insert(slot) } } try? context.save() } } private struct SyncSnapshot: Codable { let updatedAt: Date let settings: SettingsPayload? let tags: [TagPayload] let dishes: [DishPayload] let weekPlans: [WeekPlanPayload] } private struct SettingsPayload: Codable { let mealWindows: String // Optional for backward-compatibility with pre-2.0 snapshots. let enabledMealTypesRaw: String? let includeWeekends: Bool let language: String let calendarId: String? let syncEnabled: Bool let syncMode: String? let lunchTime: Date let dinnerTime: Date let eventDuration: Int let eventPrefix: String let reminderMinutesBefore: Int? let iCloudSyncEnabled: Bool? let weekExportStyle: String? let onboardingCompleted: Bool // isPremium is intentionally omitted — determined by StoreKit only, never by sync. // Old payloads in iCloud may contain this key; it is safely ignored by the decoder. } private struct TagPayload: Codable { let id: UUID let name: String let nameEN: String let color: String let maxPerWeek: Int? let noConsecutive: Bool let noDuplicateInDay: Bool let mealTypeRestriction: String? let isDefault: Bool let sortOrder: Int } private struct DishPayload: Codable { let id: UUID let name: String let descriptionText: String? let tagIds: [UUID] let createdAt: Date // Optional for backward-compatibility with pre-2.0 snapshots. Photos are not // synced through the KV store (size limits) — they ride CloudKit instead. let ingredients: [String]? } private struct WeekPlanPayload: Codable { let id: UUID let weekStartDate: Date let createdAt: Date let updatedAt: Date let slots: [MealSlotPayload] } private struct MealSlotPayload: Codable { let id: UUID let dayOfWeek: Int let mealType: String let dishId: UUID? // Optional so payloads written by older installs (no key) still decode. var secondaryDishId: UUID? let calendarEventId: String? let isRuleOverridden: Bool }