// // RemoteImageService.swift // SportsTime // // Downloads and caches remote images for PDF export (team logos, stadium photos). // import Foundation import UIKit import LRUCache actor RemoteImageService { // MARK: - Errors enum ImageFetchError: Error, LocalizedError { case invalidURL case downloadFailed(String) case invalidImageData var errorDescription: String? { switch self { case .invalidURL: return "Invalid image URL" case .downloadFailed(let reason): return "Image download failed: \(reason)" case .invalidImageData: return "Downloaded data is not a valid image" } } } // MARK: - Properties private let urlSession: URLSession private let imageCache = LRUCache(countLimit: 50) // MARK: - Init init() { // Configure URLSession with caching let config = URLSessionConfiguration.default config.requestCachePolicy = .returnCacheDataElseLoad config.urlCache = URLCache( memoryCapacity: 50 * 1024 * 1024, // 50 MB memory diskCapacity: 100 * 1024 * 1024, // 100 MB disk diskPath: "ImageCache" ) config.timeoutIntervalForRequest = 15 config.timeoutIntervalForResource = 30 self.urlSession = URLSession(configuration: config) } // MARK: - Public Methods /// Fetch a single image from URL func fetchImage(from url: URL) async throws -> UIImage { // Check cache first if let cached = imageCache.value(forKey: url) { return cached } // Download image let (data, response) = try await urlSession.data(from: url) // Validate response guard let httpResponse = response as? HTTPURLResponse, (200...299).contains(httpResponse.statusCode) else { let statusCode = (response as? HTTPURLResponse)?.statusCode ?? -1 throw ImageFetchError.downloadFailed("HTTP \(statusCode)") } // Create image guard let image = UIImage(data: data) else { throw ImageFetchError.invalidImageData } // Scale image for PDF (max 400pt width to keep file size reasonable) let scaledImage = scaleImage(image, maxWidth: 400) // Cache it imageCache.setValue(scaledImage, forKey: url) return scaledImage } /// Batch fetch multiple images in parallel func fetchImages(from urls: [URL]) async -> [URL: UIImage] { var results: [URL: UIImage] = [:] await withTaskGroup(of: (URL, UIImage?).self) { group in for url in urls { group.addTask { do { let image = try await self.fetchImage(from: url) return (url, image) } catch { return (url, nil) } } } for await (url, image) in group { if let image = image { results[url] = image } } } return results } /// Fetch team logos by team ID func fetchTeamLogos(teams: [Team]) async -> [String: UIImage] { let urlToTeam: [URL: String] = Dictionary( teams.compactMap { team in guard let logoURL = team.logoURL else { return nil } return (logoURL, team.id) }, uniquingKeysWith: { _, last in last } ) let images = await fetchImages(from: Array(urlToTeam.keys)) var result: [String: UIImage] = [:] for (url, image) in images { if let teamId = urlToTeam[url] { result[teamId] = image } } return result } /// Fetch stadium photos by stadium ID func fetchStadiumPhotos(stadiums: [Stadium]) async -> [String: UIImage] { let urlToStadium: [URL: String] = Dictionary( stadiums.compactMap { stadium in guard let imageURL = stadium.imageURL else { return nil } return (imageURL, stadium.id) }, uniquingKeysWith: { _, last in last } ) let images = await fetchImages(from: Array(urlToStadium.keys)) var result: [String: UIImage] = [:] for (url, image) in images { if let stadiumId = urlToStadium[url] { result[stadiumId] = image } } return result } /// Clear the in-memory cache func clearCache() { imageCache.removeAll() } // MARK: - Private Methods /// Scale image to max width while maintaining aspect ratio private func scaleImage(_ image: UIImage, maxWidth: CGFloat) -> UIImage { let currentWidth = image.size.width guard currentWidth > maxWidth else { return image } let scale = maxWidth / currentWidth let newSize = CGSize( width: image.size.width * scale, height: image.size.height * scale ) let renderer = UIGraphicsImageRenderer(size: newSize) return renderer.image { _ in image.draw(in: CGRect(origin: .zero, size: newSize)) } } }