package ru.yandex.qe.dispenser.ws.resources_model;

import java.math.BigDecimal;
import java.math.RoundingMode;
import java.time.Instant;
import java.util.ArrayList;
import java.util.Collection;
import java.util.HashMap;
import java.util.HashSet;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.Set;
import java.util.UUID;
import java.util.function.Function;
import java.util.function.Predicate;
import java.util.stream.Collectors;

import javax.inject.Inject;

import org.apache.commons.lang3.tuple.Pair;
import org.jetbrains.annotations.NotNull;
import org.springframework.stereotype.Component;

import ru.yandex.qe.dispenser.domain.Person;
import ru.yandex.qe.dispenser.domain.QuotaChangeRequest;
import ru.yandex.qe.dispenser.domain.Resource;
import ru.yandex.qe.dispenser.domain.Segment;
import ru.yandex.qe.dispenser.domain.Service;
import ru.yandex.qe.dispenser.domain.dao.resources_model.ResourcesModelMappingDao;
import ru.yandex.qe.dispenser.domain.hierarchy.Hierarchy;
import ru.yandex.qe.dispenser.domain.hierarchy.HierarchySupplier;
import ru.yandex.qe.dispenser.domain.index.LongIndexBase;
import ru.yandex.qe.dispenser.domain.resources_model.DeliverableResource;
import ru.yandex.qe.dispenser.domain.resources_model.ExternalDeliverableResource;
import ru.yandex.qe.dispenser.domain.resources_model.ExternalResource;
import ru.yandex.qe.dispenser.domain.resources_model.InternalResource;
import ru.yandex.qe.dispenser.domain.resources_model.QuotaChangeRequestDeliveryMapping;
import ru.yandex.qe.dispenser.domain.resources_model.QuotaChangeRequestsDeliveryMappings;
import ru.yandex.qe.dispenser.domain.resources_model.QuotaRequestDelivery;
import ru.yandex.qe.dispenser.domain.resources_model.QuotaRequestDeliveryContext;
import ru.yandex.qe.dispenser.domain.resources_model.QuotaRequestDeliveryResolveStatus;
import ru.yandex.qe.dispenser.domain.resources_model.ResourceModelMappingTarget;
import ru.yandex.qe.dispenser.domain.resources_model.ResourcesModelMapping;
import ru.yandex.qe.dispenser.ws.reqbody.RequestQuotaValidatedBody;

@Component("simpleResourceModelMapper")
public class SimpleResourceModelMapper implements ResourcesModelMapper {

    protected final ResourcesModelMappingDao mappingDao;
    private final HierarchySupplier hierarchySupplier;

    @Inject
    public SimpleResourceModelMapper(ResourcesModelMappingDao mappingDao,
                                     HierarchySupplier hierarchySupplier) {
        this.mappingDao = mappingDao;
        this.hierarchySupplier = hierarchySupplier;
    }

    @Override
    public QuotaRequestDeliveryContext mapNonAllocatingToExternalResources(QuotaChangeRequest request,
                                                                           Collection<QuotaChangeRequest.Change> changes,
                                                                           Service provider,
                                                                           Person author) {
        return mapNonAllocatingToExternalResources(
                request,
                changes,
                provider,
                author,
                null
        );
    }

