package com.estateunified.panel import com.google.gson.Gson import com.google.gson.reflect.TypeToken import java.nio.charset.StandardCharsets import java.nio.file.Files import java.nio.file.Path import java.nio.file.StandardCopyOption import java.util.UUID internal data class ListingPhotoDto( val id: String = UUID.randomUUID().toString().take(12), val listingId: String, val ownerUserId: String, val storedName: String, val originalName: String = "", val mime: String = "image/jpeg", val sizeBytes: Long = 0, val width: Int = 0, val height: Int = 0, val isCover: Boolean = false, val captionRu: String = "", val sortOrder: Int = 0, val uploadedAtIso: String = java.time.format.DateTimeFormatter.ISO_LOCAL_DATE_TIME.format(java.time.LocalDateTime.now()), ) internal class ListingPhotosStore( private val gson: Gson, private val metaFile: Path, private val photosRoot: Path, ) { private val lock = Any() fun absolutePath(): String = metaFile.toAbsolutePath().normalize().toString() fun photosDir(): Path = photosRoot fun all(): List = synchronized(lock) { load() } fun forListing(listingId: String): List = synchronized(lock) { load().filter { it.listingId == listingId }.sortedWith( compareByDescending { it.isCover }.thenBy { it.sortOrder }.thenBy { it.uploadedAtIso }, ) } fun get(id: String): ListingPhotoDto? = synchronized(lock) { load().firstOrNull { it.id == id } } fun saveBytes( listingId: String, ownerUserId: String, originalName: String, mime: String, bytes: ByteArray, ): ListingPhotoDto { val ext = extOf(originalName, mime).lowercase() val storedName = "${java.util.UUID.randomUUID().toString().take(16)}.$ext" val dir = photosRoot.resolve(safeSegment(listingId)) Files.createDirectories(dir) val target = dir.resolve(storedName) Files.write(target, bytes) synchronized(lock) { val list = load().toMutableList() val makeCover = list.none { it.listingId == listingId } val maxOrder = list.filter { it.listingId == listingId }.maxOfOrNull { it.sortOrder } ?: -1 val dto = ListingPhotoDto( listingId = listingId, ownerUserId = ownerUserId, storedName = storedName, originalName = originalName, mime = mime, sizeBytes = bytes.size.toLong(), isCover = makeCover, sortOrder = maxOrder + 1, ) list.add(0, dto) persist(list) return dto } } fun delete(id: String): Boolean = synchronized(lock) { val list = load().toMutableList() val idx = list.indexOfFirst { it.id == id } if (idx < 0) return@synchronized false val cur = list[idx] list.removeAt(idx) if (cur.isCover) { list.firstOrNull { it.listingId == cur.listingId }?.let { repl -> val i = list.indexOf(repl) list[i] = repl.copy(isCover = true) } } runCatching { Files.deleteIfExists(photosRoot.resolve(safeSegment(cur.listingId)).resolve(cur.storedName)) } persist(list) true } fun setCover(id: String): ListingPhotoDto? = synchronized(lock) { val list = load().toMutableList() val target = list.firstOrNull { it.id == id } ?: return@synchronized null val updated = list.map { when { it.id == id -> it.copy(isCover = true) it.listingId == target.listingId -> it.copy(isCover = false) else -> it } }.toMutableList() persist(updated) updated.first { it.id == id } } fun reorder(listingId: String, idsInOrder: List): List = synchronized(lock) { val list = load().toMutableList() val orderMap = idsInOrder.withIndex().associate { (i, v) -> v to i } val updated = list.map { if (it.listingId == listingId && orderMap.containsKey(it.id)) { it.copy(sortOrder = orderMap.getValue(it.id)) } else { it } }.toMutableList() persist(updated) updated.filter { it.listingId == listingId } } fun bytesOf(listingId: String, storedName: String): ByteArray? { val path = photosRoot.resolve(safeSegment(listingId)).resolve(safeSegment(storedName)) if (!Files.isRegularFile(path)) return null return runCatching { Files.readAllBytes(path) }.getOrNull() } fun mimeFor(storedName: String): String { val lc = storedName.lowercase() return when { lc.endsWith(".png") -> "image/png" lc.endsWith(".webp") -> "image/webp" lc.endsWith(".gif") -> "image/gif" lc.endsWith(".heic") -> "image/heic" else -> "image/jpeg" } } private fun load(): MutableList { if (!Files.isRegularFile(metaFile)) return mutableListOf() val text = Files.readString(metaFile, StandardCharsets.UTF_8) if (text.isBlank()) return mutableListOf() return runCatching { gson.fromJson>(text, listType) ?: mutableListOf() }.getOrElse { mutableListOf() } } private fun persist(list: List) { Files.createDirectories(metaFile.toAbsolutePath().parent) val tmp = metaFile.resolveSibling(metaFile.fileName.toString() + ".tmp") Files.writeString(tmp, gson.toJson(list), StandardCharsets.UTF_8) Files.move(tmp, metaFile, StandardCopyOption.REPLACE_EXISTING, StandardCopyOption.ATOMIC_MOVE) } private fun extOf(originalName: String, mime: String): String { val dot = originalName.lastIndexOf('.') if (dot > 0 && dot < originalName.length - 1) { val ext = originalName.substring(dot + 1).lowercase() if (ext.length in 2..5 && ext.all { it.isLetterOrDigit() }) return ext } return when (mime.lowercase()) { "image/png" -> "png" "image/webp" -> "webp" "image/gif" -> "gif" "image/heic" -> "heic" else -> "jpg" } } private fun safeSegment(s: String): String = s.replace(Regex("[^A-Za-z0-9._-]"), "_").take(120) companion object { private val listType = object : TypeToken>() {}.type } }