import Foundation /// Pre-login public store locator — states/cities/stores-by-location, and /// store detail. Always talks to `pedifoods.com.br` (the BFF), never /// `atomenta.com.br` directly. See /// docs/plans/app-migrate-atomenta-calls-to-pedifoods-bff.md. /// /// Deliberately does not go through ApiService.sendEnvelope — that treats /// any session-expired-shaped response as the *customer* session expiring /// (clears TokenStore, forces logout). A guest-session 401 here means the /// short-lived guest JWT expired and must be silently refreshed instead. final class PublicLocationService: @unchecked Sendable { static let shared = PublicLocationService() private let client: ApiClient private let guestSession: GuestSessionService init(client: ApiClient = ApiClient(), guestSession: GuestSessionService = .shared) { self.client = client self.guestSession = guestSession } /// States + their cities in one call — keyed by UF, e.g. `{"SP": [...]}`. func fetchLocations() async throws -> PublicLocationsResult { try await sendGuestAuthed(path: "/api/public/locations") } func fetchStores(state: String, city: String) async throws -> [PublicStoreListItem] { let query = [ URLQueryItem(name: "state", value: state), URLQueryItem(name: "city", value: city) ] return try await sendGuestAuthed(path: "/api/public/stores/by-location", query: query) } /// No guest token — this route is fully public/unauthenticated per the doc. /// /// `state`/`city` are optional on the server and are what let it resolve /// the city centroid the store `distance` is measured from; without them /// the response carries `distance: 0` (see /// docs/plans/public-store-distance-consumer.md). Defaults to whatever the /// visitor picked in "ENTREGAR EM:". func fetchStoreDetail( identifier: String, state: String? = GuestLocationStore.shared.selectedState, city: String? = GuestLocationStore.shared.selectedCity ) async throws -> PublicStoreDetail { let encodedIdentifier = identifier.addingPercentEncoding(withAllowedCharacters: .urlPathAllowed) ?? identifier var query: [URLQueryItem] = [] if let state, state.isEmpty == false { query.append(URLQueryItem(name: "state", value: state)) } if let city, city.isEmpty == false { query.append(URLQueryItem(name: "city", value: city)) } let req = ApiRequest( path: "/api/public/store/\(encodedIdentifier)", method: "GET", module: .none, requiresAuth: false, queryItems: query, baseURLOverride: ApiConfig.pediFoodsBFFURL ) let envelope: ApiEnvelope = try await client.send(req) guard envelope.error == false, let result = envelope.result else { throw NetworkError.invalidResponse } return result } /// No guest token — same open pattern as fetchStoreDetail. Reuses the /// existing StoreCatalogCategory/StoreCatalogProduct models directly: /// the public response is the same category+products shape (with extra /// computed inventory/stockStatus fields the decoder just ignores). func fetchStoreProducts(storeId: String) async throws -> [StoreCatalogCategory] { let encodedStoreId = storeId.addingPercentEncoding(withAllowedCharacters: .urlPathAllowed) ?? storeId let req = ApiRequest( path: "/api/public/store/\(encodedStoreId)/products", method: "GET", module: .none, requiresAuth: false, baseURLOverride: ApiConfig.pediFoodsBFFURL ) let envelope: ApiEnvelope<[StoreCatalogCategory]> = try await client.send(req) guard envelope.error == false, let result = envelope.result else { throw NetworkError.invalidResponse } return result } private func sendGuestAuthed(path: String, query: [URLQueryItem] = []) async throws -> T { let token = try await guestSession.validToken() do { return try await performGuestRequest(path: path, query: query, token: token) } catch let error as NetworkError where isGuestSessionExpired(error) { let refreshed = try await guestSession.invalidateAndRefresh() return try await performGuestRequest(path: path, query: query, token: refreshed) } } private func performGuestRequest(path: String, query: [URLQueryItem], token: String) async throws -> T { let req = ApiRequest( path: path, method: "GET", module: .none, requiresAuth: false, queryItems: query, baseURLOverride: ApiConfig.pediFoodsBFFURL, customBearerToken: token ) let envelope: ApiEnvelope = try await client.send(req) guard envelope.error == false, let result = envelope.result else { throw NetworkError.invalidResponse } return result } private func isGuestSessionExpired(_ error: NetworkError) -> Bool { switch error { case .unauthorized: return true case .httpError(let statusCode, _): return statusCode == 401 default: return false } } }