import AVFoundation import Combine import Foundation @MainActor final class HermesRepository: ObservableObject { @Published private(set) var currentSession: HermesSessionResponse? @Published private(set) var currentRound: HermesRound? @Published private(set) var isLoading = true @Published private(set) var errorCause: Error? @Published private(set) var serverClockOffset: TimeInterval? private let apiClient: HermesAPIClient private var mockRoundIndex = 0 init(apiClient: HermesAPIClient) { self.apiClient = apiClient } func reset() { currentSession = nil currentRound = nil isLoading = false errorCause = nil serverClockOffset = nil mockRoundIndex = 0 } func bootstrapMock(localeCode: String, appVersion: String) async { isLoading = true errorCause = nil mockRoundIndex = 0 currentSession = MockHermesData.session(localeCode: localeCode, appVersion: appVersion) currentRound = MockHermesData.round(index: mockRoundIndex, now: Date()) isLoading = false } func refreshMockRound() async throws -> HermesRound { isLoading = true errorCause = nil mockRoundIndex = (mockRoundIndex + 1) % MockHermesData.roundCount let round = MockHermesData.round(index: mockRoundIndex, now: Date()) currentRound = round isLoading = false return round } func bootstrap(_ request: HermesSessionStartRequest) async throws -> HermesSessionResponse { isLoading = true errorCause = nil do { await syncClock() let session: HermesSessionResponse if let existingSession = currentSession { session = existingSession } else { session = try await startSession(request) } if currentRound == nil { currentRound = try await loadRoundFromNetwork() } isLoading = false return session } catch { errorCause = error isLoading = false throw error } } func refreshRoundFromNetwork() async throws -> HermesRound { isLoading = true errorCause = nil do { await syncClock() let round = try await loadRoundFromNetwork() currentRound = round isLoading = false return round } catch { errorCause = error isLoading = false throw error } } func startSession(_ request: HermesSessionStartRequest) async throws -> HermesSessionResponse { let session = try await apiClient.startSession(request) currentSession = session return session } func endSession() async throws -> HermesSessionResponse { let session = try await apiClient.endSession() currentSession = session return session } func submitBetIntent(_ request: HermesBetIntentRequest) async throws -> HermesBetIntentResponse { try await apiClient.submitBetIntent(request) } func currentOdds(marketID: UUID) async throws -> HermesOddsVersion { try await apiClient.currentOdds(marketID: marketID) } func settlement(eventID: UUID) async throws -> HermesSettlement { try await apiClient.settlement(eventID: eventID) } func experimentConfig() async throws -> HermesExperimentConfig { try await apiClient.experimentConfig() } func localization(localeCode: String) async throws -> HermesLocalizationBundle { try await apiClient.localization(localeCode: localeCode) } func submitAnalyticsBatch(_ payload: HermesAnalyticsBatchRequest) async throws { try await apiClient.submitAnalyticsBatch(payload) } func serverNow() -> Date { guard let serverClockOffset else { return Date() } return Date().addingTimeInterval(serverClockOffset) } private func syncClock() async { do { let health = try await apiClient.health() serverClockOffset = health.serverTime.timeIntervalSince(Date()) } catch { return } } private func loadRoundFromNetwork() async throws -> HermesRound { let event = try await apiClient.nextEvent() let manifest = try await apiClient.eventManifest(eventID: event.id) guard let media = manifest.media.first(where: { $0.mediaType == "hls_main" }) ?? manifest.media.first else { throw HermesAPIError.invalidResponse } let market: HermesMarket if let manifestMarket = manifest.markets.first { market = manifestMarket } else { let markets = try await apiClient.markets(eventID: event.id) guard let fallbackMarket = markets.first else { throw HermesAPIError.invalidResponse } market = fallbackMarket } let oddsVersion = try await apiClient.currentOdds(marketID: market.id) let settlement = try await apiClient.settlement(eventID: event.id) return HermesRound( event: event, media: media, market: market, oddsVersion: oddsVersion, settlement: settlement ) } } enum MockHermesData { static let roundCount = 3 private static let fallbackMediaURL = URL(string: "https://devstreaming-cdn.apple.com/videos/streaming/examples/img_bipbop_adv_example_ts/master.m3u8")! static func session(localeCode: String, appVersion: String) -> HermesSessionResponse { HermesSessionResponse( sessionId: UUID(uuidString: "00000000-0000-0000-0000-000000000001") ?? UUID(), userId: UUID(uuidString: "00000000-0000-0000-0000-000000000002") ?? UUID(), startedAt: Date(), endedAt: nil, experimentVariant: "modern", appVersion: appVersion, deviceModel: "Demo Device", osVersion: "Demo OS", localeCode: localeCode, devicePlatform: "ios" ) } static func round(index: Int, now: Date) -> HermesRound { let scenarios = [ scenario( eventID: "10000000-0000-0000-0000-000000000001", marketID: "20000000-0000-0000-0000-000000000001", yesOutcomeID: "30000000-0000-0000-0000-000000000001", noOutcomeID: "30000000-0000-0000-0000-000000000002", promptKey: "event.question.score", sourceRef: "mock-score", winningOutcomeID: "30000000-0000-0000-0000-000000000001", now: now ), scenario( eventID: "10000000-0000-0000-0000-000000000011", marketID: "20000000-0000-0000-0000-000000000011", yesOutcomeID: "30000000-0000-0000-0000-000000000011", noOutcomeID: "30000000-0000-0000-0000-000000000012", promptKey: "event.question.save", sourceRef: "mock-save", winningOutcomeID: "30000000-0000-0000-0000-000000000012", now: now ), scenario( eventID: "10000000-0000-0000-0000-000000000021", marketID: "20000000-0000-0000-0000-000000000021", yesOutcomeID: "30000000-0000-0000-0000-000000000021", noOutcomeID: "30000000-0000-0000-0000-000000000022", promptKey: "event.question.convert", sourceRef: "mock-convert", winningOutcomeID: "30000000-0000-0000-0000-000000000021", now: now ), ] return scenarios[index % scenarios.count] } private static func scenario( eventID: String, marketID: String, yesOutcomeID: String, noOutcomeID: String, promptKey: String, sourceRef: String, winningOutcomeID: String, now: Date ) -> HermesRound { let mediaURL = localMediaURL(named: "example-round.mov") ?? fallbackMediaURL let previewDurationMs = assetDurationMs(url: mediaURL) ?? 15_000 let revealURL = revealMediaURL() let revealDurationMs = assetDurationMs(url: revealURL) ?? 6_000 let lockAt = now.addingTimeInterval(Double(previewDurationMs) / 1_000.0) let settleAt = lockAt.addingTimeInterval(6) let event = HermesEvent( id: UUID(uuidString: eventID) ?? UUID(), sportType: "football", sourceRef: sourceRef, titleEn: promptKey, titleSv: promptKey, status: "active", previewStartMs: 0, previewEndMs: previewDurationMs, revealStartMs: 0, revealEndMs: revealDurationMs, lockAt: lockAt, settleAt: settleAt ) let media = HermesEventMedia( id: UUID(), eventId: event.id, mediaType: "hls_main", hlsMasterUrl: mediaURL, posterUrl: nil, durationMs: previewDurationMs, previewStartMs: 0, previewEndMs: previewDurationMs, revealStartMs: 0, revealEndMs: revealDurationMs ) let marketUUID = UUID(uuidString: marketID) ?? UUID() let market = HermesMarket( id: marketUUID, eventId: event.id, questionKey: promptKey, marketType: promptKey, status: "open", lockAt: lockAt, settlementRuleKey: "demo", outcomes: [ HermesOutcome( id: UUID(uuidString: yesOutcomeID) ?? UUID(), marketId: marketUUID, outcomeCode: "yes", labelKey: "round.yes", sortOrder: 0 ), HermesOutcome( id: UUID(uuidString: noOutcomeID) ?? UUID(), marketId: marketUUID, outcomeCode: "no", labelKey: "round.no", sortOrder: 1 ), ] ) let oddsVersionID = UUID() let oddsVersion = HermesOddsVersion( id: oddsVersionID, marketId: market.id, versionNo: 1, createdAt: now, isCurrent: true, odds: [ HermesOutcomeOdds(id: UUID(), oddsVersionId: oddsVersionID, outcomeId: market.outcomes[0].id, decimalOdds: 1.82, fractionalNum: 91, fractionalDen: 50), HermesOutcomeOdds(id: UUID(), oddsVersionId: oddsVersionID, outcomeId: market.outcomes[1].id, decimalOdds: 2.05, fractionalNum: 41, fractionalDen: 20), ] ) let settlement = HermesSettlement( id: UUID(), marketId: market.id, settledAt: settleAt, winningOutcomeId: UUID(uuidString: winningOutcomeID) ?? market.outcomes[0].id ) return HermesRound(event: event, media: media, market: market, oddsVersion: oddsVersion, settlement: settlement) } static func revealMediaURL() -> URL { localMediaURL(named: "example-reveal.mov") ?? fallbackMediaURL } private static func localMediaURL(named fileName: String) -> URL? { let sourceFileURL = URL(fileURLWithPath: #filePath) let repoRoot = sourceFileURL .deletingLastPathComponent() .deletingLastPathComponent() .deletingLastPathComponent() .deletingLastPathComponent() let candidate = repoRoot.appendingPathComponent(fileName) return FileManager.default.fileExists(atPath: candidate.path) ? candidate : nil } private static func assetDurationMs(url: URL) -> Int? { let duration = AVAsset(url: url).duration.seconds guard duration.isFinite, duration > 0 else { return nil } return Int((duration * 1_000.0).rounded()) } }