package de.avatic.lcc.service.calculation;

import de.avatic.lcc.dto.generic.RateType;
import de.avatic.lcc.dto.generic.TransportType;
import de.avatic.lcc.model.db.nodes.Node;
import de.avatic.lcc.model.db.premises.route.*;
import de.avatic.lcc.model.db.properties.SystemPropertyMappingId;
import de.avatic.lcc.model.db.rates.ContainerRate;
import de.avatic.lcc.model.db.rates.MatrixRate;
import de.avatic.lcc.repositories.NodeRepository;
import de.avatic.lcc.repositories.properties.PropertyRepository;
import de.avatic.lcc.repositories.rates.ContainerRateRepository;
import de.avatic.lcc.repositories.rates.MatrixRateRepository;
import org.jetbrains.annotations.Debug.Renderer;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.stereotype.Service;

import java.util.*;
import java.util.stream.Collectors;

@Service
public class RoutingService {

    private static final Logger log = LoggerFactory.getLogger(RoutingService.class);


    private final MatrixRateRepository matrixRateRepository;
    private final ChainResolver chainResolver;
    private final NodeRepository nodeRepository;
    private final ContainerRateRepository containerRateRepository;
    private final DistanceService distanceService;
    private final PropertyRepository propertyRepository;

    public RoutingService(MatrixRateRepository matrixRateRepository, ChainResolver chainResolver, NodeRepository nodeRepository, ContainerRateRepository containerRateRepository, DistanceService distanceService, PropertyRepository propertyRepository) {
        this.matrixRateRepository = matrixRateRepository;
        this.chainResolver = chainResolver;
        this.nodeRepository = nodeRepository;
        this.containerRateRepository = containerRateRepository;
        this.distanceService = distanceService;
        this.propertyRepository = propertyRepository;
    }

    public List<RouteInformation> findRoutes(Node destination, Node source, boolean sourceIsUserNode) {
        List<RouteInformation> routeInformation = new ArrayList<>();
        TemporaryContainer container = new TemporaryContainer(source, destination, sourceIsUserNode);

        /*
         * Get the source and destination node from database.
         * Check if there is a matrix rate for the source country.
         */
        matrixRateRepository.getByCountryIds(source.getCountryId(), source.getCountryId()).ifPresent(container::setSourceMatrixRate);


        /*
         * Generate recursive all chains starting with the destination node.
         * This means all chains within the last node of a chain are connected to the
         * existing chain.
         *
         * Furthermore, it is evaluated that all nodes within the chain do not have chains
         * themselves that are in conflict with the chain.
         *
         * Then get all countries from the end of the destination chains.
         */

        List<List<Node>> destinationChains = getChain(destination.getId(), container);
        destinationChains.forEach(chain -> chain.addFirst(destination));
        List<Integer> inboundCountries = destinationChains.stream().filter(chain -> !chain.isEmpty()).map(chain -> chain.getLast().getCountryId()).distinct().toList();

        /*
         * Get all outbound nodes for the country of the source node. In this first step this includes:
         * - all intermediate nodes that have the same country id.
         * - all nodes that are explicitly mapped as outbound node for this country id.
         */
        List<Node> outboundNodes = nodeRepository.getAllOutboundFor(source.getCountryId());

        /*
         * Find main runs based on the outbound nodes and the inbound countries found before.
         * Find post-runs for the main runs.
         *
         * Store all information in the TemporaryContainer object.
         */
        container.setMainRuns(outboundNodes.stream().collect(Collectors.toMap(Node::getId, n -> containerRateRepository.findRoutesByStartNodeIdAndDestinationCountryId(n.getId(), inboundCountries))));
        container.setPostRuns(container.getMainRuns().stream().collect(Collectors.toMap(ContainerRate::getId, containerRateRepository::getPostRunsFor)));


        connectDestinationChainAndMainRun(container, outboundNodes, destinationChains);
        connectSourceChainAndSource(container);

        /*
         * At this point all routes with a main run are created.
         * We find now the best route per main run and throw away the rest.
         */
        // we cannot do this, because we need all the Intermediate Warehouses to show.
        //findCheapestPerMainRun(container);

        /*
         * Now we also create routes without main run (matrix rate)
         */
        connectDestinationChainDirectly(container, destinationChains);

        /* remove duplicates & lower qualities. */
        removeDuplicateRoutes(container.getRoutes(), true);

        /*
         * finally find and mark the fastest and the cheapest route.
         */
        findAndMarkCheapestAndFastest(container);


        container.getRoutes().stream().map(TemporaryRouteObject::getFullDebugText).forEach(s -> log.info("Found route: {}", s));


        /*
         * Convert to Database model
         */
        for (var route : container.getRoutes()) {
            RouteInformation routeInformationObj = new RouteInformation();

            routeInformationObj.setRoute(mapRoute(route));
            routeInformationObj.setSections(mapSections(route.getSections(), sourceIsUserNode));

            routeInformation.add(routeInformationObj);
        }

        if(routeInformation.size() == 1) {
            routeInformation.getFirst().getRoute().setSelected(true);
        }

        return routeInformation;
    }


