import CoreML import Vision import UIKit import StoreKit import PDFKit import FirebaseFirestore import FirebaseStorage import FirebaseAuth import Network import ImageIO import CoreImage struct SharedEngineResources { static let context = CIContext() } // MARK: - MODEL MANAGER (OTA Updates) class ModelManager { static let shared = ModelManager() private let defaults = UserDefaults.standard func getModel(name: String) -> VNCoreMLModel? { // 1. Check Documents (Downloaded Update) if let docDir = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask).first { let modelURL = docDir.appendingPathComponent("models/\(name).mlmodelc") if FileManager.default.fileExists(atPath: modelURL.path), let model = try? MLModel(contentsOf: modelURL), let vnModel = try? VNCoreMLModel(for: model) { return vnModel } } // 2. Check Bundle (Built-in Fallback) if let bundleURL = Bundle.main.url(forResource: name, withExtension: "mlmodelc"), let model = try? MLModel(contentsOf: bundleURL), let vnModel = try? VNCoreMLModel(for: model) { return vnModel } return nil } func checkForUpdates() { guard FirebaseApp.app() != nil else { return } let models = ["IYmtgFoilClassifier", "IYmtgConditionClassifier", "IYmtgStampClassifier", "IYmtgSetClassifier"] for name in models { let ref = Storage.storage().reference().child("models/\(name).mlmodel") ref.getMetadata { meta, error in guard let meta = meta, let remoteDate = meta.updated else { return } let localDate = self.defaults.object(forKey: "ModelDate_\(name)") as? Date ?? Date.distantPast if remoteDate > localDate { let tempURL = FileManager.default.temporaryDirectory.appendingPathComponent("\(name).mlmodel") ref.write(toFile: tempURL) { url, error in guard let url = url else { return } DispatchQueue.global(qos: .utility).async { do { let compiledURL = try MLModel.compileModel(at: url) let docDir = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask)[0].appendingPathComponent("models") try FileManager.default.createDirectory(at: docDir, withIntermediateDirectories: true) let destURL = docDir.appendingPathComponent("\(name).mlmodelc") let tempDestURL = docDir.appendingPathComponent("temp_\(name).mlmodelc") try? FileManager.default.removeItem(at: tempDestURL) try FileManager.default.copyItem(at: compiledURL, to: tempDestURL) if FileManager.default.fileExists(atPath: destURL.path) { _ = try FileManager.default.replaceItem(at: destURL, withItemAt: tempDestURL, backupItemName: nil, options: []) } else { try FileManager.default.moveItem(at: tempDestURL, to: destURL) } self.defaults.set(remoteDate, forKey: "ModelDate_\(name)") print("✅ OTA Model Updated: \(name)") } catch { print("❌ Model Update Failed for \(name): \(error)") } } } } } } } } // MARK: - 0. ANALYSIS ACTOR actor AnalysisActor { private var database: [CardMetadata] = [] private var fingerprintCache: [UUID: VNFeaturePrintObservation] = [:] func loadDatabase(from url: URL) throws { let data = try Data(contentsOf: url, options: .mappedIfSafe) let loaded = try JSONDecoder().decode([CardFingerprint].self, from: data) self.fingerprintCache.removeAll() self.database.removeAll() for card in loaded { if let obs = try? NSKeyedUnarchiver.unarchivedObject(ofClass: VNFeaturePrintObservation.self, from: card.featureData) { self.fingerprintCache[card.id] = obs } self.database.append(CardMetadata(id: card.id, name: card.name, setCode: card.setCode, collectorNumber: card.collectorNumber, hasFoilPrinting: card.hasFoilPrinting, hasSerializedPrinting: card.hasSerializedPrinting ?? false, priceScanned: card.priceScanned)) } } func analyze(croppedImage: CGImage, orientation: CGImagePropertyOrientation) async -> (CardMetadata?, Bool) { guard let print = try? await FeatureMatcher.generateFingerprint(from: croppedImage, orientation: orientation) else { return (nil, false) } let result = FeatureMatcher.identify(scan: print, database: self.database, cache: self.fingerprintCache) var resolvedCard: CardMetadata? var detectedSerialized = false switch result { case .exact(let card): resolvedCard = card case .unknown: return (nil, false) case .ambiguous(_, let candidates): // FIX: Pass orientation to OCR, otherwise it fails on portrait images (sideways text) let (ocrSet, ocrNum, ocrYear, isSerialized) = OCREngine.readCardDetails(image: croppedImage, orientation: orientation) detectedSerialized = isSerialized // Run Heuristics to resolve specific ambiguities let isAlpha = CornerDetector.isAlphaCorner(image: croppedImage, orientation: orientation) let saturation = SaturationDetector.analyze(image: croppedImage, orientation: orientation) let borderColor = BorderDetector.detect(image: croppedImage, orientation: orientation) let hasListSymbol = ListSymbolDetector.hasListSymbol(image: croppedImage, orientation: orientation) let hasStamp = StampDetector.hasStamp(image: croppedImage, orientation: orientation) var filtered = candidates // 1. Alpha (LEA) vs Beta (LEB) if candidates.contains(where: { $0.setCode == "LEA" }) && candidates.contains(where: { $0.setCode == "LEB" }) { if isAlpha { filtered = filtered.filter { $0.setCode == "LEA" } } else { filtered = filtered.filter { $0.setCode == "LEB" } } } // 2. Unlimited (2ED) vs Revised (3ED) if candidates.contains(where: { $0.setCode == "2ED" }) && candidates.contains(where: { $0.setCode == "3ED" }) { if saturation > 0.25 { filtered = filtered.filter { $0.setCode == "2ED" } } else { filtered = filtered.filter { $0.setCode == "3ED" || $0.setCode == "SUM" } } } // 3. The List / Mystery if hasListSymbol { let listSets = ["PLIST", "MB1", "UPLIST", "H1R"] let listCandidates = filtered.filter { listSets.contains($0.setCode) } if !listCandidates.isEmpty { filtered = listCandidates } } // 4. World Champ (Gold Border) if borderColor == .gold { let wcCandidates = filtered.filter { $0.setCode.hasPrefix("WC") } if !wcCandidates.isEmpty { filtered = wcCandidates } } // 5. Promo Stamps if hasStamp { let promoCandidates = filtered.filter { $0.setCode.lowercased().hasPrefix("p") } if !promoCandidates.isEmpty { filtered = promoCandidates } } // 6. Chronicles (White Border) vs Originals (Black Border) let chroniclesOriginals = ["ARN", "ATQ", "LEG", "DRK"] if candidates.contains(where: { $0.setCode == "CHR" }) && candidates.contains(where: { chroniclesOriginals.contains($0.setCode) }) { if borderColor == .white { filtered = filtered.filter { $0.setCode == "CHR" } } else if borderColor == .black { filtered = filtered.filter { chroniclesOriginals.contains($0.setCode) } } } // 7. Summer Magic (Edgar) - 1994 Copyright on Revised if let year = ocrYear, year == "1994", candidates.contains(where: { $0.setCode == "3ED" }) { let sumCandidates = filtered.filter { $0.setCode == "SUM" } if !sumCandidates.isEmpty { filtered = sumCandidates } } else if candidates.contains(where: { $0.setCode == "3ED" }) { // If not explicitly 1994, assume Revised (3ED) and filter out Summer Magic (SUM) filtered = filtered.filter { $0.setCode != "SUM" } } var resolved: CardMetadata? if let set = ocrSet, let num = ocrNum, let match = filtered.first(where: { $0.setCode.uppercased() == set && $0.collectorNumber == num }) { resolved = match } else if let set = ocrSet, let match = filtered.first(where: { $0.setCode.uppercased() == set }) { resolved = match } else if let set = SetSymbolEngine.recognizeSet(image: croppedImage, orientation: orientation), let match = filtered.first(where: { $0.setCode.caseInsensitiveCompare(set) == .orderedSame }) { resolved = match } else if let set = ClusterEngine.refine(candidates: filtered), let match = filtered.first(where: { $0.setCode == set }) { resolved = match } else { resolved = filtered.first ?? candidates.first } resolvedCard = resolved } guard let card = resolvedCard else { return (nil, false) } // DB CHECK: Only run/trust OCR serialization if the card is known to have a serialized printing if card.hasSerializedPrinting { // If we had an exact match, OCR wasn't run yet. Run it now specifically for serialization. if case .exact = result { let (_, _, _, isSer) = OCREngine.readCardDetails(image: croppedImage, orientation: orientation) detectedSerialized = isSer } } else { detectedSerialized = false } return (card, detectedSerialized) } } // MARK: - 1. STORE ENGINE @MainActor class StoreEngine: ObservableObject { @Published var products: [Product] = [] @Published var showThankYou = false var transactionListener: Task? = nil init() { // FIX: Added [weak self] to prevent retain cycle transactionListener = Task.detached { [weak self] in for await result in Transaction.updates { do { guard let self = self else { return } let transaction = try self.checkVerified(result) await transaction.finish() await MainActor.run { self.showThankYou = true } } catch {} } } } deinit { transactionListener?.cancel() } func loadProducts() async { do { let p = try await Product.products(for: AppConfig.tipJarProductIDs) self.products = p #if DEBUG if p.isEmpty { print("⚠️ StoreEngine: No products found. Verify IAP ID in AppConfig.") } #endif } catch { print("Store Error: \(error)") } } func purchase(_ product: Product) async { guard let result = try? await product.purchase() else { return } switch result { case .success(let verification): if let transaction = try? checkVerified(verification) { await transaction.finish() self.showThankYou = true } case .pending, .userCancelled: break @unknown default: break } } nonisolated func checkVerified(_ result: VerificationResult) throws -> T { switch result { case .unverified: throw StoreError.failedVerification case .verified(let safe): return safe } } enum StoreError: Error { case failedVerification } } // MARK: - 2. PRICING ENGINE 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)) } } } class InsuranceEngine { 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) } } // MARK: - 3. SECURE DEV MODE class DevEngine { static var isDevMode = false static func activateIfCompiled() { #if ENABLE_DEV_MODE isDevMode = true print("⚠️ DEV MODE COMPILED") #endif } static func saveRaw(image: UIImage, label: String) { #if ENABLE_DEV_MODE if isDevMode, let data = image.jpegData(compressionQuality: 1.0) { let path = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask)[0].appendingPathComponent("RawTrainingData") try? FileManager.default.createDirectory(at: path, withIntermediateDirectories: true) try? data.write(to: path.appendingPathComponent("TRAIN_\(label)_\(UUID().uuidString).jpg")) } #endif } } // MARK: - 4. CONDITION & ML struct DamageObservation: Identifiable, Sendable { let id = UUID() let type: String let rect: CGRect let confidence: Float } class ConditionEngine { enum Severity: Int { case minor=1; case critical=10 } static var model: VNCoreMLModel? = { guard AppConfig.enableConditionGrading else { return nil } return ModelManager.shared.getModel(name: "IYmtgConditionClassifier") }() static func getSeverity(for type: String) -> Severity { return (type == "Inking" || type == "Rips" || type == "WaterDamage") ? .critical : .minor } static func detectDamage(image: CGImage, orientation: CGImagePropertyOrientation = .up) -> [DamageObservation] { guard let model = model else { return [] } let request = VNCoreMLRequest(model: model) request.imageCropAndScaleOption = .scaleFill let handler = VNImageRequestHandler(cgImage: image, orientation: orientation, options: [:]) do { try handler.perform([request]) guard let results = request.results as? [VNRecognizedObjectObservation] else { return [] } return results.filter { $0.confidence > 0.7 }.map { obs in DamageObservation(type: obs.labels.first?.identifier ?? "Unknown", rect: obs.boundingBox, confidence: obs.confidence) } } catch { return [] } } static func overallGrade(damages: [DamageObservation]) -> String { if model == nil && damages.isEmpty { return "Ungraded" } var score = 0 for d in damages { if getSeverity(for: d.type) == .critical { return "Damaged" } score += 1 } if score == 0 { return "Near Mint (NM)" } if score <= 2 { return "Excellent (EX)" } return "Played (PL)" } } class OCREngine { static func readCardDetails(image: CGImage, orientation: CGImagePropertyOrientation = .up) -> (setCode: String?, number: String?, year: String?, isSerialized: Bool) { let request = VNRecognizeTextRequest() request.recognitionLevel = .accurate request.usesLanguageCorrection = false let handler = VNImageRequestHandler(cgImage: image, orientation: orientation) try? handler.perform([request]) guard let obs = request.results as? [VNRecognizedTextObservation] else { return (nil, nil, nil, false) } var possibleSetCode: String? var possibleNumber: String? var possibleYear: String? var isSerialized = false for observation in obs { guard let candidate = observation.topCandidates(1).first else { continue } let text = candidate.string.trimmingCharacters(in: .whitespacesAndNewlines) // Set Code: 3-5 chars, uppercase // FIX: Ensure it's in the bottom half to avoid reading Card Name (e.g. "FOG") as Set Code // FIX: Must contain at least one letter to avoid reading years or numbers as Set Codes if text.count >= 3 && text.count <= 5 && text == text.uppercased() && possibleSetCode == nil && text.rangeOfCharacter(from: .letters) != nil { // Vision coordinates: (0,0) is Bottom-Left. y < 0.5 is Bottom Half. // FIX: Tighten to y < 0.2 to match Collector Number and fully exclude Text Box (e.g. "FLY") if observation.boundingBox.origin.y < 0.2 { possibleSetCode = text } } // Collector Number & Serialized if text.contains("/") && text.count <= 10 { // Detect "XXX/500" pattern // FILTER: Ignore Power/Toughness in bottom-right corner (x > 0.75, y < 0.2) // VNObservation bbox is normalized (0,0 is bottom-left) if observation.boundingBox.origin.x > 0.75 && observation.boundingBox.origin.y < 0.2 { continue } // FILTER: Treat anything below the type line (y < 0.5) as standard info (Set/Collector Number) // This prevents false positives from text box numbers or standard collector info being flagged as serialized. // FIX: Tighten standard location to bottom 20% to avoid text box stats (e.g. "10/10" token) let isStandardLocation = observation.boundingBox.origin.y < 0.2 let parts = text.split(separator: "/") if parts.count == 2 { let numStr = parts[0].trimmingCharacters(in: .whitespacesAndNewlines) let denomStr = parts[1].trimmingCharacters(in: .whitespacesAndNewlines) if let num = Int(numStr), let denom = Int(denomStr) { if isStandardLocation { // Ignore small denominators (e.g. 1/1, 2/2) in text box to avoid P/T false positives if denom < 10 { continue } if possibleNumber == nil { possibleNumber = numStr } } else if observation.boundingBox.origin.y > 0.5 { // FIX: Only consider Top Half (Art) as Serialized to avoid Text Box false positives isSerialized = true } } } } else if text.count >= 1 && text.count <= 5, let first = text.first, first.isNumber, possibleNumber == nil { // FIX: Only accept simple numbers if they are at the very bottom (Collector Number location) // AND not in the bottom-right corner (Power/Toughness zone) // This prevents reading mana costs, power/toughness, or damage values as collector numbers if observation.boundingBox.origin.y < 0.2 && observation.boundingBox.origin.x < 0.75 { possibleNumber = text } } // Copyright Year (for Summer Magic detection) // Look for 1993-2025 if let range = text.range(of: #"(19|20)\d{2}"#, options: .regularExpression) { if observation.boundingBox.origin.y < 0.15 { possibleYear = String(text[range]) } } } return (possibleSetCode, possibleNumber, possibleYear, isSerialized) } } class TrainingUploader { static func upload(image: UIImage, label: String, force: Bool = false) { if !force && !AppConfig.isTrainingOptIn { return } guard FirebaseApp.app() != nil else { return } let safeLabel = label.replacingOccurrences(of: "/", with: "-") let ref = Storage.storage().reference().child("training/\(safeLabel)/\(UUID().uuidString).jpg") if let data = image.jpegData(compressionQuality: 0.9) { ref.putData(data, metadata: nil) } } } class CloudEngine { static var db: Firestore? { return FirebaseApp.app() != nil ? Firestore.firestore() : nil } static func signInSilently() { guard FirebaseApp.app() != nil else { return } if Auth.auth().currentUser == nil { Auth.auth().signInAnonymously() } } static func save(card: SavedCard) async { guard let db = db, let uid = Auth.auth().currentUser?.uid else { return } let data: [String: Any] = [ "name": card.name, "set": card.setCode, "num": card.collectorNumber, "val": card.currentValuation ?? 0.0, "cond": card.condition, "foil": card.foilType, "coll": card.collectionName, "loc": card.storageLocation, "grade": card.grade ?? "", "svc": card.gradingService ?? "", "cert": card.certNumber ?? "", "date": card.dateAdded, "curr": card.currencyCode ?? "USD", "rarity": card.rarity ?? "", "colors": card.colorIdentity ?? [], "ser": card.isSerialized ?? false, "img": card.imageFileName, "custom": card.isCustomValuation ] do { try await db.collection("users").document(uid).collection("inventory").document(card.id.uuidString).setData(data, merge: true) } catch { print("Cloud Save Error: \(error)") } } static func delete(card: SavedCard) async { guard let db = db, let uid = Auth.auth().currentUser?.uid else { return } do { try await db.collection("users").document(uid).collection("inventory").document(card.id.uuidString).delete() } catch { print("Cloud Delete Error: \(error)") } } static func batchUpdatePrices(cards: [SavedCard]) async { guard let db = db, let uid = Auth.auth().currentUser?.uid else { return } let ref = db.collection("users").document(uid).collection("inventory") let chunks = stride(from: 0, to: cards.count, by: 400).map { Array(cards[$0.. String { if format == .csv { func cleanCSV(_ text: String) -> String { return text.replacingOccurrences(of: "\"", with: "\"\"") } var csv = "Count,Name,Set,Number,Condition,Foil,Price,Serialized\n" for card in cards { let ser = (card.isSerialized ?? false) ? "Yes" : "No" let line = "1,\"\(cleanCSV(card.name))\",\(card.setCode),\(card.collectorNumber),\(card.condition),\(card.foilType),\(card.currentValuation ?? 0.0),\(ser)\n" csv.append(line) } return csv } else if format == .mtgo { return cards.map { "1 \($0.name)" }.joined(separator: "\n") } else { // Arena return cards.map { "1 \($0.name) (\($0.setCode)) \($0.collectorNumber)" }.joined(separator: "\n") } } static func generate(cards: [SavedCard], format: ExportFormat) -> URL? { if format == .insurance { return generatePDF(cards: cards) } let text = generateString(cards: cards, format: format) let filename = format == .csv ? "Collection.csv" : (format == .mtgo ? "MTGO_List.txt" : "Arena_Decklist.txt") let path = FileManager.default.temporaryDirectory.appendingPathComponent(filename) try? text.write(to: path, atomically: true, encoding: .utf8) return path } private static func generatePDF(cards: [SavedCard]) -> URL? { let fmt = UIGraphicsPDFRendererFormat() fmt.documentInfo = [kCGPDFContextCreator: "IYmtg"] as [String: Any] let url = FileManager.default.temporaryDirectory.appendingPathComponent("Insurance.pdf") let totalVal = cards.reduce(0) { $0 + ($1.currentValuation ?? 0) } let count = cards.count UIGraphicsPDFRenderer(bounds: CGRect(x: 0, y: 0, width: 612, height: 792), format: fmt).writePDF(to: url) { ctx in var pageY: CGFloat = 50 let headerAttrs: [NSAttributedString.Key: Any] = [.font: UIFont.boldSystemFont(ofSize: 24)] let subHeaderAttrs: [NSAttributedString.Key: Any] = [.font: UIFont.boldSystemFont(ofSize: 14)] let textAttrs: [NSAttributedString.Key: Any] = [.font: UIFont.systemFont(ofSize: 12)] func drawHeader() { "Insurance Schedule - \(Date().formatted(date: .abbreviated, time: .shortened))".draw(at: CGPoint(x: 50, y: 50), withAttributes: headerAttrs) } func drawColumnHeaders(y: CGFloat) { let hAttrs: [NSAttributedString.Key: Any] = [.font: UIFont.boldSystemFont(ofSize: 10), .foregroundColor: UIColor.darkGray] "CARD DETAILS".draw(at: CGPoint(x: 100, y: y), withAttributes: hAttrs) "SET / #".draw(at: CGPoint(x: 310, y: y), withAttributes: hAttrs) "COND".draw(at: CGPoint(x: 420, y: y), withAttributes: hAttrs) "VALUE".draw(at: CGPoint(x: 520, y: y), withAttributes: hAttrs) } ctx.beginPage() drawHeader() // Summary Section "Policy Holder: __________________________".draw(at: CGPoint(x: 50, y: 90), withAttributes: subHeaderAttrs) "Total Items: \(count)".draw(at: CGPoint(x: 50, y: 115), withAttributes: subHeaderAttrs) "Total Value: \(cards.first?.currencyCode == "EUR" ? "€" : "$")\(String(format: "%.2f", totalVal))".draw(at: CGPoint(x: 200, y: 115), withAttributes: subHeaderAttrs) drawColumnHeaders(y: 135) pageY = 150 for card in cards { // Check for page break (Row height increased to 60 for image) if pageY + 65 > 740 { ctx.beginPage() drawHeader() drawColumnHeaders(y: 75) pageY = 90 } // Column 0: Image if let img = ImageManager.load(name: card.imageFileName) { img.draw(in: CGRect(x: 50, y: pageY, width: 40, height: 56)) } let textY = pageY + 20 // Column 1: Name (Truncate if long) let name = "\(card.name)\((card.isSerialized ?? false) ? " [S]" : "")" name.draw(in: CGRect(x: 100, y: textY, width: 200, height: 18), withAttributes: textAttrs) // Column 2: Set let setInfo = "\(card.setCode.uppercased()) #\(card.collectorNumber)" setInfo.draw(at: CGPoint(x: 310, y: textY), withAttributes: textAttrs) // Column 3: Condition (Short code) let cond = card.condition.components(separatedBy: "(").last?.replacingOccurrences(of: ")", with: "") ?? card.condition cond.draw(at: CGPoint(x: 420, y: textY), withAttributes: textAttrs) // Column 4: Value let val = "\(card.currencyCode == "EUR" ? "€" : "$")\(String(format: "%.2f", card.currentValuation ?? 0.0))" val.draw(at: CGPoint(x: 520, y: textY), withAttributes: textAttrs) pageY += 65 } // Condensed Summary Section ctx.beginPage() drawHeader() "Condensed Manifest".draw(at: CGPoint(x: 50, y: 90), withAttributes: subHeaderAttrs) pageY = 120 for card in cards { if pageY > 740 { ctx.beginPage() drawHeader() "Condensed Manifest (Cont.)".draw(at: CGPoint(x: 50, y: 90), withAttributes: subHeaderAttrs) pageY = 120 } let line = "\(card.name) (\(card.setCode) #\(card.collectorNumber))" line.draw(in: CGRect(x: 50, y: pageY, width: 400, height: 18), withAttributes: textAttrs) let val = "\(card.currencyCode == "EUR" ? "€" : "$")\(String(format: "%.2f", card.currentValuation ?? 0.0))" val.draw(at: CGPoint(x: 500, y: pageY), withAttributes: textAttrs) pageY += 20 } // FIX: Ensure Grand Total doesn't fall off the page if pageY + 40 > 792 { ctx.beginPage() drawHeader() pageY = 90 } "GRAND TOTAL: \(cards.first?.currencyCode == "EUR" ? "€" : "$")\(String(format: "%.2f", totalVal))".draw(at: CGPoint(x: 400, y: pageY + 20), withAttributes: subHeaderAttrs) } return url } } enum ExportFormat: String, CaseIterable { case insurance="PDF"; case arena="Arena"; case mtgo="MTGO"; case csv="CSV" } // MARK: - 5. IMAGE MANAGER class ImageManager { static var dir: URL { if FileManager.default.ubiquityIdentityToken != nil { return FileManager.default.url(forUbiquityContainerIdentifier: nil)?.appendingPathComponent("Documents") ?? FileManager.default.urls(for: .documentDirectory, in: .userDomainMask)[0] } return FileManager.default.urls(for: .documentDirectory, in: .userDomainMask)[0] } static func save(_ img: UIImage, name: String) throws { let url = dir.appendingPathComponent(name) if !FileManager.default.fileExists(atPath: dir.path) { try? FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true) } let maxSize: CGFloat = 1024 var actualImg = img if img.size.width > maxSize || img.size.height > maxSize { let scale = maxSize / max(img.size.width, img.size.height) let newSize = CGSize(width: img.size.width * scale, height: img.size.height * scale) let format = UIGraphicsImageRendererFormat() format.scale = 1 let renderer = UIGraphicsImageRenderer(size: newSize, format: format) actualImg = renderer.image { _ in img.draw(in: CGRect(origin: .zero, size: newSize)) } } guard let data = actualImg.jpegData(compressionQuality: 0.7) else { return } try data.write(to: url, options: .atomic) } static func load(name: String) -> UIImage? { let p = dir.appendingPathComponent(name) if FileManager.default.fileExists(atPath: p.path) { return UIImage(contentsOfFile: p.path) } try? FileManager.default.startDownloadingUbiquitousItem(at: p) return UIImage(contentsOfFile: p.path) } static func migrateToCloud() { guard let cloudURL = FileManager.default.url(forUbiquityContainerIdentifier: nil)?.appendingPathComponent("Documents") else { return } let localURL = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask)[0] if !FileManager.default.fileExists(atPath: cloudURL.path) { try? FileManager.default.createDirectory(at: cloudURL, withIntermediateDirectories: true) } let fileManager = FileManager.default guard let files = try? fileManager.contentsOfDirectory(at: localURL, includingPropertiesForKeys: nil) else { return } for file in files { if file.pathExtension == "jpg" { let dest = cloudURL.appendingPathComponent(file.lastPathComponent) if !fileManager.fileExists(atPath: dest.path) { try? fileManager.moveItem(at: file, to: dest) } } } } static func delete(name: String) async { let url = dir.appendingPathComponent(name) try? FileManager.default.removeItem(at: url) } static func cleanupOrphans(activeImages: Set) { Task.detached { guard !activeImages.isEmpty else { return } let fileManager = FileManager.default guard let files = try? fileManager.contentsOfDirectory(at: dir, includingPropertiesForKeys: [.contentModificationDateKey]) else { return } for fileURL in files { if fileURL.pathExtension == "jpg" { if !activeImages.contains(fileURL.lastPathComponent) { if let attrs = try? fileManager.attributesOfItem(atPath: fileURL.path), let date = attrs[.modificationDate] as? Date, Date().timeIntervalSince(date) > 3600 { try? fileManager.removeItem(at: fileURL) } } } } } } } class ClusterEngine { static func refine(candidates: [CardMetadata]) -> String? { // Weighted voting: 1st candidate = 3 pts, 2nd = 2 pts, others = 1 pt var scores: [String: Int] = [:] for (index, card) in candidates.prefix(5).enumerated() { let weight = max(1, 3 - index) scores[card.setCode, default: 0] += weight } return scores.sorted { $0.value > $1.value }.first?.key } } // MARK: - 10. SET SYMBOL ENGINE class SetSymbolEngine { static var model: VNCoreMLModel? = { guard AppConfig.enableSetSymbolDetection else { return nil } return ModelManager.shared.getModel(name: "IYmtgSetClassifier") }() static func recognizeSet(image: CGImage, orientation: CGImagePropertyOrientation = .up) -> String? { guard let model = model else { return nil } let request = VNCoreMLRequest(model: model) request.imageCropAndScaleOption = .scaleFill // FIX: Use regionOfInterest (Normalized 0..1, Bottom-Left origin) // Target: x=0.85 (Right), y=0.36 (Mid-Lower), w=0.12, h=0.09 // Adjusted to ensure we catch the symbol which sits on the type line (approx y=0.40) request.regionOfInterest = CGRect(x: 0.85, y: 0.36, width: 0.12, height: 0.09) let handler = VNImageRequestHandler(cgImage: image, orientation: orientation, options: [:]) do { try handler.perform([request]) guard let results = request.results as? [VNClassificationObservation], let top = results.first else { return nil } return top.confidence > 0.6 ? top.identifier : nil } catch { return nil } } } // MARK: - 11. BORDER DETECTOR class BorderDetector { enum BorderColor { case black, white, gold, other } static func detect(image: CGImage, orientation: CGImagePropertyOrientation = .up) -> BorderColor { // Sample pixels from the edge (e.g., 5% margin) let context = SharedEngineResources.context let ciImage = CIImage(cgImage: image).oriented(orientation) let width = ciImage.extent.width let height = ciImage.extent.height // Crop a small strip from the left edge let cropRect = CGRect(x: CGFloat(width) * 0.02, y: CGFloat(height) * 0.4, width: CGFloat(width) * 0.05, height: CGFloat(height) * 0.2) let vector = CIVector(cgRect: cropRect) let filter = CIFilter(name: "CIAreaAverage", parameters: [kCIInputImageKey: ciImage, kCIInputExtentKey: vector]) guard let output = filter?.outputImage else { return .other } var bitmap = [UInt8](repeating: 0, count: 4) context.render(output, toBitmap: &bitmap, rowBytes: 4, bounds: CGRect(x: 0, y: 0, width: 1, height: 1), format: .RGBA8, colorSpace: nil) let r = Int(bitmap[0]) let g = Int(bitmap[1]) let b = Int(bitmap[2]) let brightness = (r + g + b) / 3 // Gold/Yellow detection (World Champ Decks): High Red/Green, Low Blue if r > 140 && g > 120 && b < 100 && r > b + 40 { return .gold } if brightness < 60 { return .black } if brightness > 180 { return .white } return .other } } // MARK: - 12. LIST SYMBOL DETECTOR class ListSymbolDetector { static func hasListSymbol(image: CGImage, orientation: CGImagePropertyOrientation = .up) -> Bool { // The "List" / "Mystery" symbol is a small white icon in the bottom-left corner. // It sits roughly at x: 3-7%, y: 93-97% of the card frame. let context = SharedEngineResources.context let ciImage = CIImage(cgImage: image).oriented(orientation) let width = ciImage.extent.width let height = ciImage.extent.height // FIX: CIImage uses Bottom-Left origin. The List symbol is Bottom-Left. // y: 0.03 is Bottom. y: 0.93 is Top. let cropRect = CGRect(x: width * 0.03, y: height * 0.03, width: width * 0.05, height: height * 0.04) let vector = CIVector(cgRect: cropRect) // Calculate average brightness in this spot guard let filter = CIFilter(name: "CIAreaAverage", parameters: [kCIInputImageKey: ciImage, kCIInputExtentKey: vector]), let output = filter.outputImage else { return false } var bitmap = [UInt8](repeating: 0, count: 4) context.render(output, toBitmap: &bitmap, rowBytes: 4, bounds: CGRect(x: 0, y: 0, width: 1, height: 1), format: .RGBA8, colorSpace: nil) // A white symbol on a black border will significantly raise the average brightness compared to a pure black border. // Pure black ~ 0-20. With symbol ~ 80-150. let brightness = (Int(bitmap[0]) + Int(bitmap[1]) + Int(bitmap[2])) / 3 return brightness > 60 } } // MARK: - 13. CORNER DETECTOR (Alpha vs Beta) class CornerDetector { static func isAlphaCorner(image: CGImage, orientation: CGImagePropertyOrientation = .up) -> Bool { // Alpha corners are 2mm radius (very round). Beta are 1mm (standard). // We analyze the top-left corner (4% of width). // If significantly more "background" (non-black) pixels exist in the corner square, it's Alpha. let context = SharedEngineResources.context let ciImage = CIImage(cgImage: image).oriented(orientation) let width = ciImage.extent.width let height = ciImage.extent.height let cornerSize = Int(Double(width) * 0.04) // FIX: Analyze Top-Left corner (y is at top in CIImage coordinates) let cropRect = CGRect(x: 0, y: CGFloat(height) - CGFloat(cornerSize), width: CGFloat(cornerSize), height: CGFloat(cornerSize)) let cropped = ciImage.cropped(to: cropRect) var bitmap = [UInt8](repeating: 0, count: cornerSize * cornerSize * 4) context.render(cropped, toBitmap: &bitmap, rowBytes: cornerSize * 4, bounds: cropRect, format: .RGBA8, colorSpace: nil) var backgroundPixelCount = 0 let totalPixels = cornerSize * cornerSize for i in stride(from: 0, to: bitmap.count, by: 4) { let r = Int(bitmap[i]) let g = Int(bitmap[i+1]) let b = Int(bitmap[i+2]) let brightness = (r + g + b) / 3 // Assuming Black Border is < 60 brightness. // If pixel is brighter, it's likely the background revealed by the round corner. if brightness > 80 { backgroundPixelCount += 1 } } // Alpha corners reveal roughly 30-40% background in a tight corner crop. Beta reveals < 20%. return Double(backgroundPixelCount) / Double(totalPixels) > 0.25 } } // MARK: - 14. SATURATION DETECTOR (Unlimited vs Revised) class SaturationDetector { static func analyze(image: CGImage, orientation: CGImagePropertyOrientation = .up) -> Double { // Crop to center 50% to analyze artwork saturation, ignoring borders let ciImage = CIImage(cgImage: image).oriented(orientation) let width = ciImage.extent.width let height = ciImage.extent.height let cropRect = CGRect(x: width * 0.25, y: height * 0.25, width: width * 0.5, height: height * 0.5) let vector = CIVector(cgRect: cropRect) guard let filter = CIFilter(name: "CIAreaAverage", parameters: [kCIInputImageKey: ciImage, kCIInputExtentKey: vector]), let output = filter.outputImage else { return 0 } var bitmap = [UInt8](repeating: 0, count: 4) let context = SharedEngineResources.context context.render(output, toBitmap: &bitmap, rowBytes: 4, bounds: CGRect(x: 0, y: 0, width: 1, height: 1), format: .RGBA8, colorSpace: nil) // Simple Saturation approximation: (Max - Min) / Max let r = Double(bitmap[0]) / 255.0; let g = Double(bitmap[1]) / 255.0; let b = Double(bitmap[2]) / 255.0 let maxC = max(r, max(g, b)); let minC = min(r, min(g, b)) return maxC == 0 ? 0 : (maxC - minC) / maxC } } // MARK: - 15. STAMP DETECTOR (Promos) class StampDetector { static var model: VNCoreMLModel? = { guard AppConfig.enableStampDetection else { return nil } return ModelManager.shared.getModel(name: "IYmtgStampClassifier") }() static func hasStamp(image: CGImage, orientation: CGImagePropertyOrientation = .up) -> Bool { guard let model = model else { return false } let request = VNCoreMLRequest(model: model) request.imageCropAndScaleOption = .scaleFill let handler = VNImageRequestHandler(cgImage: image, orientation: orientation, options: [:]) try? handler.perform([request]) guard let results = request.results as? [VNClassificationObservation], let top = results.first else { return false } return top.identifier == "Stamped" && top.confidence > 0.8 } } // MARK: - 6. FOIL ENGINE actor FoilEngine { private var frameCounter = 0 private var lowConfidenceStreak = 0 static var model: VNCoreMLModel? = { guard AppConfig.enableFoilDetection else { return nil } return ModelManager.shared.getModel(name: "IYmtgFoilClassifier") }() private lazy var request: VNCoreMLRequest? = { guard let model = FoilEngine.model else { return nil } let req = VNCoreMLRequest(model: model) req.imageCropAndScaleOption = .scaleFill return req }() func addFrame(_ image: CGImage, orientation: CGImagePropertyOrientation = .up) -> String? { frameCounter += 1 // SAFETY: Prevent integer overflow in long-running sessions if frameCounter > 1000 { frameCounter = 0 } if frameCounter % 5 != 0 { return nil } guard let request = self.request else { return AppConfig.Defaults.defaultFoil } let handler = VNImageRequestHandler(cgImage: image, orientation: orientation, options: [:]) do { try handler.perform([request]) guard let results = request.results as? [VNClassificationObservation], let top = results.first else { return AppConfig.Defaults.defaultFoil } if top.confidence > 0.85 { lowConfidenceStreak = 0 return top.identifier } else { lowConfidenceStreak += 1 if lowConfidenceStreak >= 3 { return AppConfig.Defaults.defaultFoil } else { return nil } } } catch { return AppConfig.Defaults.defaultFoil } } } // MARK: - 7. PERSISTENCE ACTOR (V1.1.0: iCloud Migration) actor PersistenceActor { static let shared = PersistenceActor() private let fileName = "user_collection.json" private let backupName = "user_collection.bak" // Helper to access cloud URL for migration checks private var cloudURL: URL? { FileManager.default.url(forUbiquityContainerIdentifier: nil)?.appendingPathComponent("Documents") } private var localURL: URL { FileManager.default.urls(for: .documentDirectory, in: .userDomainMask)[0] } private var fileURL: URL { ImageManager.dir.appendingPathComponent(fileName) } private var backupURL: URL { ImageManager.dir.appendingPathComponent(backupName) } init() {} func save(_ cards: [SavedCard]) { do { let data = try JSONEncoder().encode(cards) if FileManager.default.fileExists(atPath: fileURL.path) { try? FileManager.default.removeItem(at: backupURL) try? FileManager.default.copyItem(at: fileURL, to: backupURL) } try data.write(to: fileURL, options: .atomic) } catch { print("Save failed: \(error)") } } func load() -> [SavedCard] { migrateIfNeeded() try? FileManager.default.startDownloadingUbiquitousItem(at: fileURL) if let data = try? Data(contentsOf: fileURL), let list = try? JSONDecoder().decode([SavedCard].self, from: data) { return list } if let data = try? Data(contentsOf: backupURL), let list = try? JSONDecoder().decode([SavedCard].self, from: data) { return list } return [] } private func migrateIfNeeded() { guard let cloud = cloudURL else { return } let cloudFile = cloud.appendingPathComponent(fileName) let localFile = localURL.appendingPathComponent(fileName) if !FileManager.default.fileExists(atPath: cloud.path) { try? FileManager.default.createDirectory(at: cloud, withIntermediateDirectories: true) } if FileManager.default.fileExists(atPath: localFile.path) && !FileManager.default.fileExists(atPath: cloudFile.path) { try? FileManager.default.moveItem(at: localFile, to: cloudFile) print("MIGRATION: Moved local DB to iCloud.") ImageManager.migrateToCloud() } } } // MARK: - 8. NETWORK MONITOR class NetworkMonitor: ObservableObject { static let shared = NetworkMonitor() private let monitor = NWPathMonitor() @Published var isConnected = true init() { monitor.pathUpdateHandler = { path in DispatchQueue.main.async { self.isConnected = path.status == .satisfied } } monitor.start(queue: DispatchQueue.global(qos: .background)) } } // MARK: - 9. REVIEW ENGINE class ReviewEngine { static func logScan() { let count = UserDefaults.standard.integer(forKey: "scanCount") + 1 UserDefaults.standard.set(count, forKey: "scanCount") if count == 20 || count == 100 { DispatchQueue.main.asyncAfter(deadline: .now() + 2.0) { if let scene = UIApplication.shared.connectedScenes.first as? UIWindowScene { SKStoreReviewController.requestReview(in: scene) } } } } }