import Foundation import ManaCore import SwiftData /// Verwaltet die `PendingSubmission`-Queue. Hauptverantwortung: Drafts /// persistieren bei Network-Failure, beim Foreground/Reconnect via /// `tryFlush(api:)` einen Retry-Run starten. /// /// **Retry-Strategie:** alle wartenden Drafts werden sequentiell gesendet. /// Bei `400/409/401` (Server-Code) wird der Eintrag **gelöscht** — das /// sind permanente Fehler (validation_failed, duplicate, unauthorized), /// die ein Retry nicht rettet. Bei Network-/5xx-Fehler bleibt der Draft /// in der Queue mit `lastError` für Diagnose; `retryCount++`. /// /// **Hartes Limit:** 50 Retries pro Eintrag, danach wird er als /// "permanent failed" markiert (`retryCount > 50`) und nicht mehr /// versucht — User muss manuell löschen oder neu eintragen. @MainActor @Observable final class SubmissionQueue { let container: ModelContainer private var inFlight: Bool = false /// Sammelt Drop-Records vom letzten tryFlush — eine UI-Schicht kann /// sie als "diese N Submissions wurden permanent verworfen"-Banner /// anzeigen, statt dass der User stumm verliert. struct DropRecord: Identifiable, Sendable { let id = UUID() let textPreview: String let authorName: String? let code: String? let message: String let droppedAt: Date } /// Drop-Notifications der letzten Session — UI liest und konsumiert /// via `consumeDropNotifications()`. private(set) var dropNotifications: [DropRecord] = [] init(container: ModelContainer) { self.container = container } /// Leert die Liste — Caller hat sie gerade angezeigt. func consumeDropNotifications() -> [DropRecord] { let snapshot = dropNotifications dropNotifications.removeAll() return snapshot } /// Hängt einen Draft an die Queue. func deposit(_ draft: QuoteDraft, error: String? = nil) { let ctx = ModelContext(container) let entry = PendingSubmission(draft: draft) entry.lastError = error ctx.insert(entry) try? ctx.save() Log.app.info("Submission deposited (queue depth: \(self.queueDepth()))") } /// Anzahl der wartenden Submissions. func queueDepth() -> Int { let ctx = ModelContext(container) let descriptor = FetchDescriptor() return (try? ctx.fetch(descriptor).count) ?? 0 } /// Holt alle pending Drafts (für UI). func loadAll() -> [PendingSubmission] { let ctx = ModelContext(container) let descriptor = FetchDescriptor( sortBy: [SortDescriptor(\.createdAt)] ) return (try? ctx.fetch(descriptor)) ?? [] } /// Löscht eine Submission manuell aus der Queue. func delete(id: UUID) { let ctx = ModelContext(container) let descriptor = FetchDescriptor( predicate: #Predicate { $0.id == id } ) if let row = try? ctx.fetch(descriptor).first { ctx.delete(row) try? ctx.save() } } /// Versucht alle wartenden Submissions zu senden. Returns count of /// erfolgreich gesendeter Items. @discardableResult func tryFlush(api: ZitareAPI) async -> Int { guard !inFlight else { return 0 } inFlight = true defer { inFlight = false } let ctx = ModelContext(container) let descriptor = FetchDescriptor( predicate: #Predicate { $0.retryCount <= 50 }, sortBy: [SortDescriptor(\.createdAt)] ) guard let entries = try? ctx.fetch(descriptor), !entries.isEmpty else { return 0 } Log.app.info("Flushing \(entries.count) pending submission(s)") var sent = 0 for entry in entries { let draft = entry.toDraft() do { _ = try await api.submitQuote(draft) ctx.delete(entry) sent += 1 } catch let error as ZitareAPIError where shouldDrop(error) { Log.app.warning("Dropping pending submission (permanent error: \(error.code ?? "?", privacy: .public))") entry.lastError = error.errorDescription // Notify UI: dieser Draft wurde verworfen. Pre-Snapshot // der Daten vor dem Delete machen. dropNotifications.append(DropRecord( textPreview: String(entry.text.prefix(120)), authorName: entry.authorName, code: error.code, message: error.errorDescription ?? "Fehler", droppedAt: Date() )) ctx.delete(entry) } catch let error as LocalizedError { entry.lastError = error.errorDescription entry.lastTriedAt = Date() entry.retryCount += 1 } catch { entry.lastError = String(describing: error) entry.lastTriedAt = Date() entry.retryCount += 1 } } try? ctx.save() Log.app.info("Flush done — sent \(sent)/\(entries.count)") return sent } /// 4xx-Errors sind permanent (validation, auth, duplicate) → Drop. /// 5xx und Transport-Failure → Retry. private func shouldDrop(_ error: ZitareAPIError) -> Bool { error.status >= 400 && error.status < 500 } }