import Foundation // MARK: - SCRYFALL THROTTLER actor ScryfallThrottler { static let shared = ScryfallThrottler() private var nextAllowedTime = Date.distantPast func wait() async { let now = Date() let targetTime = max(now, nextAllowedTime) nextAllowedTime = targetTime.addingTimeInterval(0.1) let waitTime = targetTime.timeIntervalSince(now) if waitTime > 0 { try? await Task.sleep(nanoseconds: UInt64(waitTime * 1_000_000_000)) } } } // MARK: - SCRYFALL API (formerly InsuranceEngine) class ScryfallAPI { struct PricingIdentifier: Codable { let set: String; let collector_number: String } struct ScryfallData { let price: Double? let typeLine: String let rarity: String? let colors: [String]? let isSerialized: Bool } static func getPriceKey(foilType: String, currency: CurrencyCode) -> String { if foilType.caseInsensitiveCompare("Etched") == .orderedSame { return "\(currency.scryfallKey)_etched" } let isFoil = foilType != "None" && foilType != AppConfig.Defaults.defaultFoil let base = currency.scryfallKey return isFoil ? "\(base)_foil" : base } static func updateTrends(cards: [SavedCard], currency: CurrencyCode, force: Bool = false, updateTimestamp: Bool = true) async -> ([UUID: Double], [UUID: (String?, [String]?, Bool)]) { if !NetworkMonitor.shared.isConnected { return ([:], [:]) } let lastUpdate = UserDefaults.standard.object(forKey: "LastPriceUpdate") as? Date ?? Date.distantPast let isCacheExpired = Date().timeIntervalSince(lastUpdate) >= 86400 // Optimization: If cache is fresh, only fetch cards that are missing a price (Smart Partial Refresh) let cardsToFetch = (force || isCacheExpired) ? cards : cards.filter { $0.currentValuation == nil } if cardsToFetch.isEmpty { return ([:], [:]) } var updates: [UUID: Double] = [:] var metadataUpdates: [UUID: (String?, [String]?, Bool)] = [:] let marketCards = cardsToFetch.filter { !$0.isCustomValuation } let chunks = stride(from: 0, to: marketCards.count, by: 75).map { Array(marketCards[$0.. ScryfallData { if !NetworkMonitor.shared.isConnected { return ScryfallData(price: nil, typeLine: "Offline", rarity: nil, colors: nil, isSerialized: false) } await ScryfallThrottler.shared.wait() let encodedSet = setCode.addingPercentEncoding(withAllowedCharacters: .urlPathAllowed) ?? setCode let encodedNum = number.addingPercentEncoding(withAllowedCharacters: .urlPathAllowed) ?? number guard let url = URL(string: "https://api.scryfall.com/cards/\(encodedSet)/\(encodedNum)") else { return ScryfallData(price: nil, typeLine: "Error", rarity: nil, colors: nil, isSerialized: false) } var request = URLRequest(url: url) request.addValue(AppConfig.scryfallUserAgent, forHTTPHeaderField: "User-Agent") guard let (data, _) = try? await URLSession.shared.data(for: request), let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else { return ScryfallData(price: nil, typeLine: "Unknown", rarity: nil, colors: nil, isSerialized: false) } let prices = json["prices"] as? [String: Any] let priceKey = getPriceKey(foilType: foilType, currency: currency) let price = Double(prices?[priceKey] as? String ?? "") let rarity = json["rarity"] as? String let colors = json["color_identity"] as? [String] let promoTypes = json["promo_types"] as? [String] let isSer = promoTypes?.contains("serialized") ?? false return ScryfallData(price: price, typeLine: (json["type_line"] as? String) ?? "", rarity: rarity, colors: colors, isSerialized: isSer) } }