package com.parkplants.data.repository import android.content.Context import com.parkplants.data.api.ApiService import com.google.gson.Gson import com.parkplants.BuildConfig import com.parkplants.data.api.models.CreatePlantRequest import com.parkplants.data.api.models.CreatePlantTypeRequest import com.parkplants.data.api.models.Nursery import com.parkplants.data.api.models.OverlayConfig import com.parkplants.data.api.models.PlantType import com.parkplants.data.db.dao.CachedNurseryDao import com.parkplants.data.db.dao.CachedPlantTypeDao import com.parkplants.data.db.dao.PendingPlantDao import com.parkplants.data.db.dao.PlantTypeColorDao import com.parkplants.data.db.entities.CachedNursery import com.parkplants.data.db.entities.CachedPlantType import com.parkplants.data.db.entities.PendingPlant import com.parkplants.data.db.entities.PlantTypeColor import android.util.Log import dagger.hilt.android.qualifiers.ApplicationContext import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.map import okhttp3.MediaType.Companion.toMediaType import okhttp3.MultipartBody import okhttp3.OkHttpClient import okhttp3.Request import okhttp3.RequestBody.Companion.asRequestBody import java.io.File import javax.inject.Inject import javax.inject.Singleton @Singleton class PlantRepository @Inject constructor( private val api: ApiService, private val okHttpClient: OkHttpClient, private val pendingPlantDao: PendingPlantDao, private val cachedPlantTypeDao: CachedPlantTypeDao, private val cachedNurseryDao: CachedNurseryDao, private val plantTypeColorDao: PlantTypeColorDao, @ApplicationContext private val context: Context, ) { // ── Plant Types ────────────────────────────────────────────────────────── /** Возвращает из кеша немедленно (офлайн-first). */ suspend fun getCachedPlantTypes(): List = cachedPlantTypeDao.getAll().map { PlantType(it.id, it.name, null, false) } /** Фоновый рефреш из сети — обновляет кеш. Не бросает исключений. */ suspend fun refreshPlantTypesFromNetwork() { try { val remote = api.getPlantTypes() cachedPlantTypeDao.replaceAll(remote.map { CachedPlantType(it.id, it.name) }) } catch (_: Exception) { // Нет сети — кеш остаётся как есть } } suspend fun searchPlantTypes(prefix: String): List { val cached = if (prefix.isBlank()) cachedPlantTypeDao.getAll() else cachedPlantTypeDao.searchByPrefix(prefix) return cached.map { PlantType(it.id, it.name, null, false) } } suspend fun createPlantType(name: String, description: String?): Result = try { val result = api.createPlantType(CreatePlantTypeRequest(name, description)) // Добавляем в кеш cachedPlantTypeDao.insertAll(listOf(CachedPlantType(result.id, result.name))) Result.success(result) } catch (e: Exception) { Result.failure(e) } // ── Nurseries ──────────────────────────────────────────────────────────── /** Загружает питомники с сервера и кеширует. */ suspend fun getCachedNurseries(): List = cachedNurseryDao.getAll().map { Nursery(it.id, it.name) } suspend fun syncNurseries() { try { val remote = api.getNurseries() cachedNurseryDao.replaceAll(remote.map { CachedNursery(it.id, it.name) }) } catch (_: Exception) { // Офлайн — оставляем кеш как есть } } suspend fun searchNurseries(prefix: String): List { val cached = if (prefix.isBlank()) cachedNurseryDao.getAll() else cachedNurseryDao.searchByPrefix(prefix) return cached.map { Nursery(it.id, it.name) } } // ── Plant Type Colors ──────────────────────────────────────────────────── fun observePlantTypeColors(): Flow> = plantTypeColorDao.observeAll().map { list -> list.associate { it.plantTypeId to it.colorHex } } suspend fun setPlantTypeColor(plantTypeId: Int, colorHex: String) { plantTypeColorDao.upsert(PlantTypeColor(plantTypeId, colorHex)) } // ── Plants ─────────────────────────────────────────────────────────────── fun observePendingPlants(): Flow> = pendingPlantDao.observeAll() fun observeUnsyncedCount(): Flow = pendingPlantDao.observeUnsyncedCount() suspend fun savePlantLocally(plant: PendingPlant): Long = pendingPlantDao.insert(plant) suspend fun syncPlant(plant: PendingPlant) { try { var photoUrl = plant.photoUrl if (photoUrl == null && plant.localPhotoPath != null) { val file = File(plant.localPhotoPath) if (file.exists()) { val part = MultipartBody.Part.createFormData( "photo", file.name, file.asRequestBody("image/jpeg".toMediaType()), ) photoUrl = api.uploadPhoto(part).url pendingPlantDao.updatePhotoUrl(plant.id, photoUrl) } } api.createPlant( CreatePlantRequest( plantTypeId = plant.plantTypeId, lat = plant.lat, lng = plant.lng, plantedAt = plant.plantedAt, nursery = plant.nursery, photoUrl = photoUrl, ) ) pendingPlantDao.markSynced(plant.id) } catch (e: Exception) { pendingPlantDao.markFailed(plant.id, e.message ?: "Unknown error") throw e } } suspend fun syncAll() { val unsynced = pendingPlantDao.getUnsynced() unsynced.forEach { plant -> try { syncPlant(plant) } catch (_: Exception) { } } } suspend fun deleteSynced() = pendingPlantDao.deleteSynced() // ── Overlay ────────────────────────────────────────────────────────────── /** * Loads overlay config + downloads and caches the image file. * Returns OverlayConfig with a LOCAL file path for the image (or null if no overlay). * Works offline — returns cached data if network unavailable. */ suspend fun loadOverlay(): OverlayConfig? { val configFile = File(context.cacheDir, "overlay_config.json") val imageFile = File(context.cacheDir, "overlay_image.cache") val gson = Gson() return try { Log.d("Overlay", "loadOverlay: fetching config from ${BuildConfig.BASE_URL}/api/overlay") // Use OkHttp directly to avoid Retrofit+Gson nullable deserialization issues val response = okHttpClient.newCall( Request.Builder().url("${BuildConfig.BASE_URL}/api/overlay").get().build() ).execute() val bodyStr = response.body?.string()?.trim() Log.d("Overlay", "loadOverlay: response code=${response.code} body=$bodyStr") if (!response.isSuccessful || bodyStr.isNullOrEmpty() || bodyStr == "null") { Log.d("Overlay", "loadOverlay: no overlay on server, clearing cache") configFile.delete() imageFile.delete() return null } val cfg = try { gson.fromJson(bodyStr, OverlayConfig::class.java) } catch (e: Exception) { Log.e("Overlay", "loadOverlay: failed to parse config: $e") null } ?: return null Log.d("Overlay", "loadOverlay: config parsed ok, url=${cfg.url}") // Check if image changed (compare URL to cached URL) val cachedUrl = if (configFile.exists()) { try { gson.fromJson(configFile.readText(), OverlayConfig::class.java)?.url } catch (_: Exception) { null } } else null val fullImageUrl = "${BuildConfig.BASE_URL}${cfg.url}" if (cachedUrl != cfg.url || !imageFile.exists()) { Log.d("Overlay", "loadOverlay: downloading image from $fullImageUrl") val imgResponse = okHttpClient.newCall( Request.Builder().url(fullImageUrl).build() ).execute() Log.d("Overlay", "loadOverlay: image response code=${imgResponse.code}") if (imgResponse.isSuccessful) { imgResponse.body?.bytes()?.let { bytes -> Log.d("Overlay", "loadOverlay: image downloaded, size=${bytes.size} bytes") imageFile.writeBytes(bytes) } } else { Log.e("Overlay", "loadOverlay: image download failed") } } else { Log.d("Overlay", "loadOverlay: image cache hit") } // Save config to cache configFile.writeText(gson.toJson(cfg)) val result = if (imageFile.exists()) cfg.copy(url = imageFile.absolutePath) else null Log.d("Overlay", "loadOverlay: returning result=${result?.url}") result } catch (e: Exception) { Log.e("Overlay", "loadOverlay: network error: $e") // Offline — try to use cached config+image if (configFile.exists() && imageFile.exists()) { Log.d("Overlay", "loadOverlay: using cached data") try { val cfg = gson.fromJson(configFile.readText(), OverlayConfig::class.java) cfg?.copy(url = imageFile.absolutePath) } catch (_: Exception) { null } } else null } } }