import Foundation import LCEssentials /// Protocol seam for DI — ViewModels/Interactors depend on this, never on the /// concrete actor, so a mock can stand in for tests. public protocol FeatureControlEvaluating: Sendable { /// Never throws. Fresh fetch → stale cache → app-supplied `defaults`, in that /// order (FC-060 §4 "nunca bloquear fluxo crítico"). Use this everywhere a /// flag gates real product behaviour. func evaluate(keys: [String], context: FeatureControlContext) async -> FeatureControlSnapshot /// Same fetch, but surfaces the real failure. For admin/debug tooling only. func evaluateOrThrow(keys: [String], context: FeatureControlContext) async throws -> FeatureControlSnapshot /// Drops every cached entry. Call on user/store change, or app foreground if desired — /// this type does not observe app lifecycle itself (SDD §2 Non-Goals). func invalidateCache() async } public actor FeatureControlManager: FeatureControlEvaluating { private let configuration: FeatureControlConfiguration private let api: API private let defaults: [String: FeatureControlFlag] private let cache: FeatureControlCache private var exposureBuffer: [FeatureControlExposureEvent] = [] /// Coalesces concurrent cold-cache callers onto one in-flight request per key, /// instead of firing one POST per caller (which was hammering the 429 limit). private var inFlight: [FeatureControlCacheKey: Task] = [:] /// `configVersion` sentinel returned when no network response and no cache exist — /// distinguishes "never evaluated" from any real server value (server versions are ≥ 0). public static let unresolvedConfigVersion = -1 public init(configuration: FeatureControlConfiguration, api: API = .shared, defaults: [String: FeatureControlFlag] = [:], now: @escaping @Sendable () -> Date = { Date() }) { self.configuration = configuration self.api = api self.defaults = defaults self.cache = FeatureControlCache(now: now) } public func evaluate(keys: [String], context: FeatureControlContext) async -> FeatureControlSnapshot { do { return try await evaluateOrThrow(keys: keys, context: context) } catch { let key = cacheKey(keys: keys, context: context) if let stale = await cache.get(key, allowStale: true) { return stale } return FeatureControlSnapshot(evaluatedAt: Date(), configVersion: Self.unresolvedConfigVersion, flags: defaults) } } public func evaluateOrThrow(keys: [String], context: FeatureControlContext) async throws -> FeatureControlSnapshot { let key = cacheKey(keys: keys, context: context) if let fresh = await cache.get(key, allowStale: false) { return fresh } if let inFlightTask = inFlight[key] { return try await inFlightTask.value } let task = Task { var headers: [String: String] = [:] await self.configuration.auth.authorize(&headers) let body = jsonBody(FeatureControlEvaluateRequestBody( environment: self.configuration.environment, keys: keys, context: context)) do { let envelope: FeatureControlEvaluateEnvelope = try await self.api.request( url: self.configuration.baseURL + self.configuration.evaluatePath, method: .post, body: body, headers: headers, debug: false, timeoutInterval: self.configuration.requestTimeout ) guard !envelope.error else { throw FeatureControlError.server(code: envelope.code ?? "UNKNOWN", status: 200) } await self.cache.set(key, snapshot: envelope.result, ttl: self.configuration.cacheTTL) return envelope.result } catch let error as FeatureControlError { throw error } catch { throw FeatureControlErrorMapper.map(error) } } inFlight[key] = task defer { inFlight[key] = nil } return try await task.value } public func invalidateCache() async { await cache.invalidateAll() } private func cacheKey(keys: [String], context: FeatureControlContext) -> FeatureControlCacheKey { FeatureControlCacheKey(environment: configuration.environment, subjectId: context.subjectId, sortedKeys: keys.sorted(), platform: context.platform, appVersion: context.appVersion) } } // MARK: - Exposure telemetry extension FeatureControlManager { /// Buffers locally. Nothing is sent until `flushExposures()` is called. public func recordExposure(_ event: FeatureControlExposureEvent) { exposureBuffer.append(event) } /// Best-effort — batches of ≤100 (server `maxItems: 100`); a failed batch is /// dropped, never retried indefinitely (not a critical-path operation). public func flushExposures() async { guard !exposureBuffer.isEmpty else { return } let events = exposureBuffer exposureBuffer.removeAll() for batch in events.lce_featureControlChunked(into: 100) { var headers: [String: String] = [:] await configuration.auth.authorize(&headers) let body = jsonBody(FeatureControlExposureBatchBody(events: batch)) _ = try? await api.request( url: configuration.baseURL + configuration.telemetryPath, method: .post, body: body, headers: headers, debug: false, timeoutInterval: configuration.requestTimeout ) as FeatureControlExposureBatchEnvelope } } } private extension Array { func lce_featureControlChunked(into size: Int) -> [[Element]] { stride(from: 0, to: count, by: size).map { Array(self[$0..