// FuelPriceProvider.swift — data-source seam for FuelBoard. // // The official UK Fuel Finder API (api.fuelfinder.service.gov.uk) is the live // source for station-level prices, but it requires GOV.UK One Login + OAuth 2.0 // client credentials. For the scaffold we ship SampleFuelProvider (realistic // stations around a location, works offline), plus the FuelFinderProvider // skeleton with the exact integration steps documented inline so wiring the // live API later is a drop-in: change `activeProvider` to `.fuelFinder`. import Foundation // MARK: - Provider protocol protocol FuelPriceProviding { /// Fetch stations with prices. `location` may be nil (sort by price only). /// `radiusKM` nil = full dataset (device filters); set = focused server-side /// radius (alert path). Throws on failure so callers can fall back. func fetchStations(near lat: Double?, lng: Double?, fuel: FuelType, radiusKM: Double?) async throws -> [FuelStation] } enum FuelPriceProvider { /// Default: the live chain — public GitHub mirror (primary, off-LAN) → /// bundled dump/cache fallback in callers. No private LAN fallback ships in /// the app binary. static let active: FuelPriceProviding = LiveChainProvider() /// Decodes a mirror payload into stations, applying the defensive price /// band. Internal so the unit-test target can exercise the guard. static func decodeStations(from data: Data) throws -> [FuelStation] { let payload = try JSONDecoder().decode(MirrorResponse.self, from: data) return payload.stations.map { relay in FuelStation( id: relay.id ?? relay.name ?? UUID().uuidString, name: (relay.name ?? "Unknown").sanitizedStationTitle, brand: relay.brand ?? "", address: relay.address ?? "", postcode: relay.postcode ?? "", lat: relay.lat ?? 0, lng: relay.lng ?? 0, prices: relay.allPrices, priceUpdated: nil ) } } /// Price sanity band (pence/litre) — anything outside is mirror regression /// garbage and must never reach calculations (shared by the live decode /// and the history mirror decoder). static let priceBand: ClosedRange = 50...500 /// Maps mirror grade keys (E5/E10/DIESEL…) to FuelType with the defensive /// band guard. Shared by the mirror decode and the history mirror parser so /// both surfaces apply identical sanitisation. static func mapGrades(_ prices: [String: Double]) -> [FuelType: Double] { var result: [FuelType: Double] = [:] for (grade, value) in prices { guard priceBand.contains(value) else { continue } switch grade.uppercased() { case "E10": result[.e10] = value case "E5": result[.e5] = value case "DIESEL", "B7", "B7S", "B7P", "B10": result[.diesel] = value default: break } } return result } /// Decodes mirror envelope metadata (source, dataset update time) — the /// About section shows these so the user can see which data source is /// live and how fresh the GOV.UK data itself is. The fields are additive /// on the mirror, so older snapshots simply return nil values. static func decodeRelayMeta(from data: Data) -> RelayMeta? { guard let payload = try? JSONDecoder().decode(MirrorResponse.self, from: data) else { return nil } return RelayMeta( source: payload.source, stationCount: payload.stationsCount, dataUpdated: payload.dataUpdated ) } } // MARK: - Mirror payload envelope private struct MirrorResponse: Codable { /// Envelope metadata surfaced in Settings → About so the user can see /// which mirror source is live and how fresh the GOV.UK data is. let source: String? let stationsCount: Int? let dataUpdated: String? enum CodingKeys: String, CodingKey { case source, stations case stationsCount = "stations_count" case dataUpdated = "data_updated" } struct MirrorStation: Codable { let id: String? let name: String? let brand: String? let address: String? let postcode: String? let lat: Double? let lng: Double? let price: Double? let prices: [String: Double]? /// Map mirror grade keys (E5/E10/DIESEL) to FuelType for ALL fuels the /// station sells — one fetch populates every fuel tab. Defensive guard: /// any grade outside the 50–500 pence/litre band is dropped, so a mirror /// regression (e.g. the band being removed upstream) can't re-poison /// the nationwide cheapest reference with 1.3p / 1589p garbage. var allPrices: [FuelType: Double] { var result = FuelPriceProvider.mapGrades(prices ?? [:]) // Backwards-compat: mirror versions without `prices` still send `price`. if result.isEmpty, let price, FuelPriceProvider.priceBand.contains(price) { result[.e10] = price } return result } } let stations: [MirrorStation] } /// Envelope metadata from the mirror response — which data source is live /// (api/csv) and the freshest price-update timestamp the GOV.UK server /// itself reports for the dataset. Displayed in Settings → About. struct RelayMeta: Codable, Equatable { let source: String? let stationCount: Int? let dataUpdated: String? } // MARK: - Sample provider (offline fallback only) /// Representative stations spread across ENGLAND (one cluster per region) so /// the fallback works anywhere in the country — it is NOT tied to one town. /// Real UK-wide data comes from the public mirror (full-UK Fuel Finder CSV/API). /// Prices in pence/litre. struct SampleFuelProvider: FuelPriceProviding { func fetchStations(near lat: Double?, lng: Double?, fuel: FuelType, radiusKM: Double?) async throws -> [FuelStation] { try await Task.sleep(nanoseconds: 300_000_000) // simulate fetch return Self.sampleStations } static let sampleStations: [FuelStation] = [ // Yorkshire & the Humber (Halifax area) FuelStation(id: "y1", name: "Morrisons Halifax", brand: "Morrisons", address: "Haugh Shaw Road", postcode: "HX1 3TU", lat: 53.7265, lng: -1.8580, prices: [.e10: 137.9, .e5: 144.9, .diesel: 144.9], priceUpdated: nil), FuelStation(id: "y2", name: "Asda Leeds Crown Point", brand: "Asda", address: "Crown Point Road", postcode: "LS10 1ET", lat: 53.7800, lng: -1.5300, prices: [.e10: 136.9, .e5: 143.9, .diesel: 143.9], priceUpdated: nil), FuelStation(id: "y3", name: "Tesco Express York", brand: "Tesco", address: "Fulford Road", postcode: "YO10 4AB", lat: 53.9500, lng: -1.0800, prices: [.e10: 138.9, .e5: 146.9, .diesel: 145.9], priceUpdated: nil), // North West FuelStation(id: "nw1", name: "Sainsbury's Manchester", brand: "Sainsbury's", address: "Regent Road", postcode: "M5 3QH", lat: 53.4700, lng: -2.2800, prices: [.e10: 139.9, .e5: 147.9, .diesel: 146.9], priceUpdated: nil), FuelStation(id: "nw2", name: "Shell Liverpool", brand: "Shell", address: "Rice Lane", postcode: "L9 1AD", lat: 53.4400, lng: -2.9600, prices: [.e10: 142.9, .e5: 149.9, .diesel: 148.9], priceUpdated: nil), // Midlands FuelStation(id: "m1", name: "Morrisons Birmingham", brand: "Morrisons", address: "Hagley Road", postcode: "B16 8NA", lat: 52.4700, lng: -1.9500, prices: [.e10: 137.9, .e5: 144.9, .diesel: 144.9], priceUpdated: nil), FuelStation(id: "m2", name: "Tesco Express Nottingham", brand: "Tesco", address: "Mansfield Road", postcode: "NG5 2DP", lat: 52.9900, lng: -1.1500, prices: [.e10: 138.9, .e5: 146.9, .diesel: 145.9], priceUpdated: nil), // South East / London FuelStation(id: "se1", name: "Asda Wembley", brand: "Asda", address: "Great Central Way", postcode: "HA9 0DB", lat: 51.5500, lng: -0.2800, prices: [.e10: 136.9, .e5: 143.9, .diesel: 143.9], priceUpdated: nil), FuelStation(id: "se2", name: "BP Brighton", brand: "BP", address: "Lewes Road", postcode: "BN2 3QB", lat: 50.8300, lng: -0.1100, prices: [.e10: 140.9, .diesel: 147.9], priceUpdated: nil), // South West FuelStation(id: "sw1", name: "Esso Bristol", brand: "Esso", address: "Wells Road", postcode: "BS4 2PP", lat: 51.4400, lng: -2.5700, prices: [.e10: 141.9, .e5: 148.9, .diesel: 147.9], priceUpdated: nil), // North East FuelStation(id: "ne1", name: "Gulf Newcastle", brand: "Gulf", address: "Scotswood Road", postcode: "NE4 7AA", lat: 54.9700, lng: -1.6400, prices: [.e10: 143.9, .e5: 151.9, .diesel: 149.9], priceUpdated: nil), ] } // MARK: - Fuel Finder API provider (integration point) /// Live provider skeleton for the official GOV.UK Fuel Finder API. /// /// To activate: /// 1. Sign in at developer.fuel-finder.service.gov.uk with GOV.UK One Login, /// register an app, and generate client credentials (client_id + secret). /// 2. Implement token fetch: POST to the token endpoint with OAuth 2.0 /// client_credentials grant → access_token (expires in 1h). /// 3. GET prices with `Authorization: Bearer ` and decode the /// stations JSON into [FuelStation]. /// /// Endpoints (per gov.uk docs): /// base: https://api.fuelfinder.service.gov.uk /// auth: base + /v1/token /// prices: base + /v1/prices (full snapshot) /// price: base + /v1/prices/GB-12345 (single station) /// /// Then switch `FuelPriceProvider.active` to `FuelFinderProvider(...)`. struct FuelFinderProvider: FuelPriceProviding { let baseURL = URL(string: "https://api.fuelfinder.service.gov.uk")! let clientID: String let clientSecret: String func fetchStations(near lat: Double?, lng: Double?, fuel: FuelType, radiusKM: Double?) async throws -> [FuelStation] { // TODO: OAuth token → GET /v1/prices → map to FuelStation. // The live API requires authentication; see notes above. throw FuelProviderError.notImplemented } } enum FuelProviderError: LocalizedError { case notImplemented case relayUnavailable case mirrorUnavailable var errorDescription: String? { switch self { case .notImplemented: return "Live Fuel Finder API not wired yet — activate with GOV.UK One Login credentials." case .relayUnavailable: return "FuelBoard live data unavailable — showing cached/sample data." case .mirrorUnavailable: return "GitHub price mirror unreachable." } } }