    @Override
    public QuotaChangeRequestsDeliveryMappings mapToDestinations(List<QuotaChangeRequest> requests,
                                                                 Set<Service> supportedProviders,
                                                                 Map<Long, Set<Service>> eligibleProvidersPerRequest) {
        Hierarchy hierarchy = hierarchySupplier.get();
        Map<Long, Set<Resource>> eligibleResourcesByCampaign = new HashMap<>();
        Map<Long, Set<Resource>> eligibleResourcesByRequest = new HashMap<>();
        requests.forEach(request -> {
            Set<Service> eligibleProviders = eligibleProvidersPerRequest.getOrDefault(request.getId(), Set.of());
            request.getChanges().forEach(change -> {
                // Skip ineligible providers, e.g. providers not suitable for this mapper
                if (!supportedProviders.contains(change.getResource().getService())) {
                    return;
                }
                if (!eligibleProviders.contains(change.getResource().getService())) {
                    return;
                }
                // Skip resources where there is nothing to allocate
                if (change.getAmountReady() > change.getAmountAllocating()) {
                    eligibleResourcesByCampaign.computeIfAbsent(request.getCampaignId(), k -> new HashSet<>())
                            .add(change.getResource());
                    eligibleResourcesByRequest.computeIfAbsent(request.getId(), k -> new HashSet<>())
                            .add(change.getResource());
                }
            });
        });
        // Find mappings by campaigns and resources
        Map<Long, List<ResourcesModelMapping>> mappingsByCampaign = new HashMap<>();
        eligibleResourcesByCampaign.forEach((campaignId, resources) -> mappingsByCampaign.put(campaignId,
                getMappings(resources, campaignId)));
        // Group mapping by campaign and resource
        Map<Long, Map<InternalResourceKey, List<ResourcesModelMapping>>> mappingsByCampaignResource = new HashMap<>();
        mappingsByCampaign.forEach((campaignId, mappings) -> mappingsByCampaignResource.put(campaignId,
                mappings.stream().collect(Collectors.groupingBy(SimpleResourceModelMapper::toInternalResourceKey))));
        Map<Long, QuotaChangeRequestDeliveryMapping> destinationsByRequestId = new HashMap<>();
        // Map each request
        requests.forEach(request -> {
            Map<DeliverableResource, Set<ExternalDeliverableResource>> destinations = new HashMap<>();
            // Mappings for this request's campaign
            Map<InternalResourceKey, List<ResourcesModelMapping>> mappings = mappingsByCampaignResource
                    .getOrDefault(request.getCampaignId(), Map.of());
            // Eligible resources for this request
            Set<Resource> requestEligibleResources = eligibleResourcesByRequest.getOrDefault(request.getId(), Set.of());
            // Group eligible changes by provider
            Map<Service, List<QuotaChangeRequest.Change>> eligibleChangesByProvider = request.getChanges().stream()
                    .filter(c -> requestEligibleResources.contains(c.getResource())).collect(Collectors
                            .groupingBy(c -> c.getResource().getService(), Collectors.toList()));
            // Map each provider separately
            eligibleChangesByProvider.forEach((provider, changes) -> {
                // Reuse overridable implementation to extract allocatable amounts
                Pair<Map<InternalKey, BigDecimal>, List<QuotaChangeRequest.Change>> processedChanges
                        = processChanges(request, provider, changes, mappings);
                Map<InternalKey, BigDecimal> sourceAmounts = processedChanges.getLeft();
                Map<InternalKey, DeliverableResource> deliverySources = new HashMap<>();
                // Expand resource and segments
                processedChanges.getLeft().keySet().forEach(key -> deliverySources.computeIfAbsent(key, k -> {
                    Resource resource = hierarchy.getResourceReader().read(k.getResourceId());
                    Set<Segment> segments = hierarchy.getSegmentReader().readByIds(k.getSegmentIds());
                    return new DeliverableResource(resource, segments);
                }));
                // Map each eligible amount
                sourceAmounts.forEach((sourceKey, amount) -> {
                    // Find matching mapping
                    InternalResourceKey sourceResourceKey = toInternalResourceKey(sourceKey);
                    List<ResourcesModelMapping> sourceMappings = mappings.getOrDefault(sourceResourceKey, List.of());
                    // Skip if no mappings
                    if (sourceMappings.isEmpty()) {
                        return;
                    }
                    DeliverableResource deliverySource = deliverySources.get(sourceKey);
                    Set<ExternalDeliverableResource> destinationsSet = destinations
                            .computeIfAbsent(deliverySource, k -> new HashSet<>());
                    // Check each mapping
                    sourceMappings.forEach(mapping -> {
                        // Skip if mapping is empty
                        if (mapping.getTarget().isEmpty()) {
                            return;
                        }
                        ResourceModelMappingTarget target = mapping.getTarget().get();
                        RoundingMode rounding = mapping.getRounding() != null
                                ? mapping.getRounding() : RoundingMode.HALF_UP;
                        // Skip if invalid mapping
                        if (target.getDenominator() <= 0 || target.getNumerator() < 0) {
                            return;
                        }
                        // Do actual mapping
                        BigDecimal mappedExternalAmount = calculateExternalAmount(amount, target, rounding);
                        if (mappedExternalAmount.compareTo(BigDecimal.ZERO) > 0) {
                            // Add resource as destination
                            destinationsSet.add(new ExternalDeliverableResource(target.getExternalResourceId()));
                        }
                    });
                });
            });
            destinationsByRequestId.put(request.getId(), new QuotaChangeRequestDeliveryMapping(destinations,
                    request));
        });
        return new QuotaChangeRequestsDeliveryMappings(destinationsByRequestId);
    }