    private RouteNode mapNode(Node node, boolean isUserNode) {
        RouteNode routeNode = new RouteNode();

        routeNode.setName(node.getName());
        routeNode.setCountryId(node.getCountryId());
        routeNode.setAddress(node.getAddress());
        routeNode.setGeoLng(node.getGeoLng());
        routeNode.setGeoLat(node.getGeoLat());
        routeNode.setUserNodeId(isUserNode ? node.getId() : null);
        routeNode.setNodeId(isUserNode ? null : node.getId());
        routeNode.setIntermediate(node.getIntermediate() != null ? node.getIntermediate() : false);
        routeNode.setDestination(node.getDestination() != null ? node.getDestination() : false);
        routeNode.setSource(node.getSource() != null ? node.getSource() : false);
        routeNode.setOutdated(node.getDeprecated());
        routeNode.setExternalMappingId(isUserNode ? generateShortName(node.getName()) : node.getExternalMappingId());

        return routeNode;
    }

    private String generateShortName(String name) {
        if (name.length() < 13) {
            return name;
        }

        return name.substring(0, 10) + " ...";
    }

    private Route mapRoute(TemporaryRouteObject route) {
        Route routeObj = new Route();

        routeObj.setCheapest(route.isCheapest());
        routeObj.setFastest(route.isFastest());
        routeObj.setSelected(false);

        return routeObj;
    }

    private List<RouteSectionInformation> mapSections(List<TemporaryRateObject> sections, boolean sourceIsUserNode) {
        int index = 1;

        List<RouteSectionInformation> routeSections = new ArrayList<>();
        boolean passedMainRun = false;
        boolean firstSection = true;

        RouteNode fromNode = null;
        for (var section : sections.reversed()) {
            var routeSection = mapSection(section);

            if (firstSection) {
                fromNode = mapNode(section.getFromNode(), sourceIsUserNode);
                firstSection = false;
            }

            var toNode = mapNode(section.getToNode(), false);

            if (!section.getType().equals(TemporaryRateObject.TemporaryRateObjectType.MAIN_RUN) && !section.getType().equals(TemporaryRateObject.TemporaryRateObjectType.POST_RUN)) {
                if (!passedMainRun)
                    routeSection.setPreRun(true);

                if (passedMainRun)
                    routeSection.setPostRun(true);

            }

            if (section.getType().equals(TemporaryRateObject.TemporaryRateObjectType.MAIN_RUN) || section.getType().equals(TemporaryRateObject.TemporaryRateObjectType.POST_RUN))
                passedMainRun = true;

            routeSection.setListPosition(index++);
            routeSections.add(new RouteSectionInformation(routeSection, fromNode, toNode));
            fromNode = toNode;
        }

        return routeSections;
    }

    private RouteSection mapSection(TemporaryRateObject section) {
        RouteSection routeSection = new RouteSection();

        routeSection.setTransportType(mapRouteType(section));
        routeSection.setRateType(mapRateType(section));
        routeSection.setFromRouteNodeId(section.getFromNode().getId());
        routeSection.setToRouteNodeId(section.getToNode().getId());
        routeSection.setMainRun(section.getType().equals(TemporaryRateObject.TemporaryRateObjectType.MAIN_RUN));
        routeSection.setOutdated(false);

        routeSection.setPostRun(section.getType().equals(TemporaryRateObject.TemporaryRateObjectType.POST_RUN));
        routeSection.setPreRun(false);

        return routeSection;

    }

    private RateType mapRateType(TemporaryRateObject rate) {

        if (Objects.requireNonNull(rate.getType()) == TemporaryRateObject.TemporaryRateObjectType.NEAR_BY) {
            return RateType.NEAR_BY;
        } else if (Objects.requireNonNull(rate.getType()) == TemporaryRateObject.TemporaryRateObjectType.MATRIX) {
            return RateType.MATRIX;
        }
        return RateType.CONTAINER;
    }

    private TransportType mapRouteType(TemporaryRateObject rate) {

        switch (rate.getType()) {
            case CONTAINER, MATRIX, NEAR_BY -> {
                return TransportType.ROAD;
            }
            case POST_RUN -> {
                return TransportType.POST_RUN;
            }
            case MAIN_RUN -> {
                return TransportType.valueOf(rate.getContainerRateType().name());
            }
        }

        return null;
    }

