package com.estateunified.panel import com.google.gson.Gson import com.google.gson.JsonParser import com.google.gson.reflect.TypeToken import java.net.URI import java.net.URLEncoder import java.net.http.HttpClient import java.net.http.HttpRequest import java.net.http.HttpResponse import java.nio.charset.StandardCharsets import java.nio.file.Files import java.nio.file.Path import java.nio.file.StandardCopyOption import java.time.Duration internal data class GeocodeResultDto( val query: String, val lat: Double, val lon: Double, val displayName: String = "", val source: String = "nominatim", val cachedAtIso: String = java.time.format.DateTimeFormatter.ISO_LOCAL_DATE_TIME.format(java.time.LocalDateTime.now()), ) internal class GeocodeService( private val gson: Gson, private val cacheFile: Path, private val enabled: Boolean = true, ) { private val lock = Any() private var lastRequestEpochMs: Long = 0L private val http: HttpClient by lazy { HttpClient.newBuilder() .connectTimeout(Duration.ofSeconds(6)) .build() } fun lookup(query: String, bias: String = "Armenia"): GeocodeResultDto? { val clean = query.trim() if (clean.isEmpty()) return null val key = canonicalKey(clean, bias) synchronized(lock) { val cached = loadCache()[key] if (cached != null) return cached } if (!enabled) return null val result = fetchFromNominatim(clean, bias) ?: return null synchronized(lock) { val map = loadCache().toMutableMap() map[key] = result persistCache(map) } return result } private fun fetchFromNominatim(query: String, bias: String): GeocodeResultDto? { synchronized(lock) { val now = System.currentTimeMillis() val waitMs = (lastRequestEpochMs + 1100) - now if (waitMs > 0) { runCatching { Thread.sleep(waitMs) } } lastRequestEpochMs = System.currentTimeMillis() } val composed = if (bias.isBlank()) query else "$query, $bias" val url = "https://nominatim.openstreetmap.org/search" + "?q=" + URLEncoder.encode(composed, StandardCharsets.UTF_8) + "&format=json&limit=1&addressdetails=0" val request = HttpRequest.newBuilder() .uri(URI.create(url)) .header("User-Agent", "EstateUnified/0.1 (panel)") .header("Accept-Language", "ru,en,hy") .timeout(Duration.ofSeconds(10)) .GET() .build() return runCatching { val resp = http.send(request, HttpResponse.BodyHandlers.ofString(StandardCharsets.UTF_8)) if (resp.statusCode() != 200) return@runCatching null val arr = JsonParser.parseString(resp.body()).asJsonArray if (arr.size() == 0) return@runCatching null val first = arr[0].asJsonObject val lat = first.get("lat")?.asString?.toDoubleOrNull() ?: return@runCatching null val lon = first.get("lon")?.asString?.toDoubleOrNull() ?: return@runCatching null val display = first.get("display_name")?.asString.orEmpty() GeocodeResultDto(query = query, lat = lat, lon = lon, displayName = display) }.getOrNull() } private fun canonicalKey(query: String, bias: String): String = (query.lowercase().trim() + "|" + bias.lowercase().trim()).take(300) private fun loadCache(): Map { if (!Files.isRegularFile(cacheFile)) return emptyMap() val text = Files.readString(cacheFile, StandardCharsets.UTF_8) if (text.isBlank()) return emptyMap() return runCatching { gson.fromJson>(text, mapType) ?: emptyMap() }.getOrElse { emptyMap() } } private fun persistCache(map: Map) { Files.createDirectories(cacheFile.toAbsolutePath().parent) val tmp = cacheFile.resolveSibling(cacheFile.fileName.toString() + ".tmp") Files.writeString(tmp, gson.toJson(map), StandardCharsets.UTF_8) Files.move(tmp, cacheFile, StandardCopyOption.REPLACE_EXISTING, StandardCopyOption.ATOMIC_MOVE) } private companion object { val mapType = object : TypeToken>() {}.type } }