    @Override
    public QuotaRequestDeliveryContext mapNonAllocatingToExternalResourcesWithAccounts(
            QuotaChangeRequest request, List<QuotaChangeRequest.Change> changes, Service provider,
            Person author, RequestQuotaValidatedBody validatedBody) {
        return mapNonAllocatingToExternalResources(
                request,
                changes,
                provider,
                author,
                validatedBody
        );
    }

    private QuotaRequestDeliveryContext mapNonAllocatingToExternalResources (
            QuotaChangeRequest request,
            Collection<QuotaChangeRequest.Change> changes,
            Service provider,
            Person author,
            RequestQuotaValidatedBody validatedBody) {
        if (!request.getType().equals(QuotaChangeRequest.Type.RESOURCE_PREORDER)) {
            throw new IllegalArgumentException("Invalid quota change request type");
        }
        QuotaRequestDelivery.Builder delivery = QuotaRequestDelivery.builder();
        delivery.id(UUID.randomUUID());
        delivery.authorId(author.getId());
        delivery.authorUid(author.getUid());
        Objects.requireNonNull(request.getProject().getAbcServiceId(), "Abc service id is required");
        delivery.abcServiceId(request.getProject().getAbcServiceId());
        delivery.quotaRequestId(request.getId());
        Objects.requireNonNull(request.getCampaignId(), "Campaign id is required");
        delivery.campaignId(request.getCampaignId());
        delivery.providerId(provider.getId());
        delivery.createdAt(Instant.now());
        delivery.resolved(false);
        delivery.resolveStatus(QuotaRequestDeliveryResolveStatus.IN_PROCESS);

        List<ResourcesModelMapping> mappings = getMappings(provider, request.getCampaignId());
        Map<InternalResourceKey, List<ResourcesModelMapping>> mappingByResource = mappings.stream()
                .collect(Collectors.groupingBy(SimpleResourceModelMapper::toInternalResourceKey));
        Map<ExternalKey, BigDecimal> externalAmounts = new HashMap<>();
        Map<ExternalKey, String> externalUnits = new HashMap<>();
        final Pair<Map<InternalKey, BigDecimal>, List<QuotaChangeRequest.Change>> internalAmountWithAffectedChanges =
                processChanges(request, provider, changes, mappingByResource);
        final Map<InternalKey, BigDecimal> internalAmounts = internalAmountWithAffectedChanges.getLeft();
        final Map<ExternalKey, InternalKey> internalKeyByExternalKey = new HashMap<>();
        internalAmounts.forEach((internalKey, amount) -> {
            final InternalResourceKey internalResourceKey = toInternalResourceKey(internalKey);
            final List<ResourcesModelMapping> resourcesModelMappings = mappingByResource.getOrDefault(internalResourceKey, List.of());
            if (resourcesModelMappings.isEmpty()) {
                return;
            }
            resourcesModelMappings.forEach(mapping -> {
                if (mapping.getTarget().isEmpty()) {
                    return;
                }
                ResourceModelMappingTarget target = mapping.getTarget().get();
                RoundingMode rounding = mapping.getRounding() != null ? mapping.getRounding() : RoundingMode.HALF_UP;
                if (target.getDenominator() <= 0) {
                    throw new IllegalArgumentException("Mapping denominator is invalid for " + mapping);
                }
                if (target.getNumerator() < 0) {
                    throw new IllegalArgumentException("Mapping numerator is invalid for " + mapping);
                }
                ExternalKey externalKey = toExternalKey(internalKey, target);
                BigDecimal currentExternalAmount = externalAmounts.getOrDefault(externalKey, BigDecimal.ZERO);
                BigDecimal mappedExternalAmount = calculateExternalAmount(amount, target, rounding);
                externalAmounts.put(externalKey, currentExternalAmount.add(mappedExternalAmount));
                internalKeyByExternalKey.put(externalKey, internalKey);
                String currentExternalUnit = externalUnits.get(externalKey);
                if (currentExternalUnit == null) {
                    externalUnits.put(externalKey, target.getExternalResourceBaseUnitKey());
                } else {
                    if (!currentExternalUnit.equals(target.getExternalResourceBaseUnitKey())) {
                        throw new IllegalArgumentException("Mapping unit is not "
                                + currentExternalUnit + " for " + mapping);
                    }
                }
            });
        });


        internalAmounts.forEach((k, v) -> {
            if (v.compareTo(BigDecimal.valueOf(Long.MAX_VALUE)) > 0) {
                throw new IllegalArgumentException("Amount overflow for " + k);
            }
            delivery.addInternalResource(new InternalResource(k.getResourceId(),
                    k.getSegmentIds(), k.getBigOrderId(), v.longValue()));
        });
        final Map<InternalKey, RequestQuotaValidatedBody.Change> validateChangeByInternalKey = validatedBody != null ?
                validatedBody.getChanges().stream()
                        .collect(Collectors.toMap(k -> toInternalKey(k.getChange()), Function.identity()))
                : null;
        externalAmounts.forEach((k, v) -> {
            if (v.compareTo(BigDecimal.valueOf(Long.MAX_VALUE)) > 0) {
                throw new IllegalArgumentException("Amount overflow for " + k);
            }
            String externalUnit = externalUnits.get(k);
            if (externalUnit == null) {
                throw new IllegalArgumentException("Missing unit for " + k);
            }
            if (v.compareTo(BigDecimal.ZERO) <= 0) {
                return;
            }
            UUID folderId = null;
            UUID accountId = null;
            UUID providerId = null;
            if (validatedBody != null) {
                RequestQuotaValidatedBody.Change validatedChange = validateChangeByInternalKey.get(
                        internalKeyByExternalKey.get(k)
                );
                folderId = UUID.fromString(validatedChange.getFolderId());
                accountId = UUID.fromString(validatedChange.getAccountId());
                providerId = UUID.fromString(validatedChange.getProviderId());
            }
            delivery.addExternalResource(new ExternalResource(k.getResourceId(),
                    k.getBigOrderId(), v.longValue(), externalUnit, folderId, accountId, providerId));
        });
        return QuotaRequestDeliveryContext.builder()
                .setQuotaRequestDelivery(delivery.build())
                .setAffectedChanges(internalAmountWithAffectedChanges.getRight())
                .build();
    }

