// // StadiumIdentityService.swift // SportsTime // // Service for resolving stadium identities across renames and aliases. // Wraps CanonicalStadium lookups from SwiftData. // import Foundation import SwiftData // MARK: - Stadium Identity Service /// Resolves stadium identities to canonical IDs, handling renames and aliases. /// Example: "SBC Park", "AT&T Park", and "Oracle Park" all resolve to the same canonical ID. actor StadiumIdentityService { // MARK: - Singleton static let shared = StadiumIdentityService() // MARK: - Properties private var modelContainer: ModelContainer? // Cache for performance private var uuidToCanonicalId: [UUID: String] = [:] private var canonicalIdToUUID: [String: UUID] = [:] private var nameToCanonicalId: [String: String] = [:] // MARK: - Initialization private init() {} // MARK: - Configuration /// Configure the service with a model container func configure(with container: ModelContainer) { self.modelContainer = container invalidateCache() } // MARK: - Public Methods /// Get the canonical ID for a stadium UUID /// Returns the same canonicalId for stadiums that are the same physical location func canonicalId(for stadiumUUID: UUID) async throws -> String? { // Check cache first if let cached = uuidToCanonicalId[stadiumUUID] { return cached } guard let container = modelContainer else { return nil } let context = ModelContext(container) let descriptor = FetchDescriptor( predicate: #Predicate { stadium in stadium.uuid == stadiumUUID } ) guard let stadium = try context.fetch(descriptor).first else { return nil } // Cache the result uuidToCanonicalId[stadiumUUID] = stadium.canonicalId canonicalIdToUUID[stadium.canonicalId] = stadium.uuid return stadium.canonicalId } /// Get the canonical ID for a stadium name (searches aliases too) func canonicalId(forName name: String) async throws -> String? { let lowercasedName = name.lowercased() // Check cache first if let cached = nameToCanonicalId[lowercasedName] { return cached } guard let container = modelContainer else { return nil } let context = ModelContext(container) // First check stadium aliases let aliasDescriptor = FetchDescriptor( predicate: #Predicate { alias in alias.aliasName == lowercasedName } ) if let alias = try context.fetch(aliasDescriptor).first { nameToCanonicalId[lowercasedName] = alias.stadiumCanonicalId return alias.stadiumCanonicalId } // Fall back to direct stadium name match with predicate filter let searchName = lowercasedName let stadiumDescriptor = FetchDescriptor( predicate: #Predicate { $0.name.localizedStandardContains(searchName) } ) let stadiums = try context.fetch(stadiumDescriptor) // Verify case-insensitive exact match from narrowed results if let stadium = stadiums.first(where: { $0.name.lowercased() == lowercasedName }) { nameToCanonicalId[lowercasedName] = stadium.canonicalId return stadium.canonicalId } return nil } /// Check if two stadium UUIDs represent the same physical stadium func isSameStadium(_ id1: UUID, _ id2: UUID) async throws -> Bool { guard let canonicalId1 = try await canonicalId(for: id1), let canonicalId2 = try await canonicalId(for: id2) else { // If we can't resolve, fall back to direct comparison return id1 == id2 } return canonicalId1 == canonicalId2 } /// Get the current UUID for a canonical stadium ID func currentUUID(forCanonicalId canonicalId: String) async throws -> UUID? { // Check cache first if let cached = canonicalIdToUUID[canonicalId] { return cached } guard let container = modelContainer else { return nil } let context = ModelContext(container) let descriptor = FetchDescriptor( predicate: #Predicate { stadium in stadium.canonicalId == canonicalId && stadium.deprecatedAt == nil } ) guard let stadium = try context.fetch(descriptor).first else { return nil } // Cache the result canonicalIdToUUID[canonicalId] = stadium.uuid uuidToCanonicalId[stadium.uuid] = stadium.canonicalId return stadium.uuid } /// Get the current name for a canonical stadium ID func currentName(forCanonicalId canonicalId: String) async throws -> String? { guard let container = modelContainer else { return nil } let context = ModelContext(container) let descriptor = FetchDescriptor( predicate: #Predicate { stadium in stadium.canonicalId == canonicalId && stadium.deprecatedAt == nil } ) guard let stadium = try context.fetch(descriptor).first else { return nil } return stadium.name } /// Get all historical names for a stadium func allNames(forCanonicalId canonicalId: String) async throws -> [String] { guard let container = modelContainer else { return [] } let context = ModelContext(container) // Get aliases let aliasDescriptor = FetchDescriptor( predicate: #Predicate { alias in alias.stadiumCanonicalId == canonicalId } ) let aliases = try context.fetch(aliasDescriptor) var names = aliases.map { $0.aliasName } // Add current name if let currentName = try await currentName(forCanonicalId: canonicalId) { if !names.contains(currentName.lowercased()) { names.append(currentName) } } return names } /// Find stadium by approximate location (for photo import) func findStadium(near latitude: Double, longitude: Double, radiusMeters: Double = 5000) async throws -> CanonicalStadium? { guard let container = modelContainer else { return nil } let context = ModelContext(container) // Fetch all active stadiums and filter by distance let descriptor = FetchDescriptor( predicate: #Predicate { stadium in stadium.deprecatedAt == nil } ) let stadiums = try context.fetch(descriptor) // Calculate approximate degree ranges for the radius // At equator: 1 degree ≈ 111km, so radiusMeters / 111000 gives degrees let degreeDelta = radiusMeters / 111000.0 let nearbyStadiums = stadiums.filter { stadium in abs(stadium.latitude - latitude) <= degreeDelta && abs(stadium.longitude - longitude) <= degreeDelta * 1.5 // Account for longitude compression at higher latitudes } // If multiple stadiums nearby, find the closest guard !nearbyStadiums.isEmpty else { return nil } if nearbyStadiums.count == 1 { return nearbyStadiums.first } // Calculate actual distances for the nearby stadiums return nearbyStadiums.min { s1, s2 in let d1 = haversineDistance(lat1: latitude, lon1: longitude, lat2: s1.latitude, lon2: s1.longitude) let d2 = haversineDistance(lat1: latitude, lon1: longitude, lat2: s2.latitude, lon2: s2.longitude) return d1 < d2 } } // MARK: - Cache Management /// Invalidate all caches (call after sync) func invalidateCache() { uuidToCanonicalId.removeAll() canonicalIdToUUID.removeAll() nameToCanonicalId.removeAll() } // MARK: - Private Helpers /// Calculate distance between two coordinates using Haversine formula nonisolated private func haversineDistance(lat1: Double, lon1: Double, lat2: Double, lon2: Double) -> Double { let R = 6371000.0 // Earth's radius in meters let phi1 = lat1 * .pi / 180 let phi2 = lat2 * .pi / 180 let deltaPhi = (lat2 - lat1) * .pi / 180 let deltaLambda = (lon2 - lon1) * .pi / 180 let a = sin(deltaPhi / 2) * sin(deltaPhi / 2) + cos(phi1) * cos(phi2) * sin(deltaLambda / 2) * sin(deltaLambda / 2) let c = 2 * atan2(sqrt(a), sqrt(1 - a)) return R * c } }