import Foundation struct AutocompleteEngine { struct AutocompleteResult { let filledSlots: [(slotId: UUID, dishId: UUID)] let unfilledCount: Int } struct RuleViolation { let slotId: UUID let dayOfWeek: Int let mealType: String let dishId: UUID let dishName: String /// Which rules this assignment breaks — the panel turns these into a /// plain-language explanation and a suggested fix. let reasons: [Reason] struct Reason: Hashable { enum Kind: Hashable { case maxPerWeek case noConsecutive case noDuplicateInDay case mealTypeOnly case dayRestriction case repeatedInWeek } let kind: Kind let tagId: UUID? let limit: Int? /// "lunch"/"dinner" for mealTypeOnly, "weekdays"/"weekend" for dayRestriction. let restriction: String? init(kind: Kind, tagId: UUID? = nil, limit: Int? = nil, restriction: String? = nil) { self.kind = kind self.tagId = tagId self.limit = limit self.restriction = restriction } } } // recentPlans: up to 12 previous weeks for period detection (weekly/bi-weekly/monthly cycles). static func autocomplete( emptySlots: [MealSlot], currentPlan: WeekPlan, allDishes: [Dish], allTags: [Tag], recentPlans: [WeekPlan] = [], rejectionCounts: [UUID: Int] = [:] ) -> AutocompleteResult { let tagMap = Dictionary(allTags.map { ($0.id, $0) }, uniquingKeysWith: { first, _ in first }) let dishMap = Dictionary(allDishes.map { ($0.id, $0) }, uniquingKeysWith: { first, _ in first }) let historyScores = periodScores(dishes: allDishes, recentPlans: recentPlans, currentWeekStart: currentPlan.weekStartDate) let ratingScores = Self.ratingScores(dishes: allDishes, recentPlans: recentPlans) var filledSlots: [(slotId: UUID, dishId: UUID)] = [] var unfilledCount = 0 var pendingSlots = emptySlots.filter { !$0.isEatingOut && !$0.isSkipped } // Fixed-slot rules win outright: a dish pinned to a weekday+meal fills // that slot before any scoring. Runs before the MRV loop so the pinned // dish also counts against no-repeat/tag rules for the rest of the week. for slot in pendingSlots { guard let fixed = allDishes.first(where: { $0.fixedDayOfWeek == slot.dayOfWeek && $0.fixedMealType == slot.mealType }) else { continue } slot.dishId = fixed.id filledSlots.append((slotId: slot.id, dishId: fixed.id)) } pendingSlots.removeAll { $0.dishId != nil } // MRV: at each step pick the slot with fewest valid candidates first. // Assignments update currentPlan.slotList in-place, so subsequent candidate // counts automatically reflect the growing set of committed dishes. while !pendingSlots.isEmpty { // Score each remaining slot by strict candidate count let ranked = pendingSlots .map { slot -> (slot: MealSlot, strict: [Dish]) in let strict = allDishes.filter { !violatesRules(dish: $0, slot: slot, plan: currentPlan, tagMap: tagMap, dishMap: dishMap) } return (slot, strict) } .sorted { $0.strict.count < $1.strict.count } let (slot, strictCandidates) = ranked[0] pendingSlots.removeAll { $0.id == slot.id } // Fallback: relax the implicit no-repeat rule if strict set is empty let candidates: [Dish] if strictCandidates.isEmpty { candidates = allDishes.filter { !violatesExplicitRules(dish: $0, 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, historyScores: historyScores, ratingScores: ratingScores, rejectionCounts: rejectionCounts) { slot.dishId = picked.id filledSlots.append((slotId: slot.id, dishId: picked.id)) } else { unfilledCount += 1 } } return AutocompleteResult(filledSlots: filledSlots, unfilledCount: unfilledCount) } static func findViolations(plan: WeekPlan, allDishes: [Dish], allTags: [Tag]) -> [RuleViolation] { let dishMap = Dictionary(allDishes.map { ($0.id, $0) }, uniquingKeysWith: { first, _ in first }) let tagMap = Dictionary(allTags.map { ($0.id, $0) }, uniquingKeysWith: { first, _ in first }) // Computed from the current plan instead of the stored // `isRuleOverridden` flag: that flag went stale (it listed meals whose // conflict had already been resolved, with nothing to explain) and it // missed violations that only appear when a rule is edited after // planning. Slots the user explicitly dismissed stay hidden. return plan.slotList.compactMap { slot in guard let dishId = slot.dishId, let dish = dishMap[dishId], !slot.isEatingOut, !slot.isSkipped, !slot.isRuleIgnored else { return nil } let found = reasons(dish: dish, slot: slot, plan: plan, tagMap: tagMap, dishMap: dishMap) guard !found.isEmpty else { return nil } return RuleViolation( slotId: slot.id, dayOfWeek: slot.dayOfWeek, mealType: slot.mealType, dishId: dishId, dishName: dish.name, reasons: found ) } } /// Every rule the dish breaks in this slot. Mirrors `violatesExplicitRules` /// but collects instead of short-circuiting, so the UI can explain itself. static func reasons( dish: Dish, slot: MealSlot, plan: WeekPlan, tagMap: [UUID: Tag], dishMap: [UUID: Dish] ) -> [RuleViolation.Reason] { var found: [RuleViolation.Reason] = [] if plan.slotList.contains(where: { $0.id != slot.id && $0.dishId == dish.id }) { found.append(.init(kind: .repeatedInWeek)) } for tagId in dish.tagIds { guard let tag = tagMap[tagId] else { continue } if let maxPerWeek = tag.maxPerWeek { var count = 0 for s in plan.slotList { guard let did = s.dishId, let d = dishMap[did] else { continue } if d.tagIds.contains(tagId) { count += 1 } } if count > maxPerWeek { found.append(.init(kind: .maxPerWeek, tagId: tagId, limit: maxPerWeek)) } } 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) { found.append(.init(kind: .noConsecutive, tagId: tagId)) break } } } if tag.noDuplicateInDay { for sdSlot in plan.slotList 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) { found.append(.init(kind: .noDuplicateInDay, tagId: tagId)) break } } } if let restriction = tag.mealTypeRestriction, slot.mealType != restriction { found.append(.init(kind: .mealTypeOnly, tagId: tagId, restriction: restriction)) } if let dayRestriction = tag.dayRestriction { let broken = (dayRestriction == "weekdays" && slot.dayOfWeek > 4) || (dayRestriction == "weekend" && slot.dayOfWeek < 5) if broken { found.append(.init(kind: .dayRestriction, tagId: tagId, restriction: dayRestriction)) } } } return found } static func validateDrop( dish: Dish, slot: MealSlot, plan: WeekPlan, allTags: [Tag], allDishes: [Dish] ) -> Bool { let tagMap = Dictionary(allTags.map { ($0.id, $0) }, uniquingKeysWith: { first, _ in first }) let dishMap = Dictionary(allDishes.map { ($0.id, $0) }, uniquingKeysWith: { first, _ in first }) return !violatesRules(dish: dish, slot: slot, plan: plan, tagMap: tagMap, dishMap: dishMap) } // MARK: - Rule checks private static func violatesRules( dish: Dish, slot: MealSlot, plan: WeekPlan, tagMap: [UUID: Tag], dishMap: [UUID: Dish] ) -> Bool { violatesDefaultNoRepeatRule(dish: dish, slot: slot, plan: plan) || violatesExplicitRules(dish: dish, slot: slot, plan: plan, tagMap: tagMap, dishMap: dishMap) } private static func violatesDefaultNoRepeatRule(dish: Dish, slot: MealSlot, plan: WeekPlan) -> Bool { plan.slotList.contains { $0.id != slot.id && $0.dishId == dish.id } } 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 } if let maxPerWeek = tag.maxPerWeek { var count = 0 for s in plan.slotList { guard let did = s.dishId, let d = dishMap[did] else { continue } if d.tagIds.contains(tagId) { count += 1 } } if count >= maxPerWeek { return true } } 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 } } } if tag.noDuplicateInDay { for sdSlot in plan.slotList 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 } } } if let restriction = tag.mealTypeRestriction, slot.mealType != restriction { return true } if let dayRestriction = tag.dayRestriction { switch dayRestriction { case "weekdays" where slot.dayOfWeek > 4: return true case "weekend" where slot.dayOfWeek < 5: return true default: break } } } return false } private static func adjacentSlots(of slot: MealSlot, in plan: WeekPlan) -> [MealSlot] { var result: [MealSlot] = [] if slot.dayOfWeek > 0, let prev = plan.slotList.first(where: { $0.dayOfWeek == slot.dayOfWeek - 1 && $0.mealType == slot.mealType }) { result.append(prev) } if slot.dayOfWeek < 6, let next = plan.slotList.first(where: { $0.dayOfWeek == slot.dayOfWeek + 1 && $0.mealType == slot.mealType }) { result.append(next) } return result } // MARK: - Period-based scoring // Weight multiplier per dish based on its detected recurrence cycle. // Values > 1.0 boost a dish that is "due"; < 1.0 suppress one used too recently. // Requires recentPlans covering ≥ 8–12 weeks to reliably detect monthly cycles. private static func periodScores( dishes: [Dish], recentPlans: [WeekPlan], currentWeekStart: Date ) -> [UUID: Double] { guard !recentPlans.isEmpty else { return [:] } let calendar = Calendar.current var scores: [UUID: Double] = [:] for dish in dishes { // Week offsets: how many weeks ago did this dish appear? var offsets: [Int] = [] for plan in recentPlans { guard plan.slotList.contains(where: { $0.dishId == dish.id }) else { continue } let diff = calendar.dateComponents([.weekOfYear], from: plan.weekStartDate, to: currentWeekStart).weekOfYear ?? 0 if diff > 0 { offsets.append(diff) } } guard !offsets.isEmpty else { continue } let weeksSinceLast = offsets.min()! guard offsets.count >= 2 else { // Single sighting: simple recency penalty, no cycle to detect switch weeksSinceLast { case 1: scores[dish.id] = 0.20 case 2: scores[dish.id] = 0.50 case 3: scores[dish.id] = 0.75 default: break // ≥4 weeks ago → neutral (1.0) } continue } // Estimate recurrence period via median of consecutive intervals let sorted = offsets.sorted() var intervals: [Int] = [] for i in 0.. Double { let s = values.sorted() let n = s.count return n % 2 == 1 ? Double(s[n / 2]) : Double(s[n / 2 - 1] + s[n / 2]) / 2.0 } // MARK: - Rating-based scoring // Boost dishes that appeared in liked weeks; penalise those from disliked weeks. // Signal is intentionally modest (±0.5 max) so period scoring dominates. private static func ratingScores(dishes: [Dish], recentPlans: [WeekPlan]) -> [UUID: Double] { let ratedPlans = recentPlans.filter { $0.userRating != 0 } guard !ratedPlans.isEmpty else { return [:] } var scores: [UUID: Double] = [:] for dish in dishes { var liked = 0, disliked = 0 for plan in ratedPlans { guard plan.slotList.contains(where: { $0.dishId == dish.id }) else { continue } if plan.userRating > 0 { liked += 1 } else { disliked += 1 } } guard liked + disliked > 0 else { continue } let boost = min(0.50, Double(liked) * 0.20) let penalty = min(0.50, Double(disliked) * 0.25) scores[dish.id] = max(0.50, 1.0 + boost - penalty) } return scores } // MARK: - Weighted pick private static func weightedRandomPick( candidates: [Dish], plan: WeekPlan, historyScores: [UUID: Double] = [:], ratingScores: [UUID: Double] = [:], rejectionCounts: [UUID: Int] = [:] ) -> Dish? { guard !candidates.isEmpty else { return nil } let weights: [Double] = candidates.map { dish in let usage = plan.slotList.filter { $0.dishId == dish.id }.count let priorityBoost: Double = dish.isPriority ? 2.5 : 1.0 let patternScore = historyScores[dish.id] ?? 1.0 let ratingScore = ratingScores[dish.id] ?? 1.0 let rejectionScore = rejectionCounts[dish.id].map { max(0.30, 1.0 - 0.25 * Double($0)) } ?? 1.0 let baseWeight: Double switch usage { case 0: baseWeight = 3.0 case 1: baseWeight = 2.0 default: baseWeight = 1.0 } return baseWeight * priorityBoost * patternScore * ratingScore * rejectionScore } let total = weights.reduce(0, +) var r = Double.random(in: 0..