package de.avatic.lcc.service.api;

import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import de.avatic.lcc.model.azuremaps.route.RouteDirectionsResponse;
import de.avatic.lcc.model.db.nodes.Distance;
import de.avatic.lcc.model.db.nodes.DistanceMatrixState;
import de.avatic.lcc.model.db.nodes.Location;
import de.avatic.lcc.model.db.nodes.Node;
import de.avatic.lcc.repositories.DistanceMatrixRepository;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Service;
import org.springframework.web.client.HttpClientErrorException;
import org.springframework.web.client.RestTemplate;
import org.springframework.web.util.UriComponentsBuilder;

import java.math.BigDecimal;
import java.time.LocalDateTime;
import java.util.Optional;

@Service
public class DistanceApiService {

    private static final Logger logger = LoggerFactory.getLogger(DistanceApiService.class);
    private static final String AZURE_MAPS_ROUTE_API = "https://atlas.microsoft.com/route/directions/json";

    private final DistanceMatrixRepository distanceMatrixRepository;
    private final RestTemplate restTemplate;
    private final ObjectMapper objectMapper;
    private final GeoApiService geoApiService;

    @Value("${azure.maps.subscription.key}")
    private String subscriptionKey;

    public DistanceApiService(DistanceMatrixRepository distanceMatrixRepository,
                              RestTemplate restTemplate, ObjectMapper objectMapper, GeoApiService geoApiService) {
        this.distanceMatrixRepository = distanceMatrixRepository;
        this.restTemplate = restTemplate;
        this.objectMapper = objectMapper;
        this.geoApiService = geoApiService;
    }

    public Optional<Integer> getDistance(Location from, Location to) {
        if (from == null || to == null) {
            logger.warn("Source or destination location is null");
            return Optional.empty();
        }

        if (from.getLatitude() == null || from.getLongitude() == null ||
                to.getLatitude() == null || to.getLongitude() == null) {
            return Optional.empty();
        }

        RouteDirectionsResponse response = fetchDistanceFromAzureMaps(BigDecimal.valueOf(from.getLatitude()), BigDecimal.valueOf(from.getLongitude()), BigDecimal.valueOf(to.getLatitude()), BigDecimal.valueOf(to.getLongitude()));

        if (response != null && response.getRoutes() != null && !response.getRoutes().isEmpty()) {
            return Optional.of(response.getRoutes().getFirst().getSummary().getLengthInMeters());
        }

        return Optional.empty();
    }

    public Optional<Distance> getDistance(Node from, boolean isUsrFrom, Node to, boolean isUsrTo) {

        if (from == null || to == null) {
            logger.warn("Source or destination node is null");
            return Optional.empty();
        }

        if (from.getGeoLat() == null || from.getGeoLng() == null ||
                to.getGeoLat() == null || to.getGeoLng() == null) {
            logger.warn("Missing geo coordinates for nodes: from={}, to={}", from.getExternalMappingId(), to.getExternalMappingId());
            return Optional.empty();
        }

        Optional<Distance> cachedDistance = distanceMatrixRepository.getDistance(from, isUsrFrom, to, isUsrTo);

        if (cachedDistance.isPresent() && cachedDistance.get().getState() == DistanceMatrixState.VALID) {
            logger.info("Found cached distance from node {} (user: {}) to node {} (user {}) - {} meters", from.getExternalMappingId(), isUsrFrom, to.getExternalMappingId(), isUsrTo, cachedDistance.get().getDistance().doubleValue());
            return cachedDistance;
        }

        if (cachedDistance.isPresent() && cachedDistance.get().getState() == DistanceMatrixState.EXCEPTION) {
            if (cachedDistance.get().getRetries() >= 3)
                return Optional.empty();

            distanceMatrixRepository.updateRetries(cachedDistance.get().getId());
        }

        logger.info("Fetching distance from Azure Maps for nodes {} to {}", from.getExternalMappingId() == null ? from.getName() : from.getExternalMappingId(), to.getExternalMappingId() == null ? to.getName() : to.getExternalMappingId());
        AzureMapResponse distanceResponse = fetchDistanceFromAzureMaps(from, isUsrFrom, to, isUsrTo, true);

        if (distanceResponse.distance != null) {
            distanceMatrixRepository.saveDistance(distanceResponse.distance);
            return Optional.of(distanceResponse.distance);
        }

        if (distanceResponse.errorType != AzureMapsErrorType.NO_ERROR) {
            distanceMatrixRepository.saveDistance(getErrorDistance(cachedDistance, from, isUsrFrom, to, isUsrTo));
        }

        return Optional.empty();
    }

