import Foundation struct AutocompleteEngine { struct AutocompleteResult { let filledSlots: [(slotId: UUID, dishId: UUID)] let unfilledCount: Int } static func autocomplete( emptySlots: [MealSlot], currentPlan: WeekPlan, allDishes: [Dish], allTags: [Tag] ) -> AutocompleteResult { let tagMap = Dictionary(uniqueKeysWithValues: allTags.map { ($0.id, $0) }) let dishMap = Dictionary(uniqueKeysWithValues: allDishes.map { ($0.id, $0) }) var filledSlots: [(slotId: UUID, dishId: UUID)] = [] var unfilledCount = 0 let sortedEmpty = emptySlots.sorted { ($0.dayOfWeek, $0.mealType) < ($1.dayOfWeek, $1.mealType) } for slot in sortedEmpty { let strictCandidates = allDishes.filter { dish in !violatesRules( dish: dish, slot: slot, plan: currentPlan, tagMap: tagMap, dishMap: dishMap ) } // Fallback: if strict rules cannot fill a slot, allow repeating dishes // that only fail the implicit "no repeat this week" rule. let candidates: [Dish] if strictCandidates.isEmpty { candidates = allDishes.filter { dish in !violatesExplicitRules( dish: dish, slot: slot, plan: currentPlan, tagMap: tagMap, dishMap: dishMap ) } } else { candidates = strictCandidates } if candidates.isEmpty { unfilledCount += 1 continue } if let picked = weightedRandomPick(candidates: candidates, plan: currentPlan) { slot.dishId = picked.id filledSlots.append((slotId: slot.id, dishId: picked.id)) } else { unfilledCount += 1 } } return AutocompleteResult(filledSlots: filledSlots, unfilledCount: unfilledCount) } static func validateDrop( dish: Dish, slot: MealSlot, plan: WeekPlan, allTags: [Tag], allDishes: [Dish] ) -> Bool { let tagMap = Dictionary(uniqueKeysWithValues: allTags.map { ($0.id, $0) }) let dishMap = Dictionary(uniqueKeysWithValues: allDishes.map { ($0.id, $0) }) return !violatesRules(dish: dish, slot: slot, plan: plan, tagMap: tagMap, dishMap: dishMap) } private static func violatesRules( dish: Dish, slot: MealSlot, plan: WeekPlan, tagMap: [UUID: Tag], dishMap: [UUID: Dish] ) -> Bool { violatesDefaultNoRepeatRule(dish: dish, slot: slot, plan: plan, tagMap: tagMap) || violatesExplicitRules(dish: dish, slot: slot, plan: plan, tagMap: tagMap, dishMap: dishMap) } private static func violatesDefaultNoRepeatRule( dish: Dish, slot: MealSlot, plan: WeekPlan, tagMap: [UUID: Tag] ) -> Bool { // Default behavior: if a dish has no explicit rules, avoid repeating it in the same week. if !dishHasExplicitRules(dish: dish, tagMap: tagMap) { let alreadyAssignedThisWeek = plan.slots.contains { $0.id != slot.id && $0.dishId == dish.id } if alreadyAssignedThisWeek { return true } } return false } private static func violatesExplicitRules( dish: Dish, slot: MealSlot, plan: WeekPlan, tagMap: [UUID: Tag], dishMap: [UUID: Dish] ) -> Bool { for tagId in dish.tagIds { guard let tag = tagMap[tagId] else { continue } // Max per week if let maxPerWeek = tag.maxPerWeek { var count = 0 for s in plan.slots { guard let did = s.dishId, let d = dishMap[did] else { continue } if d.tagIds.contains(tagId) { count += 1 } } if count >= maxPerWeek { return true } } // No consecutive if tag.noConsecutive { for adjSlot in adjacentSlots(of: slot, in: plan) { guard let did = adjSlot.dishId, let d = dishMap[did] else { continue } if d.tagIds.contains(tagId) { return true } } } // No duplicate in day if tag.noDuplicateInDay { for sdSlot in plan.slots where sdSlot.dayOfWeek == slot.dayOfWeek && sdSlot.id != slot.id { guard let did = sdSlot.dishId, let d = dishMap[did] else { continue } if d.tagIds.contains(tagId) { return true } } } // Meal type restriction if let restriction = tag.mealTypeRestriction, slot.mealType != restriction { return true } } return false } private static func dishHasExplicitRules(dish: Dish, tagMap: [UUID: Tag]) -> Bool { for tagId in dish.tagIds { guard let tag = tagMap[tagId] else { continue } if tag.maxPerWeek != nil || tag.noConsecutive || tag.noDuplicateInDay || tag.mealTypeRestriction != nil { return true } } return false } private static func adjacentSlots(of slot: MealSlot, in plan: WeekPlan) -> [MealSlot] { var result: [MealSlot] = [] if slot.dayOfWeek > 0, let prev = plan.slots.first(where: { $0.dayOfWeek == slot.dayOfWeek - 1 && $0.mealType == slot.mealType }) { result.append(prev) } if slot.dayOfWeek < 6, let next = plan.slots.first(where: { $0.dayOfWeek == slot.dayOfWeek + 1 && $0.mealType == slot.mealType }) { result.append(next) } return result } private static func weightedRandomPick(candidates: [Dish], plan: WeekPlan) -> Dish? { guard !candidates.isEmpty else { return nil } let weights: [Double] = candidates.map { dish in let usage = plan.slots.filter { $0.dishId == dish.id }.count switch usage { case 0: return 3.0 case 1: return 2.0 default: return 1.0 } } let total = weights.reduce(0, +) var r = Double.random(in: 0..