import Foundation import CoreData import Combine @MainActor class InvestmentSourceRepository: ObservableObject { private let context: NSManagedObjectContext @Published private(set) var sources: [InvestmentSource] = [] private var cancellables = Set() init(context: NSManagedObjectContext = CoreDataStack.shared.viewContext) { self.context = context fetchSources() setupNotificationObserver() } private func setupNotificationObserver() { NotificationCenter.default.publisher(for: .NSManagedObjectContextObjectsDidChange, object: context) .receive(on: DispatchQueue.main) .sink { [weak self] notification in guard let self, self.isRelevantChange(notification) else { return } self.fetchSources() } .store(in: &cancellables) NotificationCenter.default.publisher(for: .cloudKitForceReload) .receive(on: DispatchQueue.main) .sink { [weak self] _ in self?.fetchSources() } .store(in: &cancellables) } // MARK: - Fetch func fetchSources(account: Account? = nil) { let request: NSFetchRequest = InvestmentSource.fetchRequest() if let account = account { request.predicate = NSPredicate(format: "account == %@", account) } request.sortDescriptors = [ NSSortDescriptor(keyPath: \InvestmentSource.name, ascending: true) ] // Performance: Add batch size for large datasets request.fetchBatchSize = 100 do { sources = try context.fetch(request) } catch { print("Failed to fetch sources: \(error)") sources = [] } } func fetchSource(by id: UUID) -> InvestmentSource? { let request: NSFetchRequest = InvestmentSource.fetchRequest() request.predicate = NSPredicate(format: "id == %@", id as CVarArg) request.fetchLimit = 1 return try? context.fetch(request).first } func fetchSources(for category: Category) -> [InvestmentSource] { let request: NSFetchRequest = InvestmentSource.fetchRequest() request.predicate = NSPredicate(format: "category == %@", category) request.sortDescriptors = [ NSSortDescriptor(keyPath: \InvestmentSource.name, ascending: true) ] return (try? context.fetch(request)) ?? [] } func fetchActiveSources() -> [InvestmentSource] { sources.filter { $0.isActive } } func fetchSourcesNeedingUpdate(for account: Account? = nil) -> [InvestmentSource] { let filtered = account == nil ? sources : sources.filter { $0.account?.id == account?.id } return filtered.filter { $0.needsUpdate } } // MARK: - Create /// Check if a source with the given name already exists in the account (case-insensitive) func sourceExists(name: String, in account: Account?) -> Bool { let trimmed = name.trimmingCharacters(in: .whitespacesAndNewlines) let normalized = trimmed.lowercased() return sources.contains { source in let nameMatches = source.name.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() == normalized let accountMatches = source.account?.id == account?.id return nameMatches && accountMatches } } /// Find an existing source by name (case-insensitive) within the same account func findSource(byName name: String, in account: Account?) -> InvestmentSource? { let trimmed = name.trimmingCharacters(in: .whitespacesAndNewlines) let normalized = trimmed.lowercased() return sources.first { source in let nameMatches = source.name.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() == normalized let accountMatches = source.account?.id == account?.id return nameMatches && accountMatches } } @discardableResult func createSource( name: String, category: Category, notificationFrequency: NotificationFrequency = .monthly, customFrequencyMonths: Int = 1, account: Account? = nil ) -> InvestmentSource { // Check if source with same name already exists in this account if let existing = findSource(byName: name, in: account) { return existing } let source = InvestmentSource(context: context) source.name = name.trimmingCharacters(in: .whitespacesAndNewlines) source.category = category source.notificationFrequency = notificationFrequency.rawValue source.customFrequencyMonths = Int16(customFrequencyMonths) source.account = account save() return source } // MARK: - Update func updateSource( _ source: InvestmentSource, name: String? = nil, category: Category? = nil, notificationFrequency: NotificationFrequency? = nil, customFrequencyMonths: Int? = nil, isActive: Bool? = nil, account: Account? = nil ) { if let name = name { source.name = name } if let category = category { source.category = category } if let frequency = notificationFrequency { source.notificationFrequency = frequency.rawValue } if let customMonths = customFrequencyMonths { source.customFrequencyMonths = Int16(customMonths) } if let isActive = isActive { source.isActive = isActive } if let account = account { source.account = account } save() } func toggleActive(_ source: InvestmentSource) { source.isActive.toggle() save() } // MARK: - Delete func deleteSource(_ source: InvestmentSource) { if source.managedObjectContext == context { context.delete(source) } else { let objectInContext = context.object(with: source.objectID) context.delete(objectInContext) } save() } func deleteSource(at offsets: IndexSet) { for index in offsets { guard index < sources.count else { continue } context.delete(sources[index]) } save() } // MARK: - Statistics var sourceCount: Int { sources.count } var activeSourceCount: Int { sources.filter { $0.isActive }.count } var totalValue: Decimal { sources.reduce(Decimal.zero) { $0 + $1.latestValue } } func topSources(limit: Int = 5) -> [InvestmentSource] { sources .sorted { $0.latestValue > $1.latestValue } .prefix(limit) .map { $0 } } // MARK: - Freemium Check var canAddMoreSources: Bool { // This will be checked against FreemiumValidator true } // MARK: - Save private func save() { guard context.hasChanges else { return } do { try context.save() // Note: Removed redundant fetchSources() call - the NotificationCenter observer // in setupNotificationObserver() already handles refetching on context changes CoreDataStack.shared.refreshWidgetData() } catch { print("Failed to save context: \(error)") } } private func isRelevantChange(_ notification: Notification) -> Bool { guard let info = notification.userInfo else { return false } let keys: [String] = [ NSInsertedObjectsKey, NSUpdatedObjectsKey, NSDeletedObjectsKey, NSRefreshedObjectsKey ] for key in keys { if let objects = info[key] as? Set { if objects.contains(where: { $0 is InvestmentSource }) { return true } } } return false } }