    @NotNull
    protected BigDecimal calculateExternalAmount(
            BigDecimal internalAmount,
            ResourceModelMappingTarget target,
            RoundingMode rounding
    ) {
        return internalAmount.multiply(BigDecimal.valueOf(target.getNumerator()))
                .divide(BigDecimal.valueOf(target.getDenominator()), 0, rounding);
    }

    protected Pair<Map<InternalKey, BigDecimal>, List<QuotaChangeRequest.Change>> processChanges(QuotaChangeRequest request,
                                                                                                 Service provider,
                                                                                                 Collection<QuotaChangeRequest.Change> changes,
                                                                                                 Map<InternalResourceKey, List<ResourcesModelMapping>> mappings) {
        List<QuotaChangeRequest.Change> eligibleProviderChanges = changes.stream()
                .filter(eligibleToResourcesModelAllocation(provider))
                .collect(Collectors.toList());
        Map<InternalKey, BigDecimal> internalAmounts = new HashMap<>();
        List<QuotaChangeRequest.Change> affectedChanges = new ArrayList<>();
        for (QuotaChangeRequest.Change change : eligibleProviderChanges) {
            Objects.requireNonNull(change.getBigOrder(), "Change big order is required");
            InternalResourceKey changeInternalResourceKey = toInternalResourceKey(change);
            List<ResourcesModelMapping> resourceMappings = mappings
                    .getOrDefault(changeInternalResourceKey, List.of());
            if (resourceMappings.isEmpty()) {
                continue;
            }
            InternalKey internalChangeKey = toInternalKey(change);
            BigDecimal changeAmount = BigDecimal.valueOf(change.getAmountReady() - change.getAmountAllocating());
            internalAmounts.put(internalChangeKey, internalAmounts.getOrDefault(internalChangeKey, BigDecimal.ZERO)
                    .add(changeAmount));
            affectedChanges.add(change);
        }
        return Pair.of(internalAmounts, affectedChanges);
    }

    protected List<ResourcesModelMapping> getMappings(Service provider, long campaignId) {
        return mappingDao.getResourcesMappingsForProvider(provider, campaignId);
    }

    protected List<ResourcesModelMapping> getMappings(Set<Resource> resources, long campaignId) {
        return mappingDao.getResourcesMappings(resources, campaignId);
    }

    public static boolean hasNonAllocatingAmountReady(QuotaChangeRequest.Change change) {
        return change.getAmountReady() > change.getAmountAllocating();
    }

    public static Predicate<QuotaChangeRequest.Change> eligibleToResourcesModelAllocation(Service service) {
        return c -> c.getResource().getService().equals(service) && hasNonAllocatingAmountReady(c);
    }

    public static InternalResourceKey toInternalResourceKey(InternalKey internalKey) {
        return new InternalResourceKey(internalKey.getResourceId(), internalKey.getSegmentIds());
    }

