// // CloudKitConflictResolver.swift // Yattee // // Handles conflict resolution when local and remote records differ. // import CloudKit import Foundation /// Resolves conflicts between local and remote CloudKit records. actor CloudKitConflictResolver { // MARK: - Subscription Conflict Resolution /// Resolves a conflict between local and server subscription records. /// /// Strategy: /// - Keep record with most recent `lastUpdatedAt` func resolveSubscriptionConflict( local: CKRecord, server: CKRecord ) -> CKRecord { let localDate = (local["lastUpdatedAt"] as? Date) ?? Date.distantPast let serverDate = (server["lastUpdatedAt"] as? Date) ?? Date.distantPast // IMPORTANT: Always start with server record to preserve recordChangeTag // This is required for CloudKit to accept the update let resolved = server // If local is newer, copy its fields to server record if localDate > serverDate { resolved["name"] = local["name"] resolved["channelDescription"] = local["channelDescription"] resolved["subscriberCount"] = local["subscriberCount"] resolved["avatarURLString"] = local["avatarURLString"] resolved["bannerURLString"] = local["bannerURLString"] resolved["isVerified"] = local["isVerified"] resolved["lastUpdatedAt"] = local["lastUpdatedAt"] resolved["providerName"] = local["providerName"] } return resolved } // MARK: - WatchEntry Conflict Resolution /// Resolves a conflict between local and server watch entry records. /// /// Strategy: /// - Use most recent updatedAt for watch progress (respects "mark as unwatched") /// - Use most recent updatedAt for metadata fields /// - Preserve finishedAt from whichever record marked it finished func resolveWatchEntryConflict( local: CKRecord, server: CKRecord ) -> CKRecord { let localSeconds = (local["watchedSeconds"] as? Double) ?? 0 let serverSeconds = (server["watchedSeconds"] as? Double) ?? 0 let localFinished = ((local["isFinished"] as? Int64) ?? 0) == 1 let serverFinished = ((server["isFinished"] as? Int64) ?? 0) == 1 let localUpdated = (local["updatedAt"] as? Date) ?? Date.distantPast let serverUpdated = (server["updatedAt"] as? Date) ?? Date.distantPast // IMPORTANT: Always start with server record to preserve recordChangeTag // This is required for CloudKit to accept the update let resolved = server // Copy metadata from local if it's newer if localUpdated > serverUpdated { resolved["title"] = local["title"] resolved["authorName"] = local["authorName"] resolved["authorID"] = local["authorID"] resolved["duration"] = local["duration"] resolved["thumbnailURLString"] = local["thumbnailURLString"] } // Use watch progress from the most recently updated record // This respects user intent - if they marked as unwatched, that action wins if localUpdated > serverUpdated { resolved["watchedSeconds"] = localSeconds as CKRecordValue resolved["isFinished"] = (localFinished ? 1 : 0) as CKRecordValue resolved["finishedAt"] = local["finishedAt"] resolved["isLive"] = local["isLive"] } else { resolved["watchedSeconds"] = serverSeconds as CKRecordValue resolved["isFinished"] = (serverFinished ? 1 : 0) as CKRecordValue resolved["finishedAt"] = server["finishedAt"] resolved["isLive"] = server["isLive"] } // Use most recent updatedAt resolved["updatedAt"] = max(localUpdated, serverUpdated) as CKRecordValue return resolved } // MARK: - Bookmark Conflict Resolution /// Resolves a conflict between local and server bookmark records. /// /// Strategy: /// - Keep most recent createdAt (newest wins) /// - Preserve local note if it exists /// - Keep local sortOrder (user's manual ordering) func resolveBookmarkConflict( local: CKRecord, server: CKRecord ) -> CKRecord { let localCreated = (local["createdAt"] as? Date) ?? Date.distantPast let serverCreated = (server["createdAt"] as? Date) ?? Date.distantPast // IMPORTANT: Always start with server record to preserve recordChangeTag let resolved = server // If local is newer, copy its metadata to server record if localCreated > serverCreated { resolved["title"] = local["title"] resolved["authorName"] = local["authorName"] resolved["authorID"] = local["authorID"] resolved["duration"] = local["duration"] resolved["thumbnailURLString"] = local["thumbnailURLString"] resolved["isLive"] = local["isLive"] resolved["viewCount"] = local["viewCount"] resolved["publishedAt"] = local["publishedAt"] resolved["publishedText"] = local["publishedText"] resolved["createdAt"] = local["createdAt"] } // Resolve note based on timestamp (most recent wins) let localNoteModified = local["noteModifiedAt"] as? Date ?? Date.distantPast let serverNoteModified = server["noteModifiedAt"] as? Date ?? Date.distantPast if localNoteModified >= serverNoteModified { // Local note is newer or equal - use local resolved["note"] = local["note"] resolved["noteModifiedAt"] = local["noteModifiedAt"] } else { // Server note is newer - use server (already in resolved) // No need to set, already using server record } // Resolve tags based on timestamp (most recent wins) let localTagsModified = local["tagsModifiedAt"] as? Date ?? Date.distantPast let serverTagsModified = server["tagsModifiedAt"] as? Date ?? Date.distantPast if localTagsModified >= serverTagsModified { // Local tags are newer or equal - use local resolved["tags"] = local["tags"] resolved["tagsModifiedAt"] = local["tagsModifiedAt"] } else { // Server tags are newer - use server (already in resolved) // No need to set, already using server record } // Always preserve local sortOrder (user's manual ordering) if let localSortOrder = local["sortOrder"] { resolved["sortOrder"] = localSortOrder } return resolved } // MARK: - Playlist Conflict Resolution /// Resolves a conflict between local and server playlist records. /// /// Strategy: /// - Keep most recent updatedAt for metadata (title, description) /// - Preserve server recordChangeTag func resolveLocalPlaylistConflict( local: CKRecord, server: CKRecord ) -> CKRecord { let localUpdated = (local["updatedAt"] as? Date) ?? Date.distantPast let serverUpdated = (server["updatedAt"] as? Date) ?? Date.distantPast // IMPORTANT: Always start with server record to preserve recordChangeTag let resolved = server // If local is newer, copy its metadata to server record if localUpdated > serverUpdated { resolved["title"] = local["title"] resolved["playlistDescription"] = local["playlistDescription"] resolved["updatedAt"] = local["updatedAt"] } return resolved } /// Resolves a conflict between local and server playlist item records. /// /// Strategy: /// - Keep most recent addedAt for metadata /// - Preserve local sortOrder (user's ordering) func resolveLocalPlaylistItemConflict( local: CKRecord, server: CKRecord ) -> CKRecord { let localAdded = (local["addedAt"] as? Date) ?? Date.distantPast let serverAdded = (server["addedAt"] as? Date) ?? Date.distantPast // IMPORTANT: Always start with server record to preserve recordChangeTag let resolved = server // If local is newer, copy its metadata to server record if localAdded > serverAdded { resolved["title"] = local["title"] resolved["authorName"] = local["authorName"] resolved["authorID"] = local["authorID"] resolved["duration"] = local["duration"] resolved["thumbnailURLString"] = local["thumbnailURLString"] resolved["isLive"] = local["isLive"] } // Always preserve local sortOrder (user's manual ordering) if let localSortOrder = local["sortOrder"] { resolved["sortOrder"] = localSortOrder } return resolved } // MARK: - SearchHistory Conflict Resolution /// Resolves a conflict between local and server search history records. /// /// Strategy: /// - Keep most recent searchedAt (newest wins) /// - Preserve server recordChangeTag func resolveSearchHistoryConflict( local: CKRecord, server: CKRecord ) -> CKRecord { let localSearched = (local["searchedAt"] as? Date) ?? Date.distantPast let serverSearched = (server["searchedAt"] as? Date) ?? Date.distantPast // IMPORTANT: Always start with server record to preserve recordChangeTag let resolved = server // If local is newer, copy its fields to server record if localSearched > serverSearched { resolved["query"] = local["query"] resolved["searchedAt"] = local["searchedAt"] } return resolved } // MARK: - RecentChannel Conflict Resolution /// Resolves a conflict between local and server recent channel records. /// /// Strategy: /// - Keep most recent visitedAt (newest wins) /// - Preserve server recordChangeTag func resolveRecentChannelConflict( local: CKRecord, server: CKRecord ) -> CKRecord { let localVisited = (local["visitedAt"] as? Date) ?? Date.distantPast let serverVisited = (server["visitedAt"] as? Date) ?? Date.distantPast // IMPORTANT: Always start with server record to preserve recordChangeTag let resolved = server // If local is newer, copy its fields to server record if localVisited > serverVisited { resolved["name"] = local["name"] resolved["thumbnailURLString"] = local["thumbnailURLString"] resolved["subscriberCount"] = local["subscriberCount"] resolved["isVerified"] = local["isVerified"] resolved["visitedAt"] = local["visitedAt"] } return resolved } // MARK: - RecentPlaylist Conflict Resolution /// Resolves a conflict between local and server recent playlist records. /// /// Strategy: /// - Keep most recent visitedAt (newest wins) /// - Preserve server recordChangeTag func resolveRecentPlaylistConflict( local: CKRecord, server: CKRecord ) -> CKRecord { let localVisited = (local["visitedAt"] as? Date) ?? Date.distantPast let serverVisited = (server["visitedAt"] as? Date) ?? Date.distantPast // IMPORTANT: Always start with server record to preserve recordChangeTag let resolved = server // If local is newer, copy its fields to server record if localVisited > serverVisited { resolved["title"] = local["title"] resolved["authorName"] = local["authorName"] resolved["videoCount"] = local["videoCount"] resolved["thumbnailURLString"] = local["thumbnailURLString"] resolved["visitedAt"] = local["visitedAt"] } return resolved } // MARK: - LayoutPreset Conflict Resolution /// Resolves a conflict between local and server layout preset records. /// /// Strategy: /// - Last write wins based on updatedAt timestamp /// - Preserve server recordChangeTag func resolveLayoutPresetConflict( local: CKRecord, server: CKRecord ) -> CKRecord { let localUpdated = (local["updatedAt"] as? Date) ?? Date.distantPast let serverUpdated = (server["updatedAt"] as? Date) ?? Date.distantPast // IMPORTANT: Always start with server record to preserve recordChangeTag let resolved = server // If local is newer, copy its fields to server record if localUpdated > serverUpdated { resolved["name"] = local["name"] resolved["updatedAt"] = local["updatedAt"] resolved["layoutJSON"] = local["layoutJSON"] } return resolved } // MARK: - ChannelNotificationSettings Conflict Resolution /// Resolves a conflict between local and server channel notification settings. /// /// Strategy: /// - Last write wins based on updatedAt timestamp — the same rule /// `DataManager.upsertChannelNotificationSettings` applies on arrival, so a /// record that wins the upload is never refused by the other device. /// - An exact tie keeps the server value and needs no deterministic /// tiebreaker: unlike accounts and data contexts, no writer stamps these /// settings with a fixed epoch date, so a tie is an accident of the clock /// rather than something migration produces on every device. /// - Preserve server recordChangeTag func resolveChannelNotificationSettingsConflict( local: CKRecord, server: CKRecord ) -> CKRecord { let localUpdated = (local["updatedAt"] as? Date) ?? Date.distantPast let serverUpdated = (server["updatedAt"] as? Date) ?? Date.distantPast // IMPORTANT: Always start with server record to preserve recordChangeTag let resolved = server // If local is newer, copy its fields to server record if localUpdated > serverUpdated { resolved["notificationsEnabled"] = local["notificationsEnabled"] resolved["updatedAt"] = local["updatedAt"] resolved["sourceRawValue"] = local["sourceRawValue"] resolved["instanceURLString"] = local["instanceURLString"] resolved["globalProvider"] = local["globalProvider"] } return resolved } // MARK: - Account Conflict Resolution /// Resolves a conflict between local and server account records. /// /// Strategy: /// - Last write wins on the app-managed `updatedAt`, never on CloudKit's /// modification date: an edit made offline has to stay orderable against /// a concurrent edit elsewhere, and a migrated account carries an /// epoch-old date precisely so any real edit outranks it. /// - An exact tie is settled by the label, so two devices that edited in the /// same instant — or migrated the same account, all of which are stamped /// at the epoch — converge instead of each keeping its own. This has to /// match `Account.isOutranked`, or a record could win the upload and still /// be refused on arrival. func resolveAccountConflict( local: CKRecord, server: CKRecord ) -> CKRecord { let localUpdated = (local["updatedAt"] as? Date) ?? Date.distantPast let serverUpdated = (server["updatedAt"] as? Date) ?? Date.distantPast let localWins: Bool if localUpdated != serverUpdated { localWins = localUpdated > serverUpdated } else { localWins = ((local["displayName"] as? String) ?? "") > ((server["displayName"] as? String) ?? "") } // IMPORTANT: Always start with server record to preserve recordChangeTag let resolved = server if localWins { resolved["displayName"] = local["displayName"] // A local record built before the field existed carries no value; // clearing the server's would flip the account back to active, so // absence leaves the server copy standing. if local["isActive"] != nil { resolved["isActive"] = local["isActive"] } resolved["updatedAt"] = local["updatedAt"] } return resolved } // MARK: - DataContext Conflict Resolution /// Resolves a conflict between local and server data-context records. /// /// Strategy: last write wins on the app-managed `changedAt` — the moment /// the user chose, not the moment a record happened to be written — with /// an exact tie settled by the selected account's ID. /// /// The tie is not hypothetical: every migrated context is stamped at the /// epoch, so two devices migrating different legacy selections produce /// identical timestamps. This has to match `StoredDataContext.isOutranked`, /// or a record could win the upload and still be refused on arrival. func resolveDataContextConflict( local: CKRecord, server: CKRecord ) -> CKRecord { let localChanged = (local["changedAt"] as? Date) ?? Date.distantPast let serverChanged = (server["changedAt"] as? Date) ?? Date.distantPast let localWins: Bool if localChanged != serverChanged { localWins = localChanged > serverChanged } else { let localKey = (local["accountID"] as? String) ?? "" let serverKey = (server["accountID"] as? String) ?? "" localWins = localKey > serverKey } let resolved = server if localWins { resolved["contextKind"] = local["contextKind"] resolved["accountID"] = local["accountID"] resolved["changedAt"] = local["changedAt"] } return resolved } // MARK: - Write-Once Conflict Resolution /// Resolves a conflict on a record that only ever means "this happened": /// the legacy-migration markers and the account deletion tombstones. /// /// There is no state to merge — both sides assert the same fact — so the /// server's copy is kept, which also preserves its change tag. func resolveWriteOnceConflict( local _: CKRecord, server: CKRecord ) -> CKRecord { server } }