import { BackendApiError, BackendErrorCode, BillingSummary, HealthCheckResponse, PurchaseProductId, ScanPlantResponse, SemanticSearchResponse, ServiceHealthResponse, SimulatedWebhookEvent, SimulatePurchaseResponse, SimulateWebhookResponse, } from './contracts'; import { getAuthToken } from './userIdentityService'; import { mockBackendService } from './mockBackendService'; import { CareInfo, Language } from '../../types'; const BACKEND_BASE_URL = (process.env.EXPO_PUBLIC_BACKEND_URL || process.env.EXPO_PUBLIC_PAYMENT_SERVER_URL || '').trim(); const REQUEST_TIMEOUT_MS = 15000; const mapHttpStatusToErrorCode = (status: number): BackendErrorCode => { if (status === 400) return 'BAD_REQUEST'; if (status === 401 || status === 403) return 'UNAUTHORIZED'; if (status === 402) return 'INSUFFICIENT_CREDITS'; if (status === 408 || status === 504) return 'TIMEOUT'; return 'PROVIDER_ERROR'; }; const buildBackendUrl = (path: string): string => { const base = BACKEND_BASE_URL.replace(/\/$/, ''); return `${base}${path}`; }; const parseMaybeJson = (value: string): Record | null => { if (!value) return null; try { return JSON.parse(value) as Record; } catch { return null; } }; const makeRequest = async ( path: string, options: { method: 'GET' | 'POST'; token: string; idempotencyKey?: string; body?: Record; }, ): Promise => { const controller = new AbortController(); const timeout = setTimeout(() => controller.abort(), REQUEST_TIMEOUT_MS); try { const headers: Record = { 'Content-Type': 'application/json', 'Authorization': `Bearer ${options.token}`, }; if (options.idempotencyKey) { headers['Idempotency-Key'] = options.idempotencyKey; } const response = await fetch(buildBackendUrl(path), { method: options.method, headers, body: options.body ? JSON.stringify(options.body) : undefined, signal: controller.signal, }); const rawText = await response.text(); const jsonPayload = parseMaybeJson(rawText); if (!response.ok) { const payloadCode = jsonPayload?.code; const code = typeof payloadCode === 'string' ? payloadCode as BackendErrorCode : mapHttpStatusToErrorCode(response.status); const payloadMessage = jsonPayload?.message; const message = typeof payloadMessage === 'string' ? payloadMessage : `Backend request failed with status ${response.status}.`; throw new BackendApiError(code, message, response.status, jsonPayload || undefined); } if (jsonPayload == null) { throw new BackendApiError('PROVIDER_ERROR', 'Backend returned invalid JSON.', 502); } return jsonPayload as T; } catch (error) { if (error instanceof BackendApiError) throw error; if (error instanceof Error && error.name === 'AbortError') { throw new BackendApiError('TIMEOUT', 'Backend request timed out.', 408); } throw new BackendApiError('PROVIDER_ERROR', 'Backend request failed unexpectedly.', 500, { detail: error instanceof Error ? error.message : String(error), }); } finally { clearTimeout(timeout); } }; export const backendApiClient = { getServiceHealth: async (): Promise => { if (!BACKEND_BASE_URL) { return { ok: true, uptimeSec: 0, timestamp: new Date().toISOString(), openAiConfigured: Boolean(process.env.EXPO_PUBLIC_OPENAI_API_KEY), dbReady: true, dbPath: 'in-app-mock-backend', stripeConfigured: Boolean(process.env.EXPO_PUBLIC_STRIPE_PUBLISHABLE_KEY), scanModel: (process.env.EXPO_PUBLIC_OPENAI_SCAN_MODEL || 'gpt-5').trim(), healthModel: (process.env.EXPO_PUBLIC_OPENAI_HEALTH_MODEL || process.env.EXPO_PUBLIC_OPENAI_SCAN_MODEL || 'gpt-5').trim(), }; } const token = await getAuthToken(); return makeRequest('/health', { method: 'GET', token, }); }, getBillingSummary: async (): Promise => { const token = await getAuthToken(); if (!BACKEND_BASE_URL) { return mockBackendService.getBillingSummary(token); } return makeRequest('/v1/billing/summary', { method: 'GET', token, }); }, scanPlant: async (params: { idempotencyKey: string; imageUri: string; language: Language; }): Promise => { const token = await getAuthToken(); if (!BACKEND_BASE_URL) { return mockBackendService.scanPlant({ userId: token, idempotencyKey: params.idempotencyKey, imageUri: params.imageUri, language: params.language, }); } return makeRequest('/v1/scan', { method: 'POST', token, idempotencyKey: params.idempotencyKey, body: { imageUri: params.imageUri, language: params.language, }, }); }, semanticSearch: async (params: { idempotencyKey: string; query: string; language: Language; }): Promise => { const token = await getAuthToken(); if (!BACKEND_BASE_URL) { return mockBackendService.semanticSearch({ userId: token, idempotencyKey: params.idempotencyKey, query: params.query, language: params.language, }); } return makeRequest('/v1/search/semantic', { method: 'POST', token, idempotencyKey: params.idempotencyKey, body: { query: params.query, language: params.language, }, }); }, runHealthCheck: async (params: { idempotencyKey: string; imageUri: string; language: Language; plantContext?: { name: string; botanicalName: string; careInfo: CareInfo; description?: string; }; }): Promise => { const token = await getAuthToken(); if (!BACKEND_BASE_URL) { return mockBackendService.healthCheck({ userId: token, idempotencyKey: params.idempotencyKey, imageUri: params.imageUri, language: params.language, plantContext: params.plantContext, }); } return makeRequest('/v1/health-check', { method: 'POST', token, idempotencyKey: params.idempotencyKey, body: { imageUri: params.imageUri, language: params.language, plantContext: params.plantContext, }, }); }, simulatePurchase: async (params: { idempotencyKey: string; productId: PurchaseProductId; }): Promise => { const token = await getAuthToken(); if (!BACKEND_BASE_URL) { return mockBackendService.simulatePurchase({ userId: token, idempotencyKey: params.idempotencyKey, productId: params.productId, }); } return makeRequest('/v1/billing/simulate-purchase', { method: 'POST', token, idempotencyKey: params.idempotencyKey, body: { productId: params.productId, }, }); }, simulateWebhook: async (params: { idempotencyKey: string; event: SimulatedWebhookEvent; payload?: { credits?: number }; }): Promise => { const token = await getAuthToken(); if (!BACKEND_BASE_URL) { return mockBackendService.simulateWebhook({ userId: token, idempotencyKey: params.idempotencyKey, event: params.event, payload: params.payload, }); } return makeRequest('/v1/billing/simulate-webhook', { method: 'POST', token, idempotencyKey: params.idempotencyKey, body: { event: params.event, payload: params.payload || {}, }, }); }, }; export const isInsufficientCreditsError = (error: unknown): boolean => { return error instanceof BackendApiError && error.code === 'INSUFFICIENT_CREDITS'; };