GeocodingService.java

package org.petify.backend.services;

import org.petify.backend.dto.GeolocationResponse;

import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import lombok.RequiredArgsConstructor;
import org.springframework.cache.annotation.Cacheable;
import org.springframework.stereotype.Service;

import java.io.IOException;
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.net.http.HttpTimeoutException;
import java.nio.charset.StandardCharsets;
import java.time.Duration;
import java.util.ArrayList;
import java.util.List;

@Service
@RequiredArgsConstructor
public class GeocodingService {

    private static final String USER_AGENT = "Petify-App/1.0";
    private static final String POLAND_SUFFIX = ", Poland";
    private static final String ACCEPT_JSON = "application/json";
    private static final Duration CONNECT_TIMEOUT = Duration.ofSeconds(5);
    private static final Duration REQUEST_TIMEOUT = Duration.ofSeconds(10);
    private static final String COUNTRY_POLAND = "Poland";

    private final ObjectMapper objectMapper = new ObjectMapper();

    private HttpResponse<String> executeHttpRequest(String url) throws IOException, InterruptedException {
        HttpClient client = HttpClient.newBuilder()
                .connectTimeout(CONNECT_TIMEOUT)
                .build();

        HttpRequest request = HttpRequest.newBuilder()
                .uri(URI.create(url))
                .timeout(REQUEST_TIMEOUT)
                .header("Accept", ACCEPT_JSON)
                .header("User-Agent", USER_AGENT)
                .GET()
                .build();

        HttpResponse<String> response = client.send(request, HttpResponse.BodyHandlers.ofString());

        if (response.statusCode() != 200) {
            throw new RuntimeException("Request failed with status: " + response.statusCode());
        }

        return response;
    }

    private String extractCityName(JsonNode address) {
        if (address == null) {
            return "";
        }

        String[] cityFields = {"city", "town", "village", "municipality"};
        for (String field : cityFields) {
            if (address.has(field)) {
                return address.get(field).asText();
            }
        }
        return "";
    }

    private AddressInfo extractAddressInfo(JsonNode address) {
        String country = address != null && address.has("country")
                ? address.get("country").asText() : COUNTRY_POLAND;
        String state = address != null && address.has("state")
                ? address.get("state").asText() : "";

        return new AddressInfo(country, state);
    }

    @Cacheable(value = "geocoding", key = "#cityName.toLowerCase().strip()")
    public GeolocationResponse getCoordinatesForCity(String cityName) throws Exception {
        if (cityName == null || cityName.trim().isEmpty()) {
            throw new IllegalArgumentException("City name cannot be empty");
        }

        String encodedCity = URLEncoder.encode(cityName.trim() + POLAND_SUFFIX, StandardCharsets.UTF_8);
        String nominatimUrl = "https://nominatim.openstreetmap.org/search?q=" + encodedCity
                + "&format=json&limit=1&addressdetails=1";

        try {
            HttpResponse<String> response = executeHttpRequest(nominatimUrl);
            JsonNode results = objectMapper.readTree(response.body());

            if (results.isEmpty()) {
                throw new RuntimeException("No coordinates found for city: " + cityName);
            }

            JsonNode firstResult = results.get(0);
            double lat = firstResult.get("lat").asDouble();
            double lon = firstResult.get("lon").asDouble();

            JsonNode address = firstResult.get("address");
            AddressInfo addressInfo = extractAddressInfo(address);
            String displayName = firstResult.has("display_name")
                    ? firstResult.get("display_name").asText() : cityName;

            return new GeolocationResponse(cityName.trim(), lat, lon,
                    addressInfo.country(), addressInfo.state(), displayName);

        } catch (HttpTimeoutException e) {
            throw new RuntimeException("Geocoding request timed out for city: " + cityName, e);
        } catch (IOException | InterruptedException e) {
            Thread.currentThread().interrupt();
            throw new RuntimeException("Error while getting coordinates for city: " + cityName, e);
        }
    }

    @Cacheable(value = "city-suggestions", key = "#query.toLowerCase().strip()")
    public List<GeolocationResponse> searchCities(String query) throws Exception {
        if (query == null || query.trim().isEmpty()) {
            return new ArrayList<>();
        }

        String encodedQuery = URLEncoder.encode(query.trim() + POLAND_SUFFIX, StandardCharsets.UTF_8);
        String nominatimUrl = "https://nominatim.openstreetmap.org/search?q=" + encodedQuery
                + "&format=json&limit=5&addressdetails=1&class=place&type=city,town,village";

        try {
            HttpResponse<String> response = executeHttpRequest(nominatimUrl);
            JsonNode results = objectMapper.readTree(response.body());
            List<GeolocationResponse> cities = new ArrayList<>();

            for (JsonNode result : results) {
                double lat = result.get("lat").asDouble();
                double lon = result.get("lon").asDouble();

                JsonNode address = result.get("address");
                String cityName = extractCityName(address);

                if (!cityName.isEmpty()) {
                    AddressInfo addressInfo = extractAddressInfo(address);
                    String displayName = result.has("display_name")
                            ? result.get("display_name").asText() : cityName;

                    cities.add(new GeolocationResponse(cityName, lat, lon,
                            addressInfo.country(), addressInfo.state(), displayName));
                }
            }

            return cities;

        } catch (HttpTimeoutException e) {
            throw new RuntimeException("City search request timed out for query: " + query, e);
        } catch (IOException | InterruptedException e) {
            Thread.currentThread().interrupt();
            throw new RuntimeException("Error while searching cities: " + query, e);
        }
    }

    public boolean isCityValid(String cityName) {
        try {
            GeolocationResponse result = getCoordinatesForCity(cityName);
            return result != null && COUNTRY_POLAND.equalsIgnoreCase(result.country());
        } catch (Exception e) {
            return false;
        }
    }

    private record AddressInfo(String country, String state) {}
}