    private void findAndMarkCheapestAndFastest(TemporaryContainer container) {
        var routes = container.getRoutes();

        TemporaryRouteObject cheapest = null;
        double cheapestCost = Double.MAX_VALUE;

        TemporaryRouteObject fastest = null;
        double fastestRoute = Double.MAX_VALUE;

        for (var route : routes) {
            double routeCost = route.getCost();
            int leadTime = route.getLeadTime();

            if (routeCost < cheapestCost) {
                cheapestCost = routeCost;
                cheapest = route;
            }

            if (leadTime < fastestRoute) {
                fastestRoute = leadTime;
                fastest = route;
            }
        }

        if (cheapest != null) {
            cheapest.setCheapest();
        }

        if (fastest != null) {
            fastest.setFastest();
        }
    }

    private void findCheapestPerMainRun(TemporaryContainer container) {

        var routesByMainRun = container.getRoutes().stream().collect(Collectors.groupingBy(TemporaryRouteObject::getMainRun));
        Collection<TemporaryRouteObject> cheapestRoutes = new ArrayList<>();

        for (var mainRun : routesByMainRun.keySet()) {
            List<TemporaryRouteObject> routes = routesByMainRun.get(mainRun);
            TemporaryRouteObject cheapest = null;
            double cheapestCost = Double.MAX_VALUE;

            for (var route : routes) {
                double routeCost = route.getCost();

                if (routeCost < cheapestCost) {
                    cheapestCost = routeCost;
                    cheapest = route;
                }
            }

            if (cheapest != null) {
                cheapestRoutes.add(cheapest);
            }
        }

        container.overrideRoutes(cheapestRoutes);
    }

    private void connectDestinationChainDirectly(TemporaryContainer container, List<List<Node>> chains) {
        Collection<TemporaryRateObject> rates = container.getRates();

        for (var chain : chains) {

            if (skipCain(chain, container))
                continue;

            var toNode = chain.isEmpty() ? container.getDestinationNode() : chain.getLast();

            TemporaryRateObject finalSection = new TemporaryRateObject(container.getSourceNode(), toNode, TemporaryRateObject.TemporaryRateObjectType.MATRIX);

            if (rates.contains(finalSection)) {
                TemporaryRateObject finalMatrixRateObj = finalSection;
                finalSection = container.getRates().stream().filter(r -> r.equals(finalMatrixRateObj)).findFirst().orElse(null);
            } else {
                var matrixRate = matrixRateRepository.getByCountryIds(container.getSourceNode().getCountryId(), toNode.getCountryId()).orElse(null);

                // no matrix rate in database. skip this chain
                if (matrixRate == null) {
                    continue;
                }

                finalSection.setRate(matrixRate);
                finalSection.setApproxDistance(distanceService.getDistance(container.getSourceNode(), toNode, true));
                rates.add(finalSection);
            }

            // could not create an temporary rate object -> so just skip here ... (should not happen)
            if (finalSection == null) {
                continue;
            }

            // create a route.
            boolean routable = true;
            TemporaryRouteObject routeObj = new TemporaryRouteObject();

            for (int idx = 1; idx < chain.size(); idx++) {
                Node startNode = chain.get(idx);
                Node endNode = chain.get(idx - 1);

                var rate = connectNodes(startNode, endNode, container);

                if (rate != null) {
                    routeObj.addSection(rate);
                } else {
                    // chain is not routable -> discard
                    routable = false;
                    break;
                }
            }

            // if the chain is routable -> add the final rate and save the route.
            if (routable) {
                routeObj.addSection(finalSection);
                routeObj.setQuality(ChainQuality.SUPERIOR);
                container.addRoute(routeObj);
            }

        }
    }

    private boolean skipCain(List<Node> chain, TemporaryContainer container) {
        if (containerRateRepository.hasMainRun(chain.getFirst().getId()))
            return true;

        Set<Integer> countryIds = new HashSet<>();
        countryIds.add(container.getSourceNode().getCountryId());
        chain.forEach(node -> countryIds.add(node.getCountryId()));
        return countryIds.size() > (Objects.equals(container.getDestinationNode().getCountryId(), container.getSourceNode().getCountryId()) ? 1 :  2);
    }


    private TemporaryRateObject connectNearByNodes(Node chainEnd, List<Node> nearByNodes, TemporaryContainer container) {

        if(nearByNodes == null)
            return null;

        for (var nearbyNode : nearByNodes) {
            TemporaryRateObject nearByRate = connectNodes(nearbyNode, chainEnd, container);
            if (null != nearByRate) {
                return nearByRate;
            }
        }
        return null;
    }

