import Foundation import ManaCore /// Cards-spezifischer API-Client. Wrapper um `AuthenticatedTransport` /// aus ManaCore, der die Cardecky-Endpoints kennt. actor CardsAPI { private let transport: AuthenticatedTransport private let decoder: JSONDecoder init(auth: AuthClient) { transport = AuthenticatedTransport(baseURL: AppConfig.apiBaseURL, auth: auth) decoder = JSONDecoder() decoder.dateDecodingStrategy = .iso8601withFractional } /// Health-Probe — verifiziert dass cardecky-api erreichbar ist /// und der eigene JWT akzeptiert wird. func healthCheck() async throws -> Bool { let (_, http) = try await transport.request(path: "/healthz") return http.statusCode == 200 } // MARK: - Decks /// `GET /api/v1/decks?archived=false` — alle aktiven Decks des Users. /// Optional: `forkedFromMarketplaceOnly` filtert auf Inbox-Decks /// (für den Inbox-Banner). func listDecks(forkedFromMarketplaceOnly: Bool = false) async throws -> [Deck] { var path = "/api/v1/decks" if forkedFromMarketplaceOnly { path += "?forked_from_marketplace=true" } let (data, http) = try await transport.request(path: path) try ensureOK(http, data: data) let body = try decoder.decode(DeckListResponse.self, from: data) return body.decks } /// `GET /api/v1/cards?deck_id=...` — Anzahl Karten in einem Deck. /// Web macht das pro Deck einzeln; identisches Pattern hier. func cardCount(deckId: String) async throws -> Int { let (data, http) = try await transport.request(path: "/api/v1/cards?deck_id=\(deckId)") try ensureOK(http, data: data) return try decoder.decode(CardListResponse.self, from: data).total } /// `GET /api/v1/reviews/due?deck_id=...&limit=500` — Anzahl fälliger /// Reviews in einem Deck. func dueCount(deckId: String) async throws -> Int { let (data, http) = try await transport.request( path: "/api/v1/reviews/due?deck_id=\(deckId)&limit=500" ) try ensureOK(http, data: data) return try decoder.decode(DueReviewsResponse.self, from: data).total } // MARK: - Study /// `GET /api/v1/reviews/due?deck_id=...&limit=500` — fällige Reviews /// inklusive zugehöriger Card-Daten. Hot-Path für die Study-View. func dueReviews(deckId: String, limit: Int = 500) async throws -> [DueReview] { let (data, http) = try await transport.request( path: "/api/v1/reviews/due?deck_id=\(deckId)&limit=\(limit)" ) try ensureOK(http, data: data) return try decoder.decode(DueReviewsListResponse.self, from: data).reviews } /// `POST /api/v1/reviews/:cardId/:subIndex/grade` — gibt eine /// Bewertung ab. Server rechnet FSRS, antwortet mit aktualisiertem /// Review. @discardableResult func gradeReview( cardId: String, subIndex: Int, rating: Rating, reviewedAt: Date = .now ) async throws -> Review { let body = try makeJSON(GradeReviewBody(rating: rating, reviewedAt: reviewedAt)) let (data, http) = try await transport.request( path: "/api/v1/reviews/\(cardId)/\(subIndex)/grade", method: "POST", body: body ) try ensureOK(http, data: data) return try decoder.decode(Review.self, from: data) } // MARK: - JSON-Encoding private func makeJSON(_ value: T) throws -> Data { let encoder = JSONEncoder() encoder.dateEncodingStrategy = .iso8601 return try encoder.encode(value) } // MARK: - Helpers private func ensureOK(_ http: HTTPURLResponse, data: Data) throws { guard (200 ..< 300).contains(http.statusCode) else { let message = (try? JSONDecoder().decode(CardsServerError.self, from: data))?.error throw AuthError.serverError(status: http.statusCode, message: message) } } } private struct CardsServerError: Decodable { let error: String? } extension JSONDecoder.DateDecodingStrategy { /// Cards-API liefert ISO8601 mit Fractional-Seconds aus /// `.toISOString()`. Standard-Strategy `.iso8601` akzeptiert die /// fractional seconds nicht — wir nutzen einen eigenen Formatter. static let iso8601withFractional: JSONDecoder.DateDecodingStrategy = .custom { decoder in let container = try decoder.singleValueContainer() let raw = try container.decode(String.self) let f = ISO8601DateFormatter() f.formatOptions = [.withInternetDateTime, .withFractionalSeconds] if let date = f.date(from: raw) { return date } f.formatOptions = [.withInternetDateTime] if let date = f.date(from: raw) { return date } throw DecodingError.dataCorruptedError( in: container, debugDescription: "Cannot decode ISO8601 date: \(raw)" ) } }