//
//  CanonicalSyncService.swift
//  SportsTime
//
//  Orchestrates syncing canonical data from CloudKit into SwiftData.
//  Uses date-based delta sync for public database efficiency.
//

import Foundation
import SwiftData
import CloudKit

actor CanonicalSyncService {

    // MARK: - Errors

    enum SyncError: Error, LocalizedError {
        case cloudKitUnavailable
        case syncAlreadyInProgress
        case saveFailed(Error)
        case schemaVersionTooNew(Int)

        var errorDescription: String? {
            switch self {
            case .cloudKitUnavailable:
                return "CloudKit is not available. Check your internet connection and iCloud settings."
            case .syncAlreadyInProgress:
                return "A sync operation is already in progress."
            case .saveFailed(let error):
                return "Failed to save synced data: \(error.localizedDescription)"
            case .schemaVersionTooNew(let version):
                return "Data requires app version supporting schema \(version). Please update the app."
            }
        }
    }

    // MARK: - Sync Result

    struct SyncResult {
        let stadiumsUpdated: Int
        let teamsUpdated: Int
        let gamesUpdated: Int
        let leagueStructuresUpdated: Int
        let teamAliasesUpdated: Int
        let stadiumAliasesUpdated: Int
        let sportsUpdated: Int
        let skippedIncompatible: Int
        let skippedOlder: Int
        let duration: TimeInterval
        let wasCancelled: Bool

        var totalUpdated: Int {
            stadiumsUpdated + teamsUpdated + gamesUpdated + leagueStructuresUpdated + teamAliasesUpdated + stadiumAliasesUpdated + sportsUpdated
        }

        var isEmpty: Bool { totalUpdated == 0 && !wasCancelled }
    }

    // MARK: - Properties

    private let cloudKitService: CloudKitService

    // MARK: - Initialization

    init(cloudKitService: CloudKitService = .shared) {
        self.cloudKitService = cloudKitService
    }

    // MARK: - Public Sync Methods

    /// Perform a full sync of all canonical data types.
    /// This is the main entry point for background sync.
    /// - Parameters:
    ///   - context: The ModelContext to use for saving data
    ///   - cancellationToken: Optional token to check for cancellation between entity syncs
    @MainActor
    func syncAll(context: ModelContext, cancellationToken: SyncCancellationToken? = nil) async throws -> SyncResult {
        let startTime = Date()
        let syncState = SyncState.current(in: context)

        // Prevent concurrent syncs, but auto-heal stale "in progress" state.
        if syncState.syncInProgress {
            let staleSyncTimeout: TimeInterval = 15 * 60
            if let lastAttempt = syncState.lastSyncAttempt,
               Date().timeIntervalSince(lastAttempt) < staleSyncTimeout {
                throw SyncError.syncAlreadyInProgress
            }
            SyncLogger.shared.log("⚠️ [SYNC] Clearing stale syncInProgress flag")
            syncState.syncInProgress = false
        }

        // Check if sync is enabled
        guard syncState.syncEnabled else {
            return SyncResult(
                stadiumsUpdated: 0, teamsUpdated: 0, gamesUpdated: 0,
                leagueStructuresUpdated: 0, teamAliasesUpdated: 0, stadiumAliasesUpdated: 0,
                sportsUpdated: 0, skippedIncompatible: 0, skippedOlder: 0,
                duration: 0, wasCancelled: false
            )
        }

        // Check CloudKit availability
        guard await cloudKitService.isAvailable() else {
            throw SyncError.cloudKitUnavailable
        }

        #if DEBUG
        SyncStatusMonitor.shared.syncStarted()
        #endif

        // Mark sync in progress
        syncState.syncInProgress = true
        syncState.lastSyncAttempt = Date()

        var totalStadiums = 0
        var totalTeams = 0
        var totalGames = 0
        var totalLeagueStructures = 0
        var totalTeamAliases = 0
        var totalStadiumAliases = 0
        var totalSports = 0
        var totalSkippedIncompatible = 0
        var totalSkippedOlder = 0
        var nonCriticalErrors: [String] = []

        /// Helper to save partial progress and check cancellation
        func saveProgressAndCheckCancellation() throws -> Bool {
            try context.save()
            if cancellationToken?.isCancelled == true {
                return true
            }
            return false
        }

        do {
            // Sync in dependency order, checking cancellation between each entity type

            // Sport sync (non-critical for schedule rendering)
            var entityStartTime = Date()
            do {
                let (sports, skipIncompat1, skipOlder1) = try await syncSports(
                    context: context,
                    since: syncState.lastSportSync ?? syncState.lastSuccessfulSync,
                    cancellationToken: cancellationToken
                )
                totalSports = sports
                totalSkippedIncompatible += skipIncompat1
                totalSkippedOlder += skipOlder1
                syncState.lastSportSync = entityStartTime
                #if DEBUG
                SyncStatusMonitor.shared.updateEntityStatus(.sport, success: true, recordCount: sports, duration: Date().timeIntervalSince(entityStartTime))
                #endif
            } catch is CancellationError {
                throw CancellationError()
            } catch {
                nonCriticalErrors.append("Sport: \(error.localizedDescription)")
                #if DEBUG
                SyncStatusMonitor.shared.updateEntityStatus(.sport, success: false, recordCount: 0, duration: Date().timeIntervalSince(entityStartTime), errorMessage: error.localizedDescription)
                #endif
                SyncLogger.shared.log("⚠️ [SYNC] Non-critical sync error (Sport): \(error.localizedDescription)")
            }
            if try saveProgressAndCheckCancellation() {
                throw CancellationError()
            }

            // Stadium sync
            entityStartTime = Date()
            let (stadiums, skipIncompat2, skipOlder2) = try await syncStadiums(
                context: context,
                since: syncState.lastStadiumSync ?? syncState.lastSuccessfulSync,
                cancellationToken: cancellationToken
            )
            totalStadiums = stadiums
            totalSkippedIncompatible += skipIncompat2
            totalSkippedOlder += skipOlder2
            syncState.lastStadiumSync = entityStartTime
            #if DEBUG
            SyncStatusMonitor.shared.updateEntityStatus(.stadium, success: true, recordCount: stadiums, duration: Date().timeIntervalSince(entityStartTime))
            #endif
            if try saveProgressAndCheckCancellation() {
                throw CancellationError()
            }

            // Team sync
            entityStartTime = Date()
            let (teams, skipIncompat3, skipOlder3) = try await syncTeams(
                context: context,
                since: syncState.lastTeamSync ?? syncState.lastSuccessfulSync,
                cancellationToken: cancellationToken
            )
            totalTeams = teams
            totalSkippedIncompatible += skipIncompat3
            totalSkippedOlder += skipOlder3
            syncState.lastTeamSync = entityStartTime
            #if DEBUG
            SyncStatusMonitor.shared.updateEntityStatus(.team, success: true, recordCount: teams, duration: Date().timeIntervalSince(entityStartTime))
            #endif
            if try saveProgressAndCheckCancellation() {
                throw CancellationError()
            }

            // Game sync
            entityStartTime = Date()
            let (games, skipIncompat4, skipOlder4) = try await syncGames(
                context: context,
                since: syncState.lastGameSync ?? syncState.lastSuccessfulSync,
                cancellationToken: cancellationToken
            )
            totalGames = games
            totalSkippedIncompatible += skipIncompat4
            totalSkippedOlder += skipOlder4
            syncState.lastGameSync = entityStartTime
            #if DEBUG
            SyncStatusMonitor.shared.updateEntityStatus(.game, success: true, recordCount: games, duration: Date().timeIntervalSince(entityStartTime))
            #endif
            if try saveProgressAndCheckCancellation() {
                throw CancellationError()
            }

            // League Structure sync (non-critical for schedule rendering)
            entityStartTime = Date()
            do {
                let (leagueStructures, skipIncompat5, skipOlder5) = try await syncLeagueStructure(
                    context: context,
                    since: syncState.lastLeagueStructureSync ?? syncState.lastSuccessfulSync,
                    cancellationToken: cancellationToken
                )
                totalLeagueStructures = leagueStructures
                totalSkippedIncompatible += skipIncompat5
                totalSkippedOlder += skipOlder5
                syncState.lastLeagueStructureSync = entityStartTime
                #if DEBUG
                SyncStatusMonitor.shared.updateEntityStatus(.leagueStructure, success: true, recordCount: leagueStructures, duration: Date().timeIntervalSince(entityStartTime))
                #endif
            } catch is CancellationError {
                throw CancellationError()
            } catch {
                nonCriticalErrors.append("LeagueStructure: \(error.localizedDescription)")
                #if DEBUG
                SyncStatusMonitor.shared.updateEntityStatus(.leagueStructure, success: false, recordCount: 0, duration: Date().timeIntervalSince(entityStartTime), errorMessage: error.localizedDescription)
                #endif
                SyncLogger.shared.log("⚠️ [SYNC] Non-critical sync error (LeagueStructure): \(error.localizedDescription)")
            }
            if try saveProgressAndCheckCancellation() {
                throw CancellationError()
            }

            // Team Alias sync (non-critical for schedule rendering)
            entityStartTime = Date()
            do {
                let (teamAliases, skipIncompat6, skipOlder6) = try await syncTeamAliases(
                    context: context,
                    since: syncState.lastTeamAliasSync ?? syncState.lastSuccessfulSync,
                    cancellationToken: cancellationToken
                )
                totalTeamAliases = teamAliases
                totalSkippedIncompatible += skipIncompat6
                totalSkippedOlder += skipOlder6
                syncState.lastTeamAliasSync = entityStartTime
                #if DEBUG
                SyncStatusMonitor.shared.updateEntityStatus(.teamAlias, success: true, recordCount: teamAliases, duration: Date().timeIntervalSince(entityStartTime))
                #endif
            } catch is CancellationError {
                throw CancellationError()
            } catch {
                nonCriticalErrors.append("TeamAlias: \(error.localizedDescription)")
                #if DEBUG
                SyncStatusMonitor.shared.updateEntityStatus(.teamAlias, success: false, recordCount: 0, duration: Date().timeIntervalSince(entityStartTime), errorMessage: error.localizedDescription)
                #endif
                SyncLogger.shared.log("⚠️ [SYNC] Non-critical sync error (TeamAlias): \(error.localizedDescription)")
            }
            if try saveProgressAndCheckCancellation() {
                throw CancellationError()
            }

            // Stadium Alias sync (non-critical for schedule rendering)
            entityStartTime = Date()
            do {
                let (stadiumAliases, skipIncompat7, skipOlder7) = try await syncStadiumAliases(
                    context: context,
                    since: syncState.lastStadiumAliasSync ?? syncState.lastSuccessfulSync,
                    cancellationToken: cancellationToken
                )
                totalStadiumAliases = stadiumAliases
                totalSkippedIncompatible += skipIncompat7
                totalSkippedOlder += skipOlder7
                syncState.lastStadiumAliasSync = entityStartTime
                #if DEBUG
                SyncStatusMonitor.shared.updateEntityStatus(.stadiumAlias, success: true, recordCount: stadiumAliases, duration: Date().timeIntervalSince(entityStartTime))
                #endif
            } catch is CancellationError {
                throw CancellationError()
            } catch {
                nonCriticalErrors.append("StadiumAlias: \(error.localizedDescription)")
                #if DEBUG
                SyncStatusMonitor.shared.updateEntityStatus(.stadiumAlias, success: false, recordCount: 0, duration: Date().timeIntervalSince(entityStartTime), errorMessage: error.localizedDescription)
                #endif
                SyncLogger.shared.log("⚠️ [SYNC] Non-critical sync error (StadiumAlias): \(error.localizedDescription)")
            }

            // Mark sync successful - clear per-entity timestamps since full sync completed
            syncState.syncInProgress = false
            syncState.lastSuccessfulSync = startTime
            syncState.lastSyncError = nonCriticalErrors.isEmpty ? nil : "Non-critical sync warnings: \(nonCriticalErrors.joined(separator: " | "))"
            syncState.consecutiveFailures = 0
            syncState.syncPausedReason = nil
            // Clear per-entity timestamps - they're only needed for partial recovery
            syncState.lastStadiumSync = nil
            syncState.lastTeamSync = nil
            syncState.lastGameSync = nil
            syncState.lastLeagueStructureSync = nil
            syncState.lastTeamAliasSync = nil
            syncState.lastStadiumAliasSync = nil
            syncState.lastSportSync = nil

            try context.save()

            #if DEBUG
            SyncStatusMonitor.shared.syncCompleted(totalDuration: Date().timeIntervalSince(startTime))
            #endif

        } catch is CancellationError {
            // Graceful cancellation - progress already saved
            syncState.syncInProgress = false
            syncState.lastSyncError = "Sync cancelled - partial progress saved"
            try? context.save()

            #if DEBUG
            SyncStatusMonitor.shared.syncFailed(error: CancellationError())
            #endif

            return SyncResult(
                stadiumsUpdated: totalStadiums,
                teamsUpdated: totalTeams,
                gamesUpdated: totalGames,
                leagueStructuresUpdated: totalLeagueStructures,
                teamAliasesUpdated: totalTeamAliases,
                stadiumAliasesUpdated: totalStadiumAliases,
                sportsUpdated: totalSports,
                skippedIncompatible: totalSkippedIncompatible,
                skippedOlder: totalSkippedOlder,
                duration: Date().timeIntervalSince(startTime),
                wasCancelled: true
            )
        } catch {
            // Mark sync failed
            syncState.syncInProgress = false
            syncState.lastSyncError = error.localizedDescription

            if isTransientCloudKitError(error) {
                // Network/account hiccups should not permanently degrade sync health.
                syncState.consecutiveFailures = 0
                syncState.syncPausedReason = nil
            } else {
                syncState.consecutiveFailures += 1

                // Pause sync after too many failures
                if syncState.consecutiveFailures >= 5 {
                    #if DEBUG
                    syncState.syncEnabled = false
                    syncState.syncPausedReason = "Too many consecutive failures. Sync paused."
                    #else
                    syncState.consecutiveFailures = 5
                    syncState.syncPausedReason = nil
                    #endif
                }
            }

            try? context.save()

            #if DEBUG
            SyncStatusMonitor.shared.syncFailed(error: error)
            #endif

            throw error
        }

        return SyncResult(
            stadiumsUpdated: totalStadiums,
            teamsUpdated: totalTeams,
            gamesUpdated: totalGames,
            leagueStructuresUpdated: totalLeagueStructures,
            teamAliasesUpdated: totalTeamAliases,
            stadiumAliasesUpdated: totalStadiumAliases,
            sportsUpdated: totalSports,
            skippedIncompatible: totalSkippedIncompatible,
            skippedOlder: totalSkippedOlder,
            duration: Date().timeIntervalSince(startTime),
            wasCancelled: false
        )
    }

    /// Re-enable sync after it was paused due to failures.
    @MainActor
    func resumeSync(context: ModelContext) {
        let syncState = SyncState.current(in: context)
        syncState.syncEnabled = true
        syncState.syncPausedReason = nil
        syncState.consecutiveFailures = 0
        try? context.save()
    }

    nonisolated private func isTransientCloudKitError(_ error: Error) -> Bool {
        guard let ckError = error as? CKError else { return false }
        switch ckError.code {
        case .networkUnavailable,
             .networkFailure,
             .serviceUnavailable,
             .requestRateLimited,
             .zoneBusy,
             .notAuthenticated,
             .accountTemporarilyUnavailable:
            return true
        default:
            return false
        }
    }

    // MARK: - Individual Sync Methods

    @MainActor
    private func syncStadiums(
        context: ModelContext,
        since lastSync: Date?,
        cancellationToken: SyncCancellationToken?
    ) async throws -> (updated: Int, skippedIncompatible: Int, skippedOlder: Int) {
        // Delta sync: nil = all stadiums, Date = only modified since
        let syncStadiums = try await cloudKitService.fetchStadiumsForSync(since: lastSync, cancellationToken: cancellationToken)

        var updated = 0
        var skippedIncompatible = 0
        var skippedOlder = 0

        for syncStadium in syncStadiums {
            // Use canonical ID directly from CloudKit - no UUID-based generation!
            let result = try mergeStadium(
                syncStadium.stadium,
                canonicalId: syncStadium.canonicalId,
                context: context
            )

            switch result {
            case .applied: updated += 1
            case .skippedIncompatible: skippedIncompatible += 1
            case .skippedOlder: skippedOlder += 1
            }
        }

        return (updated, skippedIncompatible, skippedOlder)
    }

    @MainActor
    private func syncTeams(
        context: ModelContext,
        since lastSync: Date?,
        cancellationToken: SyncCancellationToken?
    ) async throws -> (updated: Int, skippedIncompatible: Int, skippedOlder: Int) {
        // Single call for all teams with delta sync
        let allSyncTeams = try await cloudKitService.fetchTeamsForSync(since: lastSync, cancellationToken: cancellationToken)
        let activeStadiums = try context.fetch(
            FetchDescriptor<CanonicalStadium>(
                predicate: #Predicate { $0.deprecatedAt == nil }
            )
        )
        var validStadiumIds = Set(activeStadiums.map(\.canonicalId))

        var updated = 0
        var skippedIncompatible = 0
        var skippedOlder = 0

        for syncTeam in allSyncTeams {
            // Use canonical IDs directly from CloudKit - no UUID lookups!
            let result = try mergeTeam(
                syncTeam.team,
                canonicalId: syncTeam.canonicalId,
                stadiumCanonicalId: syncTeam.stadiumCanonicalId,
                validStadiumIds: &validStadiumIds,
                context: context
            )

            switch result {
            case .applied: updated += 1
            case .skippedIncompatible: skippedIncompatible += 1
            case .skippedOlder: skippedOlder += 1
            }
        }

        if skippedIncompatible > 0 {
            SyncLogger.shared.log("⚠️ [SYNC] Skipped \(skippedIncompatible) incompatible team records")
        }

        return (updated, skippedIncompatible, skippedOlder)
    }

    @MainActor
    private func syncGames(
        context: ModelContext,
        since lastSync: Date?,
        cancellationToken: SyncCancellationToken?
    ) async throws -> (updated: Int, skippedIncompatible: Int, skippedOlder: Int) {
        // Delta sync: nil = all games, Date = only modified since
        let syncGames = try await cloudKitService.fetchGamesForSync(since: lastSync, cancellationToken: cancellationToken)
        let activeTeams = try context.fetch(
            FetchDescriptor<CanonicalTeam>(
                predicate: #Predicate { $0.deprecatedAt == nil }
            )
        )
        let validTeamIds = Set(activeTeams.map(\.canonicalId))
        let teamStadiumByTeamId = Dictionary(uniqueKeysWithValues: activeTeams.map { ($0.canonicalId, $0.stadiumCanonicalId) })
        let activeStadiums = try context.fetch(
            FetchDescriptor<CanonicalStadium>(
                predicate: #Predicate { $0.deprecatedAt == nil }
            )
        )
        var validStadiumIds = Set(activeStadiums.map(\.canonicalId))

        var updated = 0
        var skippedIncompatible = 0
        var skippedOlder = 0

        for syncGame in syncGames {
            // Use canonical IDs directly from CloudKit - no UUID lookups!
            let result = try mergeGame(
                syncGame.game,
                canonicalId: syncGame.canonicalId,
                homeTeamCanonicalId: syncGame.homeTeamCanonicalId,
                awayTeamCanonicalId: syncGame.awayTeamCanonicalId,
                stadiumCanonicalId: syncGame.stadiumCanonicalId,
                validTeamIds: validTeamIds,
                teamStadiumByTeamId: teamStadiumByTeamId,
                validStadiumIds: &validStadiumIds,
                context: context
            )

            switch result {
            case .applied: updated += 1
            case .skippedIncompatible: skippedIncompatible += 1
            case .skippedOlder: skippedOlder += 1
            }
        }

        if skippedIncompatible > 0 {
            SyncLogger.shared.log("⚠️ [SYNC] Skipped \(skippedIncompatible) incompatible game records")
        }

        return (updated, skippedIncompatible, skippedOlder)
    }

    @MainActor
    private func syncLeagueStructure(
        context: ModelContext,
        since lastSync: Date?,
        cancellationToken: SyncCancellationToken?
    ) async throws -> (updated: Int, skippedIncompatible: Int, skippedOlder: Int) {
        let remoteStructures = try await cloudKitService.fetchLeagueStructureChanges(since: lastSync, cancellationToken: cancellationToken)

        var updated = 0
        var skippedIncompatible = 0
        var skippedOlder = 0

        for remoteStructure in remoteStructures {
            let result = try mergeLeagueStructure(remoteStructure, context: context)

            switch result {
            case .applied: updated += 1
            case .skippedIncompatible: skippedIncompatible += 1
            case .skippedOlder: skippedOlder += 1
            }
        }

        return (updated, skippedIncompatible, skippedOlder)
    }

    @MainActor
    private func syncTeamAliases(
        context: ModelContext,
        since lastSync: Date?,
        cancellationToken: SyncCancellationToken?
    ) async throws -> (updated: Int, skippedIncompatible: Int, skippedOlder: Int) {
        let remoteAliases = try await cloudKitService.fetchTeamAliasChanges(since: lastSync, cancellationToken: cancellationToken)

        var updated = 0
        var skippedIncompatible = 0
        var skippedOlder = 0

        for remoteAlias in remoteAliases {
            let result = try mergeTeamAlias(remoteAlias, context: context)

            switch result {
            case .applied: updated += 1
            case .skippedIncompatible: skippedIncompatible += 1
            case .skippedOlder: skippedOlder += 1
            }
        }

        return (updated, skippedIncompatible, skippedOlder)
    }

    @MainActor
    private func syncStadiumAliases(
        context: ModelContext,
        since lastSync: Date?,
        cancellationToken: SyncCancellationToken?
    ) async throws -> (updated: Int, skippedIncompatible: Int, skippedOlder: Int) {
        let remoteAliases = try await cloudKitService.fetchStadiumAliasChanges(since: lastSync, cancellationToken: cancellationToken)

        var updated = 0
        var skippedIncompatible = 0
        var skippedOlder = 0

        for remoteAlias in remoteAliases {
            let result = try mergeStadiumAlias(remoteAlias, context: context)

            switch result {
            case .applied: updated += 1
            case .skippedIncompatible: skippedIncompatible += 1
            case .skippedOlder: skippedOlder += 1
            }
        }

        return (updated, skippedIncompatible, skippedOlder)
    }

    @MainActor
    private func syncSports(
        context: ModelContext,
        since lastSync: Date?,
        cancellationToken: SyncCancellationToken?
    ) async throws -> (updated: Int, skippedIncompatible: Int, skippedOlder: Int) {
        let remoteSports = try await cloudKitService.fetchSportsForSync(since: lastSync, cancellationToken: cancellationToken)

        var updated = 0
        var skippedIncompatible = 0
        var skippedOlder = 0

        for remoteSport in remoteSports {
            let result = try mergeSport(remoteSport, context: context)

            switch result {
            case .applied: updated += 1
            case .skippedIncompatible: skippedIncompatible += 1
            case .skippedOlder: skippedOlder += 1
            }
        }

        return (updated, skippedIncompatible, skippedOlder)
    }

    // MARK: - Merge Logic

    private enum MergeResult {
        case applied
        case skippedIncompatible
        case skippedOlder
    }

    @MainActor
    private func mergeStadium(
        _ remote: Stadium,
        canonicalId: String,
        context: ModelContext
    ) throws -> MergeResult {
        // Look up existing
        let descriptor = FetchDescriptor<CanonicalStadium>(
            predicate: #Predicate { $0.canonicalId == canonicalId }
        )
        let existing = try context.fetch(descriptor).first

        if let existing = existing {
            // Preserve user fields
            let savedNickname = existing.userNickname
            let savedNotes = existing.userNotes
            let savedFavorite = existing.isFavorite

            // Update system fields
            existing.name = remote.name
            existing.city = remote.city
            existing.state = remote.state
            existing.latitude = remote.latitude
            existing.longitude = remote.longitude
            existing.capacity = remote.capacity
            existing.yearOpened = remote.yearOpened
            existing.imageURL = remote.imageURL?.absoluteString
            existing.sport = remote.sport.rawValue
            existing.timezoneIdentifier = remote.timeZoneIdentifier
            existing.source = .cloudKit
            existing.lastModified = Date()
            existing.deprecatedAt = nil
            existing.deprecationReason = nil
            existing.replacedByCanonicalId = nil

            // Restore user fields
            existing.userNickname = savedNickname
            existing.userNotes = savedNotes
            existing.isFavorite = savedFavorite

            return .applied
        } else {
            // Insert new - let init() generate deterministic UUID from canonicalId
            let canonical = CanonicalStadium(
                canonicalId: canonicalId,
                // uuid: omitted - will be generated deterministically from canonicalId
                schemaVersion: SchemaVersion.current,
                lastModified: Date(),
                source: .cloudKit,
                name: remote.name,
                city: remote.city,
                state: remote.state,
                latitude: remote.latitude,
                longitude: remote.longitude,
                capacity: remote.capacity,
                yearOpened: remote.yearOpened,
                imageURL: remote.imageURL?.absoluteString,
                sport: remote.sport.rawValue,
                timezoneIdentifier: remote.timeZoneIdentifier
            )
            context.insert(canonical)
            return .applied
        }
    }

    @MainActor
    private func mergeTeam(
        _ remote: Team,
        canonicalId: String,
        stadiumCanonicalId: String?,
        validStadiumIds: inout Set<String>,
        context: ModelContext
    ) throws -> MergeResult {
        let descriptor = FetchDescriptor<CanonicalTeam>(
            predicate: #Predicate { $0.canonicalId == canonicalId }
        )
        let existing = try context.fetch(descriptor).first

        let remoteStadiumId = stadiumCanonicalId?.trimmingCharacters(in: .whitespacesAndNewlines)
        let existingStadiumId = existing?.stadiumCanonicalId.trimmingCharacters(in: .whitespacesAndNewlines)

        let resolvedStadiumCanonicalId: String
        if let remoteStadiumId, !remoteStadiumId.isEmpty, validStadiumIds.contains(remoteStadiumId) {
            resolvedStadiumCanonicalId = remoteStadiumId
        } else if let existingStadiumId, !existingStadiumId.isEmpty, validStadiumIds.contains(existingStadiumId) {
            resolvedStadiumCanonicalId = existingStadiumId
        } else if let remoteStadiumId, !remoteStadiumId.isEmpty {
            // Keep unresolved remote refs so teams/games can still sync while stadiums catch up.
            resolvedStadiumCanonicalId = remoteStadiumId
        } else if let existingStadiumId, !existingStadiumId.isEmpty {
            resolvedStadiumCanonicalId = existingStadiumId
        } else {
            // Last-resort placeholder keeps team records usable for game rendering.
            resolvedStadiumCanonicalId = "stadium_placeholder_\(canonicalId)"
        }

        if !validStadiumIds.contains(resolvedStadiumCanonicalId) {
            try ensurePlaceholderStadium(
                canonicalId: resolvedStadiumCanonicalId,
                sport: remote.sport,
                context: context
            )
            validStadiumIds.insert(resolvedStadiumCanonicalId)
        }

        if let existing = existing {
            // Preserve user fields
            let savedNickname = existing.userNickname
            let savedFavorite = existing.isFavorite

            // Update system fields
            existing.name = remote.name
            existing.abbreviation = remote.abbreviation
            existing.sport = remote.sport.rawValue
            existing.city = remote.city
            existing.stadiumCanonicalId = resolvedStadiumCanonicalId
            existing.logoURL = remote.logoURL?.absoluteString
            existing.primaryColor = remote.primaryColor
            existing.secondaryColor = remote.secondaryColor
            existing.conferenceId = remote.conferenceId
            existing.divisionId = remote.divisionId
            existing.source = .cloudKit
            existing.lastModified = Date()
            existing.deprecatedAt = nil
            existing.deprecationReason = nil
            existing.relocatedToCanonicalId = nil

            // Restore user fields
            existing.userNickname = savedNickname
            existing.isFavorite = savedFavorite

            return .applied
        } else {
            // Insert new - let init() generate deterministic UUID from canonicalId
            let canonical = CanonicalTeam(
                canonicalId: canonicalId,
                // uuid: omitted - will be generated deterministically from canonicalId
                schemaVersion: SchemaVersion.current,
                lastModified: Date(),
                source: .cloudKit,
                name: remote.name,
                abbreviation: remote.abbreviation,
                sport: remote.sport.rawValue,
                city: remote.city,
                stadiumCanonicalId: resolvedStadiumCanonicalId,
                logoURL: remote.logoURL?.absoluteString,
                primaryColor: remote.primaryColor,
                secondaryColor: remote.secondaryColor,
                conferenceId: remote.conferenceId,
                divisionId: remote.divisionId
            )
            context.insert(canonical)
            return .applied
        }
    }

    @MainActor
    private func mergeGame(
        _ remote: Game,
        canonicalId: String,
        homeTeamCanonicalId: String,
        awayTeamCanonicalId: String,
        stadiumCanonicalId: String?,
        validTeamIds: Set<String>,
        teamStadiumByTeamId: [String: String],
        validStadiumIds: inout Set<String>,
        context: ModelContext
    ) throws -> MergeResult {
        let descriptor = FetchDescriptor<CanonicalGame>(
            predicate: #Predicate { $0.canonicalId == canonicalId }
        )
        let existing = try context.fetch(descriptor).first

        func resolveTeamId(remote: String, existing: String?) -> String? {
            if validTeamIds.contains(remote) {
                return remote
            }
            if let existing, validTeamIds.contains(existing) {
                return existing
            }
            return nil
        }

        guard let resolvedHomeTeamId = resolveTeamId(remote: homeTeamCanonicalId, existing: existing?.homeTeamCanonicalId),
              let resolvedAwayTeamId = resolveTeamId(remote: awayTeamCanonicalId, existing: existing?.awayTeamCanonicalId)
        else {
            return .skippedIncompatible
        }

        let trimmedRemoteStadiumId = stadiumCanonicalId?.trimmingCharacters(in: .whitespacesAndNewlines)
        let trimmedExistingStadiumId = existing?.stadiumCanonicalId.trimmingCharacters(in: .whitespacesAndNewlines)
        let fallbackStadiumFromTeams = (
            teamStadiumByTeamId[resolvedHomeTeamId] ??
            teamStadiumByTeamId[resolvedAwayTeamId]
        )?.trimmingCharacters(in: .whitespacesAndNewlines)

        let resolvedStadiumCanonicalId: String
        if let trimmedRemoteStadiumId, !trimmedRemoteStadiumId.isEmpty, validStadiumIds.contains(trimmedRemoteStadiumId) {
            resolvedStadiumCanonicalId = trimmedRemoteStadiumId
        } else if let fallbackStadiumFromTeams, !fallbackStadiumFromTeams.isEmpty, validStadiumIds.contains(fallbackStadiumFromTeams) {
            // Cloud record can have stale/legacy stadium refs; prefer known team home venue.
            resolvedStadiumCanonicalId = fallbackStadiumFromTeams
        } else if let trimmedExistingStadiumId, !trimmedExistingStadiumId.isEmpty, validStadiumIds.contains(trimmedExistingStadiumId) {
            // Keep existing local stadium if remote reference is invalid.
            resolvedStadiumCanonicalId = trimmedExistingStadiumId
        } else if let trimmedRemoteStadiumId, !trimmedRemoteStadiumId.isEmpty {
            resolvedStadiumCanonicalId = trimmedRemoteStadiumId
        } else if let fallbackStadiumFromTeams, !fallbackStadiumFromTeams.isEmpty {
            resolvedStadiumCanonicalId = fallbackStadiumFromTeams
        } else if let trimmedExistingStadiumId, !trimmedExistingStadiumId.isEmpty {
            resolvedStadiumCanonicalId = trimmedExistingStadiumId
        } else {
            resolvedStadiumCanonicalId = "stadium_placeholder_\(canonicalId)"
        }

        if !validStadiumIds.contains(resolvedStadiumCanonicalId) {
            try ensurePlaceholderStadium(
                canonicalId: resolvedStadiumCanonicalId,
                sport: remote.sport,
                context: context
            )
            validStadiumIds.insert(resolvedStadiumCanonicalId)
        }

        if let existing = existing {
            // Preserve user fields
            let savedAttending = existing.userAttending
            let savedNotes = existing.userNotes

            // Update system fields
            existing.homeTeamCanonicalId = resolvedHomeTeamId
            existing.awayTeamCanonicalId = resolvedAwayTeamId
            existing.stadiumCanonicalId = resolvedStadiumCanonicalId
            existing.dateTime = remote.dateTime
            existing.sport = remote.sport.rawValue
            existing.season = remote.season
            existing.isPlayoff = remote.isPlayoff
            existing.broadcastInfo = remote.broadcastInfo
            existing.source = .cloudKit
            existing.lastModified = Date()
            existing.deprecatedAt = nil
            existing.deprecationReason = nil
            existing.rescheduledToCanonicalId = nil

            // Restore user fields
            existing.userAttending = savedAttending
            existing.userNotes = savedNotes

            return .applied
        } else {
            // Insert new - let init() generate deterministic UUID from canonicalId
            let canonical = CanonicalGame(
                canonicalId: canonicalId,
                // uuid: omitted - will be generated deterministically from canonicalId
                schemaVersion: SchemaVersion.current,
                lastModified: Date(),
                source: .cloudKit,
                homeTeamCanonicalId: resolvedHomeTeamId,
                awayTeamCanonicalId: resolvedAwayTeamId,
                stadiumCanonicalId: resolvedStadiumCanonicalId,
                dateTime: remote.dateTime,
                sport: remote.sport.rawValue,
                season: remote.season,
                isPlayoff: remote.isPlayoff,
                broadcastInfo: remote.broadcastInfo
            )
            context.insert(canonical)
            return .applied
        }
    }

    @MainActor
    private func mergeLeagueStructure(
        _ remote: LeagueStructureModel,
        context: ModelContext
    ) throws -> MergeResult {
        // Schema version check
        guard remote.schemaVersion <= SchemaVersion.current else {
            return .skippedIncompatible
        }

        let remoteId = remote.id
        let descriptor = FetchDescriptor<LeagueStructureModel>(
            predicate: #Predicate { $0.id == remoteId }
        )
        let existing = try context.fetch(descriptor).first

        if let existing = existing {
            // lastModified check
            guard remote.lastModified > existing.lastModified else {
                return .skippedOlder
            }

            // Update all fields (no user fields on LeagueStructure)
            existing.sport = remote.sport
            existing.structureTypeRaw = remote.structureTypeRaw
            existing.name = remote.name
            existing.abbreviation = remote.abbreviation
            existing.parentId = remote.parentId
            existing.displayOrder = remote.displayOrder
            existing.schemaVersion = remote.schemaVersion
            existing.lastModified = remote.lastModified

            return .applied
        } else {
            // Insert new
            context.insert(remote)
            return .applied
        }
    }

    @MainActor
    private func mergeTeamAlias(
        _ remote: TeamAlias,
        context: ModelContext
    ) throws -> MergeResult {
        // Schema version check
        guard remote.schemaVersion <= SchemaVersion.current else {
            return .skippedIncompatible
        }

        let remoteId = remote.id
        let descriptor = FetchDescriptor<TeamAlias>(
            predicate: #Predicate { $0.id == remoteId }
        )
        let existing = try context.fetch(descriptor).first

        if let existing = existing {
            // lastModified check
            guard remote.lastModified > existing.lastModified else {
                return .skippedOlder
            }

            // Update all fields (no user fields on TeamAlias)
            existing.teamCanonicalId = remote.teamCanonicalId
            existing.aliasTypeRaw = remote.aliasTypeRaw
            existing.aliasValue = remote.aliasValue
            existing.validFrom = remote.validFrom
            existing.validUntil = remote.validUntil
            existing.schemaVersion = remote.schemaVersion
            existing.lastModified = remote.lastModified

            return .applied
        } else {
            // Insert new
            context.insert(remote)
            return .applied
        }
    }

    @MainActor
    private func mergeStadiumAlias(
        _ remote: StadiumAlias,
        context: ModelContext
    ) throws -> MergeResult {
        // Schema version check
        guard remote.schemaVersion <= SchemaVersion.current else {
            return .skippedIncompatible
        }

        let remoteAliasName = remote.aliasName
        let descriptor = FetchDescriptor<StadiumAlias>(
            predicate: #Predicate { $0.aliasName == remoteAliasName }
        )
        let existing = try context.fetch(descriptor).first

        if let existing = existing {
            // lastModified check
            guard remote.lastModified > existing.lastModified else {
                return .skippedOlder
            }

            // Update all fields (no user fields on StadiumAlias)
            existing.stadiumCanonicalId = remote.stadiumCanonicalId
            existing.validFrom = remote.validFrom
            existing.validUntil = remote.validUntil
            existing.schemaVersion = remote.schemaVersion
            existing.lastModified = remote.lastModified

            return .applied
        } else {
            // Insert new
            context.insert(remote)
            return .applied
        }
    }

    @MainActor
    private func mergeSport(
        _ remote: CanonicalSport,
        context: ModelContext
    ) throws -> MergeResult {
        // Schema version check
        guard remote.schemaVersion <= SchemaVersion.current else {
            return .skippedIncompatible
        }

        let remoteId = remote.id
        let descriptor = FetchDescriptor<CanonicalSport>(
            predicate: #Predicate { $0.id == remoteId }
        )
        let existing = try context.fetch(descriptor).first

        if let existing = existing {
            // lastModified check
            guard remote.lastModified > existing.lastModified else {
                return .skippedOlder
            }

            // Update all fields (no user fields on CanonicalSport)
            existing.abbreviation = remote.abbreviation
            existing.displayName = remote.displayName
            existing.iconName = remote.iconName
            existing.colorHex = remote.colorHex
            existing.seasonStartMonth = remote.seasonStartMonth
            existing.seasonEndMonth = remote.seasonEndMonth
            existing.isActive = remote.isActive
            existing.schemaVersion = remote.schemaVersion
            existing.lastModified = remote.lastModified
            existing.sourceRaw = DataSource.cloudKit.rawValue

            return .applied
        } else {
            // Insert new
            context.insert(remote)
            return .applied
        }
    }

    @MainActor
    private func ensurePlaceholderStadium(
        canonicalId: String,
        sport: Sport,
        context: ModelContext
    ) throws {
        let trimmedCanonicalId = canonicalId.trimmingCharacters(in: .whitespacesAndNewlines)
        guard !trimmedCanonicalId.isEmpty else { return }

        let descriptor = FetchDescriptor<CanonicalStadium>(
            predicate: #Predicate { $0.canonicalId == trimmedCanonicalId }
        )

        if let existing = try context.fetch(descriptor).first {
            if existing.deprecatedAt != nil {
                existing.deprecatedAt = nil
                existing.deprecationReason = nil
                existing.replacedByCanonicalId = nil
            }
            return
        }

        let placeholder = CanonicalStadium(
            canonicalId: trimmedCanonicalId,
            schemaVersion: SchemaVersion.current,
            lastModified: Date(),
            source: .cloudKit,
            name: "Venue TBD",
            city: "Unknown",
            state: "",
            latitude: 0,
            longitude: 0,
            capacity: 0,
            sport: sport.rawValue,
            timezoneIdentifier: nil
        )
        context.insert(placeholder)
        SyncLogger.shared.log("⚠️ [SYNC] Inserted placeholder stadium for unresolved reference: \(trimmedCanonicalId)")
    }
}