    private List<List<Node>> getChain(Integer id, TemporaryContainer container) {
        var chains = container.getChain(id);

        if (chains == null) {
            chains = chainResolver.buildChains(id);
            container.setChain(id, chains);
        }

        List<List<Node>> copiedChain = new ArrayList<>();
        chains.forEach(chain -> copiedChain.add(new ArrayList<>(chain)));

        return copiedChain;
    }

    private void connectSourceChainAndSource(TemporaryContainer container) {

        /* get the near-by nodes if no country matrix rate present */
        List<Node> nearByNodes = (container.hasSourceMatrixRate()) ? null : nodeRepository.getByDistance(container.getSourceNode(), getRegionRadius());

        Collection<TemporaryRouteObject> routes = new ArrayList<>();

        for (var route : container.getRoutes()) {
            var mainRun = route.getMainRun();
            var sourceChains = getChain(mainRun.getFromNode().getId(), container);
            sourceChains.forEach(chain -> chain.addFirst(mainRun.getFromNode()));

            for (var chain : sourceChains) {
                Node source = container.getSourceNode();
                Node nearByNode = null;
                TemporaryRateObject finalSection = null;
                SourceConnectionType connectionType = SourceConnectionType.NONE;

                if (source.getId().equals(chain.getLast().getId())) {
                    connectionType = SourceConnectionType.CHAIN_END_IS_SOURCE_NODE;
                } else if (nearByNodes != null) {
                    nearByNode = nearByNodes.stream().filter(n -> n.getId().equals(chain.getLast().getId())).findFirst().orElse(null);
                    if (nearByNode != null) {
                        connectionType = SourceConnectionType.CHAIN_END_IS_NEAR_BY_NODE;
                    }
                }
                if (connectionType == SourceConnectionType.NONE) {
                    // find final section: check if chain end and source node are identical, then check if chain end can be connected to
                    // source node, if this is not possible use a near-by node
                    finalSection = connectNodes(source, chain.getLast(), container);
                    if (finalSection != null)
                        connectionType = SourceConnectionType.FINAL_SECTION_WITH_SOURCE_NODE;
                }
                if (connectionType == SourceConnectionType.NONE) {
                    finalSection = connectNearByNodes(chain.getLast(), nearByNodes, container);
                    if (finalSection != null) {
                        connectionType = SourceConnectionType.FINAL_SECTION_WITH_NEAR_BY_NODE;
                        nearByNode = finalSection.getFromNode();
                    }
                }

                if (connectionType != SourceConnectionType.NONE) {
                    boolean routable = true;
                    TemporaryRouteObject duplicate = route.clone();

                    for (int idx = 1; idx < chain.size(); idx++) {
                        Node startNode = chain.get(idx);
                        Node endNode = chain.get(idx - 1);

                        TemporaryRateObject rate = connectNodes(startNode, endNode, container);

                        if (rate != null) duplicate.addSection(rate);
                        else {
                            routable = false;
                            break;
                        }
                    }

                    if (routable) {

                        if (connectionType == SourceConnectionType.FINAL_SECTION_WITH_SOURCE_NODE
                                || connectionType == SourceConnectionType.FINAL_SECTION_WITH_NEAR_BY_NODE) {
                            duplicate.addSection(finalSection);
                        }

                        if (connectionType == SourceConnectionType.FINAL_SECTION_WITH_NEAR_BY_NODE || connectionType == SourceConnectionType.CHAIN_END_IS_NEAR_BY_NODE) {
                            duplicate.routeOverNearBy(source, nearByNode);
                        }

                        routes.add(duplicate);
                    }
                }
            }
        }

        /*
         * There might be duplicate routes now, because the same route can be created by "nearby-routing" and container rates
         */
        removeDuplicateRoutes(routes, false);

        container.overrideRoutes(routes);
    }

    private void removeDuplicateRoutes(Collection<TemporaryRouteObject> routes, boolean removeLowQuality) {

        var toRemove = new ArrayList<TemporaryRouteObject>();

        var bestQuality = routes.stream().map(TemporaryRouteObject::getQuality).reduce((q1, q2) -> q1.ordinal() < q2.ordinal() ? q1 : q2);

        for (var route : routes) {

            if(removeLowQuality && route.getQuality().ordinal() > bestQuality.get().ordinal()) {
                toRemove.add(route);
                continue;
            }

            var sections = route.getSections();
            boolean hasNearByRouting = sections.getLast().getType().equals(TemporaryRateObject.TemporaryRateObjectType.NEAR_BY);


            for (var other : routes) {

                //skip same instance.
                if (other.equals(route)) continue;
                if (toRemove.contains(other)) continue;

                var otherIter = other.getSections().iterator();
                var iter = sections.iterator();
                boolean same = true;

                TemporaryRateObject lastSection = null;
                while (otherIter.hasNext() && iter.hasNext()) {
                    var otherSection = otherIter.next();
                    var section = iter.next();

                    same = otherSection.hasSameNodes(section);

                    if (!same && hasNearByRouting && iter.hasNext()) {
                        lastSection = section;
                        section = iter.next();
                        same = section.getType().equals(TemporaryRateObject.TemporaryRateObjectType.NEAR_BY) && otherSection.hasSameNodes(section, lastSection);
                    }

                    if (!same) {
                        break;
                    }
                }

                if (same && !otherIter.hasNext() && !iter.hasNext()) {
                    toRemove.add(route);
                    break;
                }
            }

        }

        routes.removeAll(toRemove);
    }