    private Distance getErrorDistance(Optional<Distance> cachedDistance, Node from, boolean isUsrFrom, Node to, boolean isUsrTo) {
        var distance = cachedDistance.orElse(new Distance());

        distance.setState(DistanceMatrixState.EXCEPTION);
        distance.setUpdatedAt(LocalDateTime.now());
        distance.setRetries(distance.getRetries() == null ? 0 : distance.getRetries() + 1);

        if (cachedDistance.isEmpty()) {
            distance.setFromUserNodeId(isUsrFrom ? from.getId() : null);
            distance.setFromNodeId(isUsrFrom ? null : from.getId());
            distance.setToUserNodeId(isUsrTo ? to.getId() : null);
            distance.setToNodeId(isUsrTo ? null : to.getId());

            distance.setFromGeoLat(from.getGeoLat());
            distance.setFromGeoLng(from.getGeoLng());
            distance.setToGeoLat(to.getGeoLat());
            distance.setToGeoLng(to.getGeoLng());

            distance.setDistance(BigDecimal.ZERO);
        }

        return distance;
    }

    private RouteDirectionsResponse fetchDistanceFromAzureMaps(BigDecimal fromLat, BigDecimal fromLng, BigDecimal toLat, BigDecimal toLng) {
        String url = UriComponentsBuilder.fromUriString(AZURE_MAPS_ROUTE_API)
                .queryParam("api-version", "1.0")
                .queryParam("subscription-key", subscriptionKey)
                .queryParam("query", String.format("%s,%s:%s,%s",
                        fromLat, fromLng,
                        toLat, toLng))
                .encode()
                .toUriString();

        return restTemplate.getForObject(url, RouteDirectionsResponse.class);
    }


    private AzureMapResponse fetchDistanceFromAzureMaps(Node from, boolean isUsrFrom, Node to, boolean isUsrTo, boolean allowFixing) {
        try {
            RouteDirectionsResponse response = fetchDistanceFromAzureMaps(from.getGeoLat(), from.getGeoLng(), to.getGeoLat(), to.getGeoLng());
            return convertToDistance(response, from, isUsrFrom, to, isUsrTo);
        } catch (Exception e) {
            if (HttpClientErrorException.class.isAssignableFrom(e.getClass()))
                return handleAzureMapsError((HttpClientErrorException) e, from, isUsrFrom, to, isUsrTo, allowFixing);

            logger.error("Error fetching distance from Azure Maps", e);
            return new AzureMapResponse(null, AzureMapsErrorType.OTHER_ERROR, null);
        }
    }

    private AzureMapResponse convertToDistance(RouteDirectionsResponse response, Node from, boolean isUsrFrom, Node to, boolean isUsrTo) {
        if (response != null && response.getRoutes() != null && !response.getRoutes().isEmpty()) {
            Integer distanceInMeters = response.getRoutes().getFirst().getSummary().getLengthInMeters();

            Distance distance = new Distance();

            if (isUsrFrom) {
                distance.setFromUserNodeId(from.getId());
                distance.setFromNodeId(null);
            } else {
                distance.setFromUserNodeId(null);
                distance.setFromNodeId(from.getId());
            }

            if (isUsrTo) {
                distance.setToUserNodeId(to.getId());
                distance.setToNodeId(null);
            } else {
                distance.setToUserNodeId(null);
                distance.setToNodeId(to.getId());
            }

            distance.setFromGeoLat(from.getGeoLat());
            distance.setFromGeoLng(from.getGeoLng());
            distance.setToGeoLat(to.getGeoLat());
            distance.setToGeoLng(to.getGeoLng());
            distance.setDistance(BigDecimal.valueOf(distanceInMeters));
            distance.setState(DistanceMatrixState.VALID);
            distance.setUpdatedAt(LocalDateTime.now());

            // reset to 0 if on success
            distance.setRetries(0);

            logger.info("Successfully fetched distance: {} meters", distanceInMeters);
            return new AzureMapResponse(distance, AzureMapsErrorType.NO_ERROR, null);
        } else {
            logger.error("No routes found in Azure Maps response");
            return new AzureMapResponse(null, AzureMapsErrorType.ROUTE_ERROR, null);
        }
    }