    public static InternalResourceKey toInternalResourceKey(ResourcesModelMapping mapping) {
        return new InternalResourceKey(mapping.getResource().getId(), mapping.getSegments().stream()
                .map(LongIndexBase::getId).collect(Collectors.toSet()));
    }

    public static InternalResourceKey toInternalResourceKey(QuotaChangeRequest.Change change) {
        return new InternalResourceKey(change.getResource().getId(), change.getSegments().stream()
                .map(LongIndexBase::getId).collect(Collectors.toSet()));
    }

    public static InternalKey toInternalKey(QuotaChangeRequest.Change change) {
        return new InternalKey(change.getResource().getId(), change.getSegments().stream()
                .map(LongIndexBase::getId).collect(Collectors.toSet()), change.getBigOrder().getId());
    }

    public static InternalKey toInternalKey(InternalResource internalResource) {
        return new InternalKey(internalResource.getResourceId(), internalResource.getSegmentIds(),
                internalResource.getBigOrderId());
    }

    public static ExternalKey toExternalKey(QuotaChangeRequest.Change change, ResourceModelMappingTarget target) {
        return new ExternalKey(target.getExternalResourceId(), change.getBigOrder().getId());
    }

    public static ExternalKey toExternalKey(InternalKey internalKey, ResourceModelMappingTarget target) {
        return new ExternalKey(target.getExternalResourceId(), internalKey.getBigOrderId());
    }

    public static final class ExternalKey {

        private final UUID resourceId;
        private final long bigOrderId;

        private ExternalKey(UUID resourceId, long bigOrderId) {
            this.resourceId = resourceId;
            this.bigOrderId = bigOrderId;
        }

        public UUID getResourceId() {
            return resourceId;
        }

        public long getBigOrderId() {
            return bigOrderId;
        }

        @Override
        public boolean equals(Object o) {
            if (this == o) {
                return true;
            }
            if (o == null || getClass() != o.getClass()) {
                return false;
            }
            ExternalKey that = (ExternalKey) o;
            return bigOrderId == that.bigOrderId &&
                    Objects.equals(resourceId, that.resourceId);
        }

        @Override
        public int hashCode() {
            return Objects.hash(resourceId, bigOrderId);
        }

        @Override
        public String toString() {
            return "ExternalKey{" +
                    "resourceId=" + resourceId +
                    ", bigOrderId=" + bigOrderId +
                    '}';
        }

    }

    public static final class InternalKey {

        private final long resourceId;
        private final Set<Long> segmentIds;
        private final long bigOrderId;

        public InternalKey(long resourceId, Set<Long> segmentIds, long bigOrderId) {
            this.resourceId = resourceId;
            this.segmentIds = segmentIds;
            this.bigOrderId = bigOrderId;
        }

        public long getResourceId() {
            return resourceId;
        }

        public Set<Long> getSegmentIds() {
            return segmentIds;
        }

        public long getBigOrderId() {
            return bigOrderId;
        }

        @Override
        public boolean equals(Object o) {
            if (this == o) {
                return true;
            }
            if (o == null || getClass() != o.getClass()) {
                return false;
            }
            InternalKey that = (InternalKey) o;
            return resourceId == that.resourceId &&
                    bigOrderId == that.bigOrderId &&
                    Objects.equals(segmentIds, that.segmentIds);
        }

        @Override
        public int hashCode() {
            return Objects.hash(resourceId, segmentIds, bigOrderId);
        }

        @Override
        public String toString() {
            return "InternalKey{" +
                    "resourceId=" + resourceId +
                    ", segmentIds=" + segmentIds +
                    ", bigOrderId=" + bigOrderId +
                    '}';
        }

    }

    public static final class InternalResourceKey {

        private final long resourceId;
        private final Set<Long> segmentIds;

        private InternalResourceKey(long resourceId, Set<Long> segmentIds) {
            this.resourceId = resourceId;
            this.segmentIds = segmentIds;
        }

        public long getResourceId() {
            return resourceId;
        }

        public Set<Long> getSegmentIds() {
            return segmentIds;
        }

        @Override
        public boolean equals(Object o) {
            if (this == o) {
                return true;
            }
            if (o == null || getClass() != o.getClass()) {
                return false;
            }
            InternalResourceKey that = (InternalResourceKey) o;
            return resourceId == that.resourceId &&
                    Objects.equals(segmentIds, that.segmentIds);
        }

        @Override
        public int hashCode() {
            return Objects.hash(resourceId, segmentIds);
        }

        @Override
        public String toString() {
            return "InternalResourceKey{" +
                    "resourceId=" + resourceId +
                    ", segmentIds=" + segmentIds +
                    '}';
        }

    }

}