    private Integer getRegionRadius() {
        var property = propertyRepository.getPropertyByMappingId(SystemPropertyMappingId.RADIUS_REGION);
        return property.map(propertyDTO -> Integer.valueOf(propertyDTO.getCurrentValue())).orElseGet(SystemPropertyMappingId.RADIUS_REGION::getDefaultAsInteger);
    }

    private void connectDestinationChainAndMainRun(TemporaryContainer container, List<Node> outboundNodes, List<List<Node>> destinationChains) {
        /*
         * Try to connect everything together:
         * - go trough all main runs and adjacent post-runs
         * - find any compatible chain:
         *      - check if chain is routable
         *      - add post run and main run
         */
        var mainruns = container.getMainRuns();

        for (var mainRun : container.getMainRuns()) {

            Node mainRunEndNode = nodeRepository.getById(mainRun.getToNodeId()).orElseThrow();
            Node mainRunStartNode = outboundNodes.stream().filter(n -> n.getId().equals(mainRun.getFromNodeId())).findFirst().orElseThrow();

            if(!mainRunStartNode.getIntermediate() || !mainRunEndNode.getIntermediate())
                continue;

            TemporaryRateObject mainRunObj = new TemporaryRateObject(mainRunStartNode, mainRunEndNode, TemporaryRateObject.TemporaryRateObjectType.MAIN_RUN, mainRun);

            var postRuns = container.getPostRuns().get(mainRun.getId());
            var sortedChainMap = sortByQuality(destinationChains, postRuns, mainRun);

            for (ChainQuality quality : ChainQuality.values()) {

                /* if connection quality is bad, do not try to route this and continue. */
                if (quality == ChainQuality.FALLBACK) continue;
                if (sortedChainMap.get(quality) == null) continue;
                boolean qualityRoutable = false;

                for (var postRun : container.getPostRuns().get(mainRun.getId())) {

                    Node postRunEndNode = nodeRepository.getById(postRun.getToNodeId()).orElseThrow();

                    if (!postRunEndNode.getIntermediate())
                        continue;

                    TemporaryRateObject postRunObj = new TemporaryRateObject(mainRunEndNode, postRunEndNode, TemporaryRateObject.TemporaryRateObjectType.POST_RUN, postRun);

                    var sortedChains = sortedChainMap.get(quality).get(postRun.getId());
                    if (sortedChains == null || sortedChains.isEmpty()) continue;

                    for (var chain : sortedChains) {
                        boolean routable = true;
                        TemporaryRouteObject routeObj = new TemporaryRouteObject();

                        for (int idx = 1; idx < chain.size() - quality.getSizeOffset(); idx++) {
                            Node startNode = chain.get(idx);
                            Node endNode = chain.get(idx - 1);

                            var rate = connectNodes(startNode, endNode, container);

                            if (rate != null) {
                                routeObj.addSection(rate);
                            } else {
                                // chain is not routable -> discard
                                routable = false;
                                break;
                            }
                        }

                        if (routable && quality == ChainQuality.LOW) {
                            var rate = connectNodes(postRunEndNode, chain.getLast(), container);

                            if (rate != null) {
                                routeObj.addSection(rate);
                            } else {
                                routable = false;
                            }
                        }

                        if (routable) {
                            qualityRoutable = true;
                            routeObj.setQuality(quality);
                            routeObj.addPostRunSection(postRunObj);
                            routeObj.addMainRunSection(mainRunObj);
                            container.addRoute(routeObj);
                        }
                    }
                }
                /* if higher quality is routable, do not calculate lower qualities. */
                if (qualityRoutable)
                    break;
            }
        }
    }