    private AzureMapResponse handleAzureMapsError(HttpClientErrorException e,
                                                  Node from, boolean isUsrFrom,
                                                  Node to, boolean isUsrTo, boolean allowFixing) {

        try {
            String responseBody = e.getResponseBodyAsString();
            JsonNode errorNode = objectMapper.readTree(responseBody);

            String errorCode = errorNode.path("error").path("code").asText();
            String errorMessage = errorNode.path("error").path("message").asText();

            logger.warn("Azure Maps API Error for nodes {} ({}) to {} ({}): {} - {}",
                    from.getExternalMappingId() == null ? from.getName() : from.getExternalMappingId(), from.getId(), to.getExternalMappingId() == null ? to.getName() : to.getExternalMappingId(), to.getId(),
                    errorCode, errorMessage);

            if (errorMessage.contains("NO_ROUTE_FOUND"))
                return new AzureMapResponse(null, AzureMapsErrorType.ROUTE_ERROR, null);

            if (errorMessage.contains("MAP_MATCHING_FAILURE")) {
                if (errorMessage.contains("Destination")) {
                    if (allowFixing) {
                        var fixedNode = fixNode(to);

                        if (fixedNode != null)
                            return fetchDistanceFromAzureMaps(from, isUsrFrom, fixedNode, isUsrTo, false);
                    }

                    return new AzureMapResponse(null, AzureMapsErrorType.NODE_ERROR, AzureMapsDefectiveNode.FROM);
                }

                if (errorMessage.contains("Origin")) {
                    if (allowFixing) {
                        var fixedNode = fixNode(from);

                        if (fixedNode != null)
                            return fetchDistanceFromAzureMaps(fixedNode, isUsrFrom, to, isUsrTo, false);
                    }

                    return new AzureMapResponse(null, AzureMapsErrorType.NODE_ERROR, AzureMapsDefectiveNode.TO);
                }

                return new AzureMapResponse(null, AzureMapsErrorType.NODE_ERROR, AzureMapsDefectiveNode.UNKNOWN);
            }

        } catch (Exception parseException) {
            logger.error("Failed to parse Azure Maps error response", parseException);
        }

        return new AzureMapResponse(null, AzureMapsErrorType.OTHER_ERROR, null);
    }

    private Node fixNode(Node node) {

        logger.info("Try to fix node {} ({}) ", node.getExternalMappingId(), node.getId());

        Location location = geoApiService.locate(node.getAddress() + ", " + node.getCountryId());

        if (location != null && location.getLatitude() != null && location.getLongitude() != null) {
            if (0 != BigDecimal.valueOf(location.getLatitude()).compareTo(node.getGeoLat()) || 0 != BigDecimal.valueOf(location.getLongitude()).compareTo(node.getGeoLng())) {
                logger.info("Fixed node {} ({}) coordinates {}, {} -> {}, {}", node.getExternalMappingId(), node.getId(), node.getGeoLat(), node.getGeoLng(), location.getLatitude(), location.getLongitude());

                node.setGeoLng(BigDecimal.valueOf(location.getLongitude()));
                node.setGeoLat(BigDecimal.valueOf(location.getLatitude()));


                return node;
            }
        }

        return null;
    }

    private enum AzureMapsErrorType {
        NO_ERROR, NODE_ERROR, ROUTE_ERROR, OTHER_ERROR
    }

    private enum AzureMapsDefectiveNode {
        FROM, TO, UNKNOWN
    }

    private record AzureMapResponse(Distance distance, AzureMapsErrorType errorType,
                                    AzureMapsDefectiveNode defectiveNode) {

    }


}