import { IdentificationResult, Language } from '../types'; import { resolveImageUri, tryResolveImageUri } from '../utils/imageUri'; import { getConfiguredApiBaseUrl } from '../utils/backendUrl'; import { backendApiClient } from './backend/backendApiClient'; import { BackendDatabaseEntry, isBackendApiError } from './backend/contracts'; import { createIdempotencyKey } from '../utils/idempotency'; import { getMockCatalog, searchMockCatalog } from './backend/mockCatalog'; export interface DatabaseEntry extends IdentificationResult { imageUri: string; imageStatus?: 'ok' | 'missing' | 'invalid'; categories: string[]; } interface SearchOptions { category?: string | null; limit?: number; } export type SemanticSearchStatus = 'success' | 'timeout' | 'provider_error' | 'no_results' | 'insufficient_credits'; export interface SemanticSearchResult { status: SemanticSearchStatus; results: DatabaseEntry[]; } const DEFAULT_SEARCH_LIMIT = 500; const hasConfiguredPlantBackend = (): boolean => Boolean( String( process.env.EXPO_PUBLIC_API_URL || process.env.EXPO_PUBLIC_BACKEND_URL || process.env.EXPO_PUBLIC_PAYMENT_SERVER_URL || '', ).trim(), ); const normalizeImageStatus = (status?: string, imageUri?: string): 'ok' | 'missing' | 'invalid' => { if (status === 'ok' || status === 'missing' || status === 'invalid') return status; const resolved = tryResolveImageUri(imageUri || ''); if (resolved) return 'ok'; return imageUri && imageUri.trim() ? 'invalid' : 'missing'; }; const mapBackendEntry = (entry: Partial & { imageUri?: string | null }): DatabaseEntry => { const imageStatus = normalizeImageStatus(entry.imageStatus, entry.imageUri || undefined); const strictImageUri = tryResolveImageUri(entry.imageUri || undefined); const imageUri = imageStatus === 'ok' ? (strictImageUri || resolveImageUri(entry.imageUri)) : (typeof entry.imageUri === 'string' ? entry.imageUri.trim() : ''); return { name: entry.name || '', botanicalName: entry.botanicalName || '', confidence: typeof entry.confidence === 'number' ? entry.confidence : 0, description: entry.description || '', careInfo: entry.careInfo || { waterIntervalDays: 7, light: 'Unknown', temp: 'Unknown' }, imageUri, imageStatus, categories: Array.isArray(entry.categories) ? entry.categories : [], }; }; export const PlantDatabaseService = { async getAllPlants(lang: Language): Promise { if (!hasConfiguredPlantBackend()) { return getMockCatalog(lang).map(mapBackendEntry); } try { const response = await fetch(`${getConfiguredApiBaseUrl()}/plants?lang=${lang}`); if (!response.ok) throw new Error('Network response was not ok'); const data = await response.json(); if (!Array.isArray(data)) return []; return data.map(mapBackendEntry); } catch (e) { console.error('Failed to fetch plants', e); return []; } }, async searchPlants(query: string, lang: Language, options: SearchOptions = {}): Promise { const { category, limit = DEFAULT_SEARCH_LIMIT } = options; if (!hasConfiguredPlantBackend()) { let results = searchMockCatalog(query || '', lang, limit); if (category) { results = results.filter(r => r.categories.includes(category)); } return results.map(mapBackendEntry); } const url = new URL(`${getConfiguredApiBaseUrl()}/plants`); url.searchParams.append('lang', lang); if (query) url.searchParams.append('q', query); if (category) url.searchParams.append('category', category); if (limit) url.searchParams.append('limit', limit.toString()); try { const response = await fetch(url.toString()); if (!response.ok) throw new Error('Network response was not ok'); const data = await response.json(); if (!Array.isArray(data)) return []; return data.map(mapBackendEntry); } catch (e) { console.error('Failed to search plants', e); return []; } }, async semanticSearchDetailed(query: string, lang: Language): Promise { const idempotencyKey = createIdempotencyKey(`semantic-${query}-${lang}`); try { const response = await backendApiClient.semanticSearch({ query, language: lang, idempotencyKey, }); const results: DatabaseEntry[] = (response.results as BackendDatabaseEntry[]).map(mapBackendEntry); return { status: results.length > 0 ? 'success' : 'no_results', results }; } catch (error) { if (isBackendApiError(error)) { if (error.code === 'INSUFFICIENT_CREDITS') { return { status: 'insufficient_credits', results: [] }; } return { status: 'provider_error', results: [] }; } return { status: 'timeout', results: [] }; } }, };