    private Map<ChainQuality, Map<Integer, List<List<Node>>>> sortByQuality(List<List<Node>> chains, List<ContainerRate> postRuns, ContainerRate mainRun) {

        Map<ChainQuality, Map<Integer, List<List<Node>>>> sortedChains = new HashMap<>();

        for (var postRun : postRuns) {


            for (var chain : chains) {
                ChainQuality chainQuality = getChainQuality(chain, postRun, mainRun);
                sortedChains.putIfAbsent(chainQuality, new HashMap<>());
                sortedChains.get(chainQuality).putIfAbsent(postRun.getId(), new ArrayList<>());
                sortedChains.get(chainQuality).get(postRun.getId()).add(chain);
            }
        }

        return sortedChains;
    }

    private ChainQuality getChainQuality(List<Node> chain, ContainerRate postRun, ContainerRate mainRun) {
        if (chain.getLast().getId().equals(postRun.getToNodeId())) {
            return ChainQuality.MEDIUM;
        } else if (chain.size() > 2
                && chain.getLast().getId().equals(mainRun.getFromNodeId())
                && chain.get(chain.size() - 2).getId().equals(postRun.getFromNodeId())
                && chain.get(chain.size() - 3).getId().equals(postRun.getToNodeId())) {
            return ChainQuality.SUPERIOR;
        } else if (chain.size() > 1
                && chain.getLast().getId().equals(postRun.getFromNodeId())
                && chain.get(chain.size() - 2).getId().equals(postRun.getToNodeId())) {
            return ChainQuality.HIGH;
        } else if (chain.getLast().getCountryId().equals(postRun.getToCountryId())) {
            return ChainQuality.LOW;
        }
        return ChainQuality.FALLBACK;
    }

    private TemporaryRateObject connectNodes(Node startNode, Node endNode, TemporaryContainer container) {
        var containerRateObj = new TemporaryRateObject(startNode, endNode, TemporaryRateObject.TemporaryRateObjectType.CONTAINER);
        var matrixRateObj = new TemporaryRateObject(startNode, endNode, TemporaryRateObject.TemporaryRateObjectType.MATRIX);

        if (container.getRates().contains(containerRateObj))
            return container.getRates().stream().filter(r -> r.equals(containerRateObj)).findFirst().orElseThrow();

        if (container.getRates().contains(matrixRateObj))
            return container.getRates().stream().filter(r -> r.equals(matrixRateObj)).findFirst().orElseThrow();

        Optional<ContainerRate> containerRate = containerRateRepository.findRoute(startNode.getId(), endNode.getId(), TransportType.ROAD);

        if (containerRate.isPresent()) {
            containerRateObj.setRate(containerRate.get());
            container.getRates().add(containerRateObj);
            return containerRateObj;
        } else {
            Optional<MatrixRate> matrixRate = matrixRateRepository.getByCountryIds(startNode.getCountryId(), endNode.getCountryId());

            if (matrixRate.isPresent()) {
                matrixRateObj.setRate(matrixRate.get());
                matrixRateObj.setApproxDistance(distanceService.getDistance(startNode, endNode, true));
                container.getRates().add(matrixRateObj);
                return matrixRateObj;
            } else {
                return null;
            }
        }
    }

    private enum SourceConnectionType {
        NONE, CHAIN_END_IS_SOURCE_NODE, CHAIN_END_IS_NEAR_BY_NODE, FINAL_SECTION_WITH_SOURCE_NODE, FINAL_SECTION_WITH_NEAR_BY_NODE
    }

    private enum ChainQuality {
        SUPERIOR(2), HIGH(1), MEDIUM(0), LOW(0), FALLBACK(0);

        private final int sizeOffset;

        ChainQuality(int sizeOffset) {
            this.sizeOffset = sizeOffset;
        }

        public int getSizeOffset() {
            return sizeOffset;
        }
    }

    private static class TemporaryContainer {

        /*
         * Set to lookup route sections. Generated from node pairs.
         */
        private final Set<TemporaryRateObject> rates = new HashSet<>();
        /*
         * Routes that are build within the routing service.
         */
        private final Collection<TemporaryRouteObject> routes = new ArrayList<>();
        /*
         * Source and destination node
         */
        private final Node source;
        private final boolean sourceIsUserNode;
        private final Node destination;
        private final Map<Integer, List<List<Node>>> chains = new HashMap<>();
        /*
         * mainRuns (maps start node to rate) retrieved from the database.
         */
        private Map<Integer, List<ContainerRate>> mainRuns;
        /*
         * postRuns (maps main_run container rate id to post_run container rate id) retrieved from the database.
         */
        private Map<Integer, List<ContainerRate>> postRuns;
        private MatrixRate sourceMatrixRate;

        public TemporaryContainer(Node source, Node destination, boolean sourceIsUserNode) {
            this.source = source;
            this.sourceIsUserNode = sourceIsUserNode;
            this.destination = destination;
            this.mainRuns = null;
            this.postRuns = null;
            this.sourceMatrixRate = null;
        }


