import Foundation enum FuelPriceTimestamp { static func parse(_ value: String?) -> Date? { guard let value, !value.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else { return nil } let formatter = ISO8601DateFormatter() formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds] return formatter.date(from: value) ?? { formatter.formatOptions = [.withInternetDateTime] return formatter.date(from: value) }() } } enum FuelPriceFreshness { static let warningAge: TimeInterval = 7 * 24 * 60 * 60 static let staleAge: TimeInterval = 30 * 24 * 60 * 60 static let futureTolerance: TimeInterval = 5 * 60 enum Source: Equatable { case gradeSpecific, stationWide, unknown } enum Status: Equatable { case fresh, warning, stale, unknown, invalid } enum Confidence: Equatable { case verified, provisional, unavailable } enum RecommendationEligibility: Equatable { case eligible, ineligible } struct Assessment: Equatable { let source: Source let status: Status let age: TimeInterval? let confidence: Confidence let recommendationEligibility: RecommendationEligibility let warning: Bool var isEligibleForBestValue: Bool { recommendationEligibility == .eligible } var stationReportAgeLabel: String? { guard source == .stationWide, let age else { return nil } return String(format: String(localized: "%@ ago", comment: "Price age, e.g. 28h ago"), Self.ageText(age)) } var selectedFuelUpdateLabel: String { guard source == .gradeSpecific, let age else { return String(localized: "Not provided", comment: "Fuel-specific update time is missing") } return String(format: String(localized: "%@ ago", comment: "Price age, e.g. 28h ago"), Self.ageText(age)) } var displayLabel: String { switch (source, status, age) { case (_, .unknown, _): return "Price age unknown" case (_, .invalid, _): return "Price timestamp invalid" case (_, .stale, let age?): return "Price updated \(Self.ageText(age)) ago · may be outdated" case (.stationWide, _, let age?): return "Price updated \(Self.ageText(age)) ago · station-wide · provisional" case (_, .warning, let age?): return "Price updated \(Self.ageText(age)) ago" case (.gradeSpecific, .fresh, let age?): return "Price updated \(Self.ageText(age)) ago" default: return "Price age unknown" } } private static func ageText(_ age: TimeInterval) -> String { let minutes = max(0, Int(age / 60)) if minutes < 60 { return "\(minutes)m" } let hours = minutes / 60 if hours < 48 { return "\(hours)h" } return "\(hours / 24)d" } } static func assessment(for station: FuelStation, fuel: FuelType, now: Date = Date()) -> Assessment { if station.priceUpdatedByFuelRaw?[fuel] != nil && station.priceUpdatedByFuel?[fuel] == nil { return invalid(source: .gradeSpecific) } if let timestamp = station.priceUpdatedByFuel?[fuel] { return assess(timestamp: timestamp, source: .gradeSpecific, now: now) } guard let timestamp = station.priceUpdated else { return unknown() } return assess(timestamp: timestamp, source: .stationWide, now: now) } /// Recommendation gate for Best Value. A station-wide timestamp under /// seven days can support a provisional estimate. Older station-wide /// reports cannot establish grade recency; grade-specific timestamps may /// remain eligible up to thirty days, but carry an age warning. static func isEligibleForBestValue(station: FuelStation, fuel: FuelType, now: Date = Date()) -> Bool { assessment(for: station, fuel: fuel, now: now).isEligibleForBestValue } static func assessment(priceUpdated: String?, priceUpdatedByGrade: [String: String]?, fuel: FuelType, now: Date = Date()) -> Assessment { if let raw = priceUpdatedByGrade?[fuel.rawValue] { guard let date = FuelPriceTimestamp.parse(raw) else { return invalid(source: .gradeSpecific) } return assess(timestamp: date.timeIntervalSince1970, source: .gradeSpecific, now: now) } guard let raw = priceUpdated else { return unknown() } guard let date = FuelPriceTimestamp.parse(raw) else { return invalid() } return assess(timestamp: date.timeIntervalSince1970, source: .stationWide, now: now) } static func assessment(priceUpdated: TimeInterval?, priceUpdatedByFuel: [FuelType: TimeInterval]?, fuel: FuelType, now: Date = Date()) -> Assessment { if let timestamp = priceUpdatedByFuel?[fuel] { return assess(timestamp: timestamp, source: .gradeSpecific, now: now) } guard let timestamp = priceUpdated else { return unknown() } return assess(timestamp: timestamp, source: .stationWide, now: now) } /// Confidence for a monetary comparison requires both selected-grade prices. /// Keep the eligibility and label decision together for app and widgets. enum ComparisonConfidence: Equatable { case verified, provisional, ineligible static func assess(station: FuelStation, fuel: FuelType, baseline: FuelStation?, now: Date = Date()) -> Self { FuelPriceFreshness.comparisonConfidence(station: station, fuel: fuel, baseline: baseline, now: now) } var warningText: String? { switch self { case .verified: return nil case .provisional: return String(localized: "Price not individually dated — saving is an estimate.") case .ineligible: return String(localized: "Price age unavailable or too old — not a Best Value recommendation.") } } } static func comparisonWarning(station: FuelStation, fuel: FuelType, baseline: FuelStation?, now: Date = Date()) -> String? { let confidence = comparisonConfidence(station: station, fuel: fuel, baseline: baseline, now: now) guard confidence == .provisional, let baseline else { return confidence.warningText } let candidate = assessment(for: station, fuel: fuel, now: now) let reference = assessment(for: baseline, fuel: fuel, now: now) let candidateNeedsWarning = candidate.confidence != .verified || candidate.status != .fresh let cause = candidateNeedsWarning ? candidate : reference if cause.source == .stationWide { let format = candidateNeedsWarning ? String(localized: "Estimated saving · %@ price not individually dated") : String(localized: "Estimated saving · closest eligible station's %@ price not individually dated") return String(format: format, fuel.displayName) } let format = candidateNeedsWarning ? String(localized: "Estimated saving · %@ price over 7 days old") : String(localized: "Estimated saving · closest eligible station's %@ price over 7 days old") return String(format: format, fuel.displayName) } static func comparisonConfidence(station: FuelStation, fuel: FuelType, baseline: FuelStation?, now: Date = Date()) -> ComparisonConfidence { guard let baseline, let candidatePrice = station.prices[fuel], candidatePrice.isFinite, candidatePrice > 0, let baselinePrice = baseline.prices[fuel], baselinePrice.isFinite, baselinePrice > 0 else { return .ineligible } let candidate = assessment(for: station, fuel: fuel, now: now) let reference = assessment(for: baseline, fuel: fuel, now: now) guard candidate.isEligibleForBestValue, reference.isEligibleForBestValue else { return .ineligible } if candidate.confidence == .verified, reference.confidence == .verified, candidate.status == .fresh, reference.status == .fresh { return .verified } return .provisional } private static func unknown() -> Assessment { Assessment(source: .unknown, status: .unknown, age: nil, confidence: .unavailable, recommendationEligibility: .ineligible, warning: true) } private static func invalid(source: Source = .unknown) -> Assessment { Assessment(source: source, status: .invalid, age: nil, confidence: .unavailable, recommendationEligibility: .ineligible, warning: true) } private static func assess(timestamp: TimeInterval, source: Source, now: Date) -> Assessment { let age = now.timeIntervalSince1970 - timestamp guard age >= -futureTolerance else { return Assessment(source: source, status: .invalid, age: nil, confidence: .unavailable, recommendationEligibility: .ineligible, warning: true) } let boundedAge = max(0, age) let status: Status = boundedAge > staleAge ? .stale : boundedAge > warningAge ? .warning : .fresh let confidence: Confidence = source == .gradeSpecific ? .verified : .provisional let eligible = status == .fresh || (source == .gradeSpecific && status == .warning) return Assessment(source: source, status: status, age: boundedAge, confidence: confidence, recommendationEligibility: eligible ? .eligible : .ineligible, warning: source != .gradeSpecific || status != .fresh) } }