import Foundation // Pure planning logic for the developer-facing "duplicate cleanup" tool. No I/O // and no `SyncEngine` dependency, so it's fully unit-testable in isolation: // `SyncEngine.scanForDuplicates()` gathers the already-decoded documents and the // referenced-split-id set, hands them to `DuplicateCleanupPlanner.plan(...)`, and // `SyncEngine.performCleanup(_:)` executes the resulting plan file by file. // // Duplicates are detected by a *content* fingerprint that deliberately ignores // identity (ids), timestamps, and cosmetic fields — two splits/workouts that were // created independently (e.g. by a sync hiccup, a restore, or manual testing) but // carry the same real content are duplicates even though every id differs. // MARK: - Plan struct DuplicateCleanupPlan: Sendable { struct SplitGroup: Sendable, Identifiable { /// Members that survive — always non-empty when the group is emitted. var keep: [SplitDocument] /// Members slated for deletion — always non-empty when the group is emitted. var delete: [SplitDocument] var id: String { keep.isEmpty ? (delete.first?.id ?? "") : keep.map(\.id).joined(separator: ",") } } struct WorkoutGroup: Sendable, Identifiable { var keep: WorkoutDocument var delete: [WorkoutDocument] var id: String { keep.id } } var splitGroups: [SplitGroup] var workoutGroups: [WorkoutGroup] var isEmpty: Bool { splitGroups.isEmpty && workoutGroups.isEmpty } /// Total number of documents this plan would delete across every group. var deleteCount: Int { splitGroups.reduce(0) { $0 + $1.delete.count } + workoutGroups.reduce(0) { $0 + $1.delete.count } } } // MARK: - Planner enum DuplicateCleanupPlanner { /// Builds a cleanup plan from already-decoded, already-`isReadable`-filtered /// documents. `referencedSplitIDs` must include every split id any workout /// (readable or quarantined) points at, plus any clone-redirect targets — the /// caller (`SyncEngine.scanForDuplicates`) is responsible for assembling it. static func plan( splits: [SplitDocument], workouts: [WorkoutDocument], referencedSplitIDs: Set, isSeed: (String) -> Bool ) -> DuplicateCleanupPlan { DuplicateCleanupPlan( splitGroups: planSplits(splits, referencedSplitIDs: referencedSplitIDs, isSeed: isSeed), workoutGroups: planWorkouts(workouts) ) } // MARK: Splits /// Hashable projection of a `MachineSetting`. The fingerprints keep the /// nil / empty distinction (non-machine vs. machine-with-nothing-recorded) /// and the user-defined order, both of which are real content. private struct MachineSettingFingerprint: Hashable { var name: String var value: String } private static func fingerprint(_ settings: [MachineSetting]?) -> [MachineSettingFingerprint]? { settings.map { $0.map { MachineSettingFingerprint(name: $0.name, value: $0.value) } } } /// Content-only projection of an exercise: ignores id and order (order is /// captured by the parent's sort, not the element itself). private struct ExerciseFingerprint: Hashable { var name: String var sets: Int var reps: Int var weight: Int var loadType: Int var durationSeconds: Int var machineSettings: [MachineSettingFingerprint]? } /// Content-only projection of a split: ignores id, dates, color, systemImage, /// order, and activityType. private struct SplitFingerprint: Hashable { var name: String var exercises: [ExerciseFingerprint] } private static func fingerprint(_ doc: SplitDocument) -> SplitFingerprint { SplitFingerprint( name: doc.name.trimmingCharacters(in: .whitespacesAndNewlines), exercises: doc.exercises.sorted { $0.order < $1.order }.map { ExerciseFingerprint( name: $0.name, sets: $0.sets, reps: $0.reps, weight: $0.weight, loadType: $0.loadType, durationSeconds: $0.durationSeconds, machineSettings: fingerprint($0.machineSettings) ) } ) } /// Survivor rules (safety-critical — see file header): /// 1. A member referenced by any workout is always kept. /// 2. A member that's a bundled seed is always kept. /// 3. If neither rule protects anyone, the lexicographically smallest id /// (ULIDs sort chronologically) survives — deterministic across devices. /// Everyone else in the group is deleted. A group with nothing to delete /// (e.g. every member is protected) is dropped entirely. private static func planSplits( _ splits: [SplitDocument], referencedSplitIDs: Set, isSeed: (String) -> Bool ) -> [DuplicateCleanupPlan.SplitGroup] { let grouped = Dictionary(grouping: splits, by: fingerprint) var groups: [DuplicateCleanupPlan.SplitGroup] = [] for (_, members) in grouped where members.count > 1 { var keep: [SplitDocument] = [] var candidates: [SplitDocument] = [] for member in members { if referencedSplitIDs.contains(member.id) || isSeed(member.id) { keep.append(member) } else { candidates.append(member) } } if keep.isEmpty, let survivorID = candidates.map(\.id).min() { keep = candidates.filter { $0.id == survivorID } candidates.removeAll { $0.id == survivorID } } guard !candidates.isEmpty else { continue } groups.append(DuplicateCleanupPlan.SplitGroup( keep: keep.sorted { $0.id < $1.id }, delete: candidates.sorted { $0.id < $1.id } )) } return groups.sorted { $0.id < $1.id } } // MARK: Workouts /// Content-only projection of a log: ignores id and the started/completed /// timestamps. private struct WorkoutLogFingerprint: Hashable { var exerciseName: String var order: Int var sets: Int var reps: Int var weight: Int var loadType: Int var durationSeconds: Int var status: String var currentStateIndex: Int var notes: String var machineSettings: [MachineSettingFingerprint]? } /// Content-only projection of a workout: ignores id, createdAt/updatedAt, /// startedAt/completedAt, exact start/end time (only the calendar day of /// `start` matters), and metrics. private struct WorkoutFingerprint: Hashable { var splitID: String var splitName: String var year: Int var month: Int var day: Int var status: String var logs: [WorkoutLogFingerprint] } private static func fingerprint(_ doc: WorkoutDocument) -> WorkoutFingerprint { let comps = Calendar.current.dateComponents([.year, .month, .day], from: doc.start) return WorkoutFingerprint( splitID: doc.splitID ?? "", splitName: doc.splitName ?? "", year: comps.year ?? 0, month: comps.month ?? 0, day: comps.day ?? 0, status: doc.status, logs: doc.logs.sorted { $0.order < $1.order }.map { WorkoutLogFingerprint( exerciseName: $0.exerciseName, order: $0.order, sets: $0.sets, reps: $0.reps, weight: $0.weight, loadType: $0.loadType, durationSeconds: $0.durationSeconds, status: $0.status, currentStateIndex: $0.currentStateIndex, notes: $0.notes ?? "", machineSettings: fingerprint($0.machineSettings) ) } ) } /// Ranks a workout for survivorship — lower sorts first (wins): (1) holds the /// HealthKit link (deleting that copy would cascade-delete the real Health /// sample), (2) has metrics at all, (3) smallest id. private static func survivorRank(_ doc: WorkoutDocument) -> (Int, Int, String) { (doc.metrics?.healthKitWorkoutUUID != nil ? 0 : 1, doc.metrics != nil ? 0 : 1, doc.id) } /// If any member of a duplicate group is in progress, the whole group is /// dropped — never touch an active workout, since the watch matches its live /// run by workout id. Otherwise the survivor is picked by `survivorRank` and /// everyone else is deleted. private static func planWorkouts(_ workouts: [WorkoutDocument]) -> [DuplicateCleanupPlan.WorkoutGroup] { let grouped = Dictionary(grouping: workouts, by: fingerprint) var groups: [DuplicateCleanupPlan.WorkoutGroup] = [] for (_, members) in grouped where members.count > 1 { if members.contains(where: { $0.status == WorkoutStatus.inProgress.rawValue }) { continue } let ranked = members.sorted { a, b in let ra = survivorRank(a), rb = survivorRank(b) if ra.0 != rb.0 { return ra.0 < rb.0 } if ra.1 != rb.1 { return ra.1 < rb.1 } return ra.2 < rb.2 } guard let survivor = ranked.first else { continue } let toDelete = ranked.dropFirst().sorted { $0.id < $1.id } groups.append(DuplicateCleanupPlan.WorkoutGroup(keep: survivor, delete: Array(toDelete))) } return groups.sorted { $0.id < $1.id } } }