import Foundation import SharedModels import SwiftData enum StartupCoordinator { /// First-launch work that must complete before the UI can be shown. /// Both calls are self-gating: they return immediately if the work is already done. @MainActor static func bootstrap(localContainer: ModelContainer) async { await DataLoader.seedIfNeeded(container: localContainer) await DataLoader.refreshCourseDataIfNeeded(container: localContainer) await DataLoader.refreshTextbookDataIfNeeded(container: localContainer) } /// Recurring maintenance: legacy migrations, identity repair, cloud dedup. /// Safe to run in the background after the UI is visible. @MainActor static func runMaintenance(localContainer: ModelContainer, cloudContainer: ModelContainer) async { await DataLoader.migrateCourseProgressIfNeeded( localContainer: localContainer, cloudContainer: cloudContainer ) let context = cloudContainer.mainContext let progress = ReviewStore.fetchOrCreateUserProgress(context: context) progress.migrateLegacyStorageIfNeeded() if progress.enabledTenseIDs.isEmpty { progress.enabledTenseIDs = ReviewStore.defaultEnabledTenses() } migrateLegacyPayloads(context: context) repairIdentityFields(context: context) dedupeCloudState(context: context) try? context.save() } private static func migrateLegacyPayloads(context: ModelContext) { let progressList = (try? context.fetch(FetchDescriptor())) ?? [] for progress in progressList { progress.migrateLegacyStorageIfNeeded() if progress.enabledTenseIDs.isEmpty { progress.enabledTenseIDs = ReviewStore.defaultEnabledTenses() } if progress.id.isEmpty { progress.id = ReviewStore.progressID } } let testResults = (try? context.fetch(FetchDescriptor())) ?? [] for result in testResults { result.migrateLegacyStorageIfNeeded() } } private static func repairIdentityFields(context: ModelContext) { let reviewCards = (try? context.fetch(FetchDescriptor())) ?? [] for card in reviewCards { card.refreshIdentityIfNeeded() } let dailyLogs = (try? context.fetch(FetchDescriptor())) ?? [] for log in dailyLogs { log.refreshIdentityIfNeeded() } } private static func dedupeCloudState(context: ModelContext) { dedupeUserProgress(context: context) dedupeReviewCards(context: context) dedupeCourseReviewCards(context: context) dedupeDailyLogs(context: context) } private static func dedupeUserProgress(context: ModelContext) { let all = (try? context.fetch(FetchDescriptor())) ?? [] guard all.count > 1 else { return } let ranked = all.sorted { score($0) > score($1) } guard let canonical = ranked.first else { return } canonical.id = ReviewStore.progressID canonical.migrateLegacyStorageIfNeeded() let mergedTenses = Set(all.flatMap(\.enabledTenseIDs)) let mergedBadges = Set(all.flatMap(\.unlockedBadgeIDs)) canonical.enabledTenseIDs = mergedTenses.isEmpty ? ReviewStore.defaultEnabledTenses() : mergedTenses.sorted() canonical.unlockedBadgeIDs = mergedBadges.sorted() canonical.totalReviewed = all.map(\.totalReviewed).max() ?? canonical.totalReviewed canonical.longestStreak = all.map(\.longestStreak).max() ?? canonical.longestStreak canonical.currentStreak = all.map(\.currentStreak).max() ?? canonical.currentStreak canonical.dailyGoal = ranked.first(where: { $0.dailyGoal != 50 })?.dailyGoal ?? canonical.dailyGoal canonical.selectedLevel = ranked.first(where: { $0.selectedLevel != VerbLevel.basic.rawValue })?.selectedLevel ?? canonical.selectedLevel canonical.showVosotros = !all.contains(where: { !$0.showVosotros }) canonical.autoFillStem = all.contains(where: \.autoFillStem) let latestTodayDate = all .map(\.todayDate) .filter { !$0.isEmpty } .max() ?? canonical.todayDate canonical.todayDate = latestTodayDate canonical.todayCount = all .filter { $0.todayDate == latestTodayDate } .map(\.todayCount) .max() ?? canonical.todayCount for other in all where other !== canonical { context.delete(other) } } private static func dedupeReviewCards(context: ModelContext) { let all = (try? context.fetch(FetchDescriptor())) ?? [] let groups = Dictionary(grouping: all, by: { ReviewCard.makeKey(verbId: $0.verbId, tenseId: $0.tenseId, personIndex: $0.personIndex) }) for (key, cards) in groups { guard cards.count > 1 else { cards.first?.key = key continue } guard let canonical = canonicalReviewCard(from: cards) else { continue } canonical.key = key for other in cards where other !== canonical { context.delete(other) } } } private static func dedupeCourseReviewCards(context: ModelContext) { let all = (try? context.fetch(FetchDescriptor())) ?? [] let groups = Dictionary(grouping: all, by: \.id) for (id, cards) in groups where cards.count > 1 { guard let canonical = cards.max(by: { reviewScore($0) < reviewScore($1) }) else { continue } canonical.id = id for other in cards where other !== canonical { context.delete(other) } } } private static func dedupeDailyLogs(context: ModelContext) { let all = (try? context.fetch(FetchDescriptor())) ?? [] let groups = Dictionary(grouping: all, by: \.dateString) for (dateString, logs) in groups { guard let canonical = logs.first else { continue } canonical.id = DailyLog.makeID(dateString) if logs.count == 1 { continue } canonical.reviewCount = logs.reduce(0) { $0 + $1.reviewCount } canonical.correctCount = logs.reduce(0) { $0 + $1.correctCount } for other in logs.dropFirst() { context.delete(other) } } } private static func score(_ progress: UserProgress) -> Int { (progress.totalReviewed * 10_000) + (progress.longestStreak * 100) + progress.currentStreak } private static func canonicalReviewCard(from cards: [ReviewCard]) -> ReviewCard? { let latest = cards.max { let lhsDate = $0.lastReviewDate ?? .distantPast let rhsDate = $1.lastReviewDate ?? .distantPast if lhsDate != rhsDate { return lhsDate < rhsDate } if $0.repetitions != $1.repetitions { return $0.repetitions < $1.repetitions } return $0.interval < $1.interval } guard let canonical = latest else { return nil } canonical.refreshIdentityIfNeeded() return canonical } private static func reviewScore(_ card: CourseReviewCard) -> Int { let timestamp = Int(card.lastReviewDate?.timeIntervalSince1970 ?? 0) return (timestamp * 10_000) + (card.repetitions * 100) + card.interval } }