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] = [] /// `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 } var headers: [String: String] = [:] await configuration.auth.authorize(&headers) let body = jsonBody(FeatureControlEvaluateRequestBody( environment: configuration.environment, keys: keys, context: context)) do { let envelope: FeatureControlEvaluateEnvelope = try await api.request( url: configuration.baseURL + configuration.evaluatePath, method: .post, body: body, headers: headers, timeoutInterval: configuration.requestTimeout ) await cache.set(key, snapshot: envelope.result, ttl: configuration.cacheTTL) return envelope.result } catch { throw FeatureControlErrorMapper.map(error) } } 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, 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..