import Foundation /// Represents the complete consent state for a visitor. /// /// This model mirrors the web consent cookie structure for cross-platform consistency. /// It is persisted locally via ``ConsentStorage`` and synced to the server. public struct ConsentState: Codable, Equatable, Sendable { // MARK: - Properties /// A stable, anonymous identifier for this device/visitor. /// Generated once and persisted across sessions. public let visitorId: String /// The set of categories the visitor has explicitly accepted. public var accepted: Set /// The set of categories the visitor has explicitly rejected. public var rejected: Set /// The timestamp at which consent was last recorded. public var consentedAt: Date? /// The banner configuration version active when consent was collected. /// Used to detect when consent must be re-collected after a config change. public var bannerVersion: String? /// Whether the user has interacted with the banner (accepted or rejected). /// /// Returns `false` when the state represents the pre-consent default. public var hasInteracted: Bool { consentedAt != nil } // MARK: - Derived State /// Returns `true` if the user has granted consent for the given category. /// /// `necessary` is always considered granted regardless of the stored state. public func isGranted(_ category: ConsentCategory) -> Bool { guard category != .necessary else { return true } return accepted.contains(category) } /// Returns `true` if the user has explicitly denied consent for the given category. public func isDenied(_ category: ConsentCategory) -> Bool { guard category != .necessary else { return false } return rejected.contains(category) } // MARK: - Initialisers /// Creates a new, blank consent state for the given visitor. /// /// No categories are accepted or rejected; `consentedAt` is `nil`. public init(visitorId: String) { self.visitorId = visitorId self.accepted = [] self.rejected = [] self.consentedAt = nil self.bannerVersion = nil } /// Creates a fully populated consent state. public init( visitorId: String, accepted: Set, rejected: Set, consentedAt: Date?, bannerVersion: String? ) { self.visitorId = visitorId self.accepted = accepted self.rejected = rejected self.consentedAt = consentedAt self.bannerVersion = bannerVersion } // MARK: - Mutations /// Returns a new state with all non-necessary categories accepted. public func acceptingAll() -> ConsentState { let allOptional = ConsentCategory.allCases.filter { $0.requiresConsent } return ConsentState( visitorId: visitorId, accepted: Set(allOptional), rejected: [], consentedAt: Date(), bannerVersion: bannerVersion ) } /// Returns a new state with all non-necessary categories rejected. public func rejectingAll() -> ConsentState { let allOptional = ConsentCategory.allCases.filter { $0.requiresConsent } return ConsentState( visitorId: visitorId, accepted: [], rejected: Set(allOptional), consentedAt: Date(), bannerVersion: bannerVersion ) } /// Returns a new state accepting only the specified categories (and rejecting the rest). public func accepting(categories: Set) -> ConsentState { let allOptional = Set(ConsentCategory.allCases.filter { $0.requiresConsent }) let toAccept = categories.filter { $0.requiresConsent } let toReject = allOptional.subtracting(toAccept) return ConsentState( visitorId: visitorId, accepted: toAccept, rejected: toReject, consentedAt: Date(), bannerVersion: bannerVersion ) } }