        public Collection<TemporaryRateObject> getRates() {
            return rates;
        }

        public List<ContainerRate> getMainRuns(Integer outboundNodeId) {
            return mainRuns.get(outboundNodeId);
        }

        public List<ContainerRate> getMainRuns() {
            return mainRuns.values().stream().flatMap(Collection::stream).toList();
        }

        public void setMainRuns(Map<Integer, List<ContainerRate>> mainRuns) {
            this.mainRuns = mainRuns;
        }

        public Map<Integer, List<ContainerRate>> getPostRuns() {
            return postRuns;
        }

        public void setPostRuns(Map<Integer, List<ContainerRate>> postRuns) {
            this.postRuns = postRuns;
        }

        public void addRoute(TemporaryRouteObject route) {
            this.routes.add(route);
        }

        public Collection<TemporaryRouteObject> getRoutes() {
            return routes;
        }

        public Node getSourceNode() {
            return source;
        }

        public Node getDestinationNode() {
            return destination;
        }

        public void setSourceMatrixRate(MatrixRate sourceMatrixRate) {
            this.sourceMatrixRate = sourceMatrixRate;
        }

        public boolean hasSourceMatrixRate() {
            return sourceMatrixRate != null;
        }

        public void overrideRoutes(Collection<TemporaryRouteObject> routes) {
            this.routes.clear();
            this.routes.addAll(routes);
        }

        public List<List<Node>> getChain(Integer id) {
            return this.chains.get(id);
        }

        public void setChain(Integer id, List<List<Node>> chain) {
            this.chains.put(id, chain);
        }

        public boolean isSourceIsUserNode() {
            return sourceIsUserNode;
        }
    }

    @Renderer(text = "getFullDebugText()")
    private static class TemporaryRouteObject {

        private final List<TemporaryRateObject> sections;

        private TemporaryRateObject mainRun;
        private TemporaryRateObject postRun;

        private boolean nearBy = false;
        private boolean isCheapest = false;
        private boolean isFastest = false;
        private ChainQuality quality;

        public TemporaryRouteObject() {
            sections = new ArrayList<>();
        }

        public List<TemporaryRateObject> getSections() {
            return sections;
        }

        public void addSection(TemporaryRateObject section) {
            this.sections.add(section);
        }

        public void addMainRunSection(TemporaryRateObject mainRun) {
            addSection(mainRun);
            this.mainRun = mainRun;
        }

        public void addPostRunSection(TemporaryRateObject postRun) {
            addSection(postRun);
            this.postRun = postRun;
        }

        public TemporaryRateObject getMainRun() {
            return mainRun;
        }

        public void setMainRun(TemporaryRateObject mainRun) {
            this.mainRun = mainRun;
        }

        public TemporaryRateObject getPostRun() {
            return postRun;
        }

        @Override
        public TemporaryRouteObject clone() {
            TemporaryRouteObject clone = new TemporaryRouteObject();
            clone.sections.addAll(sections);
            clone.mainRun = mainRun;
            clone.postRun = postRun;
            clone.nearBy = nearBy;
            clone.isCheapest = isCheapest;
            clone.isFastest = isFastest;
            clone.quality = quality;
            return clone;
        }

        public void routeOverNearBy(Node source, Node nearByNode) {
            this.sections.add(new TemporaryRateObject(source, nearByNode, TemporaryRateObject.TemporaryRateObjectType.NEAR_BY));
            this.nearBy = true;
        }

        public double getCost() {
            return sections.stream().mapToDouble(TemporaryRateObject::getCost).sum();
        }

        public void setCheapest() {
            this.isCheapest = true;
        }

        public void setFastest() {
            this.isFastest = true;
        }

        public int getLeadTime() {
            return sections.stream().mapToInt(TemporaryRateObject::getLeadTime).sum();

        }

        public Boolean isCheapest() {
            return isCheapest;
        }

        public Boolean isFastest() {
            return isFastest;
        }

        public String getFullDebugText() {

            StringBuilder sb = new StringBuilder();

            if (!sections.isEmpty()) {

                sb.append(sections.getLast().getFromNode().getDebugText());
                for (var section : sections.reversed()) {
                    sb.append(" -");
                    sb.append(section.getType().getDebugText());
                    sb.append("-> ");
                    sb.append(section.getToNode().getDebugText());
                }

            } else sb.append("Empty");


            return String.format("Route: [%s, %s]", quality.name().charAt(0) + (isFastest ? "\uD83D\uDE80" : "") + (isCheapest ? "\uD83D\uDCB2" : ""), sb);
        }

        public void setQuality(ChainQuality quality) {
            this.quality = quality;
        }

        public ChainQuality getQuality() {
            return quality;
        }

    }


    @Renderer(text = "getFullDebugText()")
    private static class TemporaryRateObject {

        private final Node fromNode;
        private final Node toNode;
        private MatrixRate matrixRate;
        private double approxDistance;
        private ContainerRate containerRate;

        private TemporaryRateObjectType type;


        public TemporaryRateObject(Node fromNode, Node toNode, TemporaryRateObjectType type) {

            this.fromNode = fromNode;
            this.toNode = toNode;
            this.type = type;
        }

        public TemporaryRateObject(Node fromNode, Node toNode, TemporaryRateObjectType type, ContainerRate rate) {
            this.fromNode = fromNode;
            this.toNode = toNode;
            this.type = type;
            this.containerRate = rate;
        }

        @Override
        public boolean equals(Object o) {
            if (o == null || getClass() != o.getClass()) return false;
            TemporaryRateObject that = (TemporaryRateObject) o;

            if (this.type.equals(that.type)) {
                if (this.type.equals(TemporaryRateObjectType.MATRIX)) {
                    return Objects.equals(this.fromNode.getCountryId(), that.fromNode.getCountryId()) && Objects.equals(this.toNode.getCountryId(), that.toNode.getCountryId());
                } else if (this.type.equals(TemporaryRateObjectType.CONTAINER) || this.type.equals(TemporaryRateObjectType.MAIN_RUN) || this.type.equals(TemporaryRateObjectType.POST_RUN) || this.type.equals(TemporaryRateObjectType.NEAR_BY)) {
                    return Objects.equals(this.fromNode.getId(), that.fromNode.getId()) && Objects.equals(this.toNode.getId(), that.toNode.getId());
                }
            }

            return false;
        }

        @Override
        public int hashCode() {
            if (matrixRate != null) return Objects.hash(matrixRate.getFromCountry(), matrixRate.getToCountry());

            if (containerRate != null) return Objects.hash(containerRate.getFromNodeId(), containerRate.getToNodeId());

            return Objects.hash(null, null);
        }

        public void setRate(ContainerRate containerRate) {
            this.containerRate = containerRate;
            this.type = TemporaryRateObjectType.CONTAINER;
        }

        public void setRate(MatrixRate matrixRate) {
            this.matrixRate = matrixRate;
            this.type = TemporaryRateObjectType.MATRIX;
        }

        public double getApproxDistance() {
            return approxDistance;
        }

        public void setApproxDistance(double distance) {
            this.approxDistance = distance;
        }

        public Node getFromNode() {
            return fromNode;
        }

        public Node getToNode() {
            return toNode;
        }

        public double getCost() {

            if (type.equals(TemporaryRateObjectType.NEAR_BY)) {
                return 0.0;
            }

            if (type.equals(TemporaryRateObjectType.MATRIX)) {
                return matrixRate.getRate().doubleValue() * approxDistance;
            } else {
                return containerRate.getRateFeu().doubleValue();
            }
        }

        public int getLeadTime() {
            if (type.equals(TemporaryRateObjectType.MATRIX))
                return 3;

            if (type.equals(TemporaryRateObjectType.NEAR_BY))
                return 0;

            return containerRate.getLeadTime();
        }

        public TemporaryRateObjectType getType() {
            return type;
        }

        public TransportType getContainerRateType() {
            return containerRate.getType();
        }

        public String getFullDebugText() {
            if (type.equals(TemporaryRateObjectType.MATRIX)) {
                return String.format("Rate [%s -\uD83D\uDCCA-> %s, %.2f km]", fromNode.getExternalMappingId(), toNode.getExternalMappingId(), approxDistance);
            }
            return String.format("Rate [%s -\uD83D\uDCE6-> %s]", fromNode.getExternalMappingId(), toNode.getExternalMappingId());
        }

        public boolean hasSameNodes(TemporaryRateObject section) {
            return fromNode.getId().equals(section.getFromNode().getId()) && toNode.getId().equals(section.getToNode().getId());
        }

        public boolean hasSameNodes(TemporaryRateObject firstSection, TemporaryRateObject secondSection) {
            return fromNode.getId().equals(firstSection.getFromNode().getId()) && toNode.getId().equals(secondSection.getToNode().getId());
        }

        private enum TemporaryRateObjectType {
            NEAR_BY("\uD83D\uDCCD"), MATRIX("\uD83D\uDCCA"), CONTAINER("\uD83D\uDCE6"), POST_RUN("\uD83D\uDE9B"), MAIN_RUN("\uD83D\uDEA2");

            private final String debugText;

            TemporaryRateObjectType(String debugText) {
                this.debugText = debugText;
            }

            public String getDebugText() {
                return debugText;
            }
        }
    }
}
