package ru.yandex.qe.dispenser.ws;

import java.time.Instant;
import java.util.ArrayList;
import java.util.Collection;
import java.util.Collections;
import java.util.HashMap;
import java.util.HashSet;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.Optional;
import java.util.Set;
import java.util.UUID;
import java.util.function.Function;
import java.util.stream.Collectors;
import java.util.stream.Stream;

import javax.ws.rs.WebApplicationException;
import javax.ws.rs.core.Response;

import com.google.common.collect.HashMultimap;
import com.google.common.collect.ImmutableMap;
import com.google.common.collect.ImmutableSet;
import com.google.common.collect.ListMultimap;
import com.google.common.collect.Multimaps;
import com.google.common.collect.SetMultimap;
import com.google.common.collect.Sets;
import org.jetbrains.annotations.NotNull;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.stereotype.Component;
import org.springframework.util.StringUtils;
import ru.yandex.qe.dispenser.domain.Campaign;
import ru.yandex.qe.dispenser.domain.Person;
import ru.yandex.qe.dispenser.domain.Project;
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.d.DeliverableDeltaDto;
import ru.yandex.qe.dispenser.domain.d.DeliverableFolderOperationDto;
import ru.yandex.qe.dispenser.domain.d.DeliverableMetaRequestDto;
import ru.yandex.qe.dispenser.domain.d.DeliverableResponseDto;
import ru.yandex.qe.dispenser.domain.d.DeliveryAndProvideMetaRequestDto;
import ru.yandex.qe.dispenser.domain.d.DeliveryRequestDto;
import ru.yandex.qe.dispenser.domain.d.DeliveryResponseDto;
import ru.yandex.qe.dispenser.domain.d.ProvideDeliveryDto;
import ru.yandex.qe.dispenser.domain.d.ProvideOperationDto;
import ru.yandex.qe.dispenser.domain.d.ProvideOperationStatusDto;
import ru.yandex.qe.dispenser.domain.d.ProvideRequestDto;
import ru.yandex.qe.dispenser.domain.d.ProvideRequestedQuota;
import ru.yandex.qe.dispenser.domain.d.ProvideResponseDto;
import ru.yandex.qe.dispenser.domain.dao.delivery.DeliveryDao;
import ru.yandex.qe.dispenser.domain.dao.quota.request.QuotaChangeRequestDao;
import ru.yandex.qe.dispenser.domain.dao.resources_model.ResourcesModelMappingCache;
import ru.yandex.qe.dispenser.domain.dao.resources_model.ResourcesModelMappingDao;
import ru.yandex.qe.dispenser.domain.exception.MultiMessageException;
import ru.yandex.qe.dispenser.domain.exception.SingleMessageException;
import ru.yandex.qe.dispenser.domain.hierarchy.Hierarchy;
import ru.yandex.qe.dispenser.domain.hierarchy.Role;
import ru.yandex.qe.dispenser.domain.i18n.LocalizableString;
import ru.yandex.qe.dispenser.domain.resources_model.DeliverableResource;
import ru.yandex.qe.dispenser.domain.resources_model.DeliveryResult;
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.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.ResourcesModelMapping;
import ru.yandex.qe.dispenser.domain.util.Errors;
import ru.yandex.qe.dispenser.ws.allocation.AllocationError;
import ru.yandex.qe.dispenser.ws.allocation.ProviderAllocationInfo;
import ru.yandex.qe.dispenser.ws.allocation.ResourceRequestAllocationWorkflow;
import ru.yandex.qe.dispenser.ws.bot.Provider;
import ru.yandex.qe.dispenser.ws.d.DApiHelper;
import ru.yandex.qe.dispenser.ws.intercept.TransactionWrapper;
import ru.yandex.qe.dispenser.ws.quota.request.workflow.context.PerformerContext;
import ru.yandex.qe.dispenser.ws.reqbody.RequestQuotaAllocationBody;
import ru.yandex.qe.dispenser.ws.reqbody.RequestQuotaValidatedBody;
import ru.yandex.qe.dispenser.ws.resources_model.ResourcesModelMapper;
import ru.yandex.qe.dispenser.ws.resources_model.ResourcesModelMapperManager;
import ru.yandex.qe.dispenser.ws.resources_model.SimpleResourceModelMapper;

import static org.springframework.util.CollectionUtils.isEmpty;

@Component
public class ResourceRequestAllocationManager {

    private static final Logger LOG = LoggerFactory.getLogger(ResourceRequestAllocationManager.class);

    private static final ImmutableSet<String> PROVIDERS_FOR_YP_ALLOCATION = ImmutableSet.of(
            Provider.GEN_CFG.getServiceKey(),
            Provider.QLOUD.getServiceKey()
    );

    private final Map<Provider, ResourceRequestAllocationWorkflow> strategyByProviderKey;
    private final ResourcePreorderChangeManager preorderChangeManager;
    private final ResourcesModelMapperManager resourcesModelMapperManager;
    private final ResourcesModelMappingDao resourcesModelMappingDao;
    private final DeliveryDao deliveryDao;
    private final DApiHelper dApiHelper;
    private final QuotaChangeRequestDao requestDao;
    private final ResourcesModelMappingCache resourcesModelMappingCache;

    public ResourceRequestAllocationManager(
            final List<ResourceRequestAllocationWorkflow> resourceRequestAllocationWorkflow,
            final ResourcePreorderChangeManager preorderChangeManager,
            final ResourcesModelMapperManager resourcesModelMapperManager,
            final ResourcesModelMappingDao resourcesModelMappingDao,
            final DeliveryDao deliveryDao,
            final DApiHelper dApiHelper,
            final QuotaChangeRequestDao requestDao,
            final ResourcesModelMappingCache resourcesModelMappingCache) {

        strategyByProviderKey = resourceRequestAllocationWorkflow.stream()
                .collect(Collectors.collectingAndThen(Collectors.toMap(
                                ResourceRequestAllocationWorkflow::getProvider,
                                Function.identity()),
                        ImmutableMap::copyOf));


        this.preorderChangeManager = preorderChangeManager;
        this.resourcesModelMapperManager = resourcesModelMapperManager;
        this.resourcesModelMappingDao = resourcesModelMappingDao;
        this.deliveryDao = deliveryDao;
        this.dApiHelper = dApiHelper;
        this.requestDao = requestDao;
        this.resourcesModelMappingCache = resourcesModelMappingCache;
    }

    public QuotaRequestDeliveryContext dryRunAllocation(final QuotaChangeRequest request, final Service provider,
                                                        final Person performer, final boolean allocateAllResources) {
        if (!ResourceRequestAllocationManager.canUserAllocateQuota(request, performer)) {
            throw SingleMessageException.forbidden("only.request.author.or.manager.can.allocate.quota");
        }
        Errors<LocalizableString> errors = new Errors<>();
        final Optional<ResourcesModelMapper> mapperForProvider =
                resourcesModelMapperManager.getMapperForProvider(provider);

        if (mapperForProvider.isEmpty()) {
            errors.add(LocalizableString.of("no.allocation.bean.for.resources.model.provider", provider.getName()));
        }
        if (!provider.getSettings().isManualQuotaAllocation()) {
            errors.add(LocalizableString.of("manual.allocation.unavailable.for", provider.getName()));
        }
        if (Objects.requireNonNull(request.getCampaign()).getType() != Campaign.Type.AGGREGATED) {
            errors.add(LocalizableString.of("only.aggregated.campaign.request.can.be.allocated"));
        }
        if (errors.hasErrors()) {
            throw MultiMessageException.illegalArgument(errors);
        }

        final ResourcesModelMapper resourcesModelMapper = mapperForProvider.orElseThrow();
        Stream<QuotaChangeRequest.Change> changeStream = request.getChanges()
                .stream()
                .filter(c -> c.getResource().getService().equals(provider));
        if (allocateAllResources) {
            changeStream = changeStream
                    .map(c -> c.copyBuilder()
                            .amountReady(c.getAmount())
                            .amountAllocating(c.getAmountAllocated())
                            .build());
        }
        final List<QuotaChangeRequest.Change> changes = changeStream.collect(Collectors.toList());
        return resourcesModelMapper.mapNonAllocatingToExternalResources(request, changes, provider, performer);
    }

    public void onManualAllocationRequest(final QuotaChangeRequest request, final Person performer,
                                          final boolean suppressSummon) {
        if (request.getCampaignId() == null) {
            throw SingleMessageException.illegalArgument(LocalizableString.of("request.cannot.be.allocated.without.campaign"));
        }

        final Set<ResourceKey> mappings = resourcesModelMappingDao
                .getResourcesMappingsForCampaign(request.getCampaignId())
                .stream()
                .map(ResourceKey::from)
                .collect(Collectors.toSet());

        final Set<Service> requestServices = request.getChanges()
                .stream()
                .map(c -> c.getResource().getService())
                .collect(Collectors.toSet());

        final Errors<AllocationError> allAllocationErrors = new Errors<>();
        boolean allocated = false;
        for (Service service : requestServices) {
            final Errors<AllocationError> errors = canUserAllocateReadyQuotaInService(performer, request, service,
                    mappings);
            allAllocationErrors.add(errors);
            if (errors.isEmpty()) {
                onManualAllocationRequestService(request, service, performer, suppressSummon);
                allocated = true;
            }
        }

        if (!allocated) {
            throw MultiMessageException.forbidden(allAllocationErrors.map(AllocationError::getMessage));
        }
    }

    private Set<ResourceKey> resourcesWithMappings(Service service, long campaignId) {
        final List<ResourcesModelMapping> resourcesMappingsForProvider = resourcesModelMappingDao.getResourcesMappingsForProvider(service, campaignId);
        return resourcesMappingsForProvider.stream()
                .map(ResourceKey::from)
                .collect(Collectors.toSet());
    }

    public void onManualAllocationRequestService(final QuotaChangeRequest request,
                                                 final Service service,
                                                 final Person performer,
                                                 final boolean suppressSummon) {

        final Errors<AllocationError> errors = canUserAllocateReadyQuotaInService(performer, request, service);
        if (errors.hasErrors()) {
            throw MultiMessageException.forbidden(errors.map(AllocationError::getMessage));
        }

        final Optional<ResourceRequestAllocationWorkflow> strategy = getWorkflow(service);

        if (request.getCampaignId() == null) {
            return;
        }

        strategy.ifPresent(resourceRequestAllocationWorkflow -> TransactionWrapper.INSTANCE.execute(() -> {
            final QuotaRequestChangeValues changeValues = new QuotaRequestChangeValues();

            final Set<ResourceKey> resourcesWithMappings = resourcesWithMappings(service, request.getCampaignId());
            final Set<QuotaChangeRequest.Change> changesToAllocateOldWay = requestDao
                    .readChangesByRequestAndProviderForUpdate(request.getId(), service.getId())
                    .stream()
                    .filter(c -> !resourcesWithMappings.contains(ResourceKey.from(c)))
                    .filter(preorderChangeManager::isResourceCanBeAllocated)
                    .peek(c -> changeValues.setAllocating(request, c.getId(), c.getAmountReady()))
                    .collect(Collectors.toSet());
            if (changesToAllocateOldWay.isEmpty()) {
                return;
            }
            preorderChangeManager.updateRequestChanges(new PerformerContext(performer), changeValues, suppressSummon);
            resourceRequestAllocationWorkflow.onAllocationRequest(request, changesToAllocateOldWay, performer, suppressSummon);
        }));

        allocateToResourcesModel(request, service, performer, suppressSummon);
    }

    public void onManualAllocationRequestServiceAccount(final QuotaChangeRequest request,
                                                        final Service service,
                                                        final Person performer,
                                                        final boolean suppressSummon,
                                                        final RequestQuotaAllocationBody body) {
        final Errors<AllocationError> errors = canUserAllocateReadyQuotaInService(performer, request, service);
        if (errors.hasErrors()) {
            throw MultiMessageException.forbidden(errors.map(AllocationError::getMessage));
        }

        if (body == null) {
            throw SingleMessageException.illegalArgument(LocalizableString.of("request.allocation.service.account.body.is.required"));
        }

        if (request.getCampaignId() == null) {
            throw SingleMessageException.illegalArgument(LocalizableString.of("request.cannot.be.allocated.without.campaign"));
        }

        if (Objects.requireNonNull(request.getCampaign()).getType() != Campaign.Type.AGGREGATED) {
            throw SingleMessageException.illegalArgument(LocalizableString.of("only.aggregated.campaign.request.can.be.allocated"));
        }

        RequestQuotaValidatedBody validatedBody = validateRequestQuotaAllocationBody(request, body, service);

        if (validatedBody.getValidationErrors().hasErrors()) {
            throw MultiMessageException.illegalArgument(validatedBody.getValidationErrors().map(AllocationError::getMessage));
        }

        allocateOldWay(request, service, performer, suppressSummon, validatedBody);

        allocateToResourcesModelWithAccounts(request, validatedBody, service, performer, suppressSummon);
    }

    private Errors<AllocationError> validateChangesIsMarkedUpRight(Set<ResourceKey> resourcesWithMappings,
                                                                   Set<QuotaChangeRequest.ChangeKey> changeKeysFromBody,
                                                                   List<QuotaChangeRequest.Change> changesFromRequest) {

        Errors<AllocationError> resourceMappingErrors = new Errors<>();
        changesFromRequest.forEach(change -> {
            if (resourcesWithMappings.contains(ResourceKey.from(change))
                    && change.getAmountReady() > change.getAmountAllocated()
                    && change.getAmountAllocating() == change.getAmountAllocated()
                    && !changeKeysFromBody.contains(change.getKey())) {
                resourceMappingErrors.add(AllocationError.notTransientError(LocalizableString.of(
                        "request.allocation.service.account.required", change.getKey().toString())));
            }
            if (!resourcesWithMappings.contains(ResourceKey.from(change)) && changeKeysFromBody.contains(change.getKey())) {
                resourceMappingErrors.add(AllocationError.notTransientError(LocalizableString.of(
                        "request.allocation.service.can.not.be.allocated.to.d", change.getKey().toString())));
            }
        });

        return resourceMappingErrors;
    }

    private RequestQuotaValidatedBody validateRequestQuotaAllocationBody(
            QuotaChangeRequest request, RequestQuotaAllocationBody body, Service service) {
        Map<ChangeKey, QuotaChangeRequest.Change> changeByKey = request.getChanges().stream()
                .collect(Collectors.toMap(v -> new ChangeKey(v, service.getKey()), Function.identity()));

        RequestQuotaValidatedBody.Builder builder = RequestQuotaValidatedBody.builder();
        List<RequestQuotaAllocationBody.Change> changes = body.getChanges();

        if (isEmpty(changes)) {
            return builder.error(AllocationError.notTransientError(LocalizableString.of(
                    "request.allocation.service.account.body.changes.empty"))).build();
        }

        if (!service.getSettings().isManualQuotaAllocation()) {
            return builder.error(AllocationError.notTransientError(LocalizableString.of(
                    "request.allocation.service.account.service.not.support.manual.allocation"))).build();
        }

        Set<ChangeKey> changesSet = new HashSet<>();
        Map<String, String> folderByAccountMap = new HashMap<>();

        for (int i = 0; i < changes.size(); i++) {
            RequestQuotaAllocationBody.Change change = changes.get(i);

            String accountId = change.getAccountId();
            String folderId = change.getFolderId();
            String providerId = change.getProviderId();
            String v = folderByAccountMap.get(accountId);

            if (v == null) {
                folderByAccountMap.put(accountId, folderId);
            } else {
                if (!v.equals(folderId)) {
                    builder.error(AllocationError.notTransientError(LocalizableString.of(
                            "request.allocation.service.account.body.change.i.account.different.folder", i)));
                }
            }

            ChangeKey changeKey = new ChangeKey(change, service.getKey());
            if (!changesSet.add(changeKey)) {
                builder.error(AllocationError.notTransientError(LocalizableString.of(
                        "request.allocation.service.account.body.change.i.duplicate", i)));
                continue;
            }

            Errors<AllocationError> errors = new Errors<>();

            if (!isValidUUID(accountId)) {
                errors.add(AllocationError.notTransientError(LocalizableString.of(
                        "request.allocation.service.account.body.change.i.account.not.fount", i)));
            }

            if (!isValidUUID(folderId)) {
                errors.add(AllocationError.notTransientError(LocalizableString.of(
                        "request.allocation.service.account.body.change.i.folder.not.found", i)));
            }

            if (!isValidUUID(providerId)) {
                errors.add(AllocationError.notTransientError(LocalizableString.of(
                        "request.allocation.service.account.body.change.i.provider.not.found", i)));
            }

            if (!changeByKey.containsKey(changeKey)) {
                errors.add(AllocationError.notTransientError(LocalizableString.of(
                        "request.allocation.service.account.body.change.i.not.found", i)));
            } else {
                QuotaChangeRequest.Change qrChange = changeByKey.get(changeKey);

                if (qrChange.getAmountAllocated() >= qrChange.getAmountReady()) {
                    errors.add(AllocationError.transientError(LocalizableString.of(
                            "request.allocation.service.account.body.change.i.allocated.more.than.ready", i)));
                }

                if (qrChange.getAmountAllocating() != qrChange.getAmountAllocated()) {
                    errors.add(AllocationError.transientError(LocalizableString.of(
                            "request.allocation.service.account.body.change.i.allocation.already.in.process", i)));
                }

                if (!service.getKey().equals(qrChange.getResource().getService().getKey())) {
                    errors.add(AllocationError.notTransientError(LocalizableString.of(
                            "request.allocation.service.account.body.change.i.wrong.service", i)));
                }

                if (!errors.hasErrors()) {
                    builder.change(RequestQuotaValidatedBody.Change.builder()
                            .change(qrChange)
                            .accountId(accountId)
                            .folderId(folderId)
                            .providerId(providerId)
                            .build());
                }
            }

            builder.errors(errors);
        }

        return builder.build();
    }

    @SuppressWarnings("BooleanMethodIsAlwaysInverted")
    private boolean isValidUUID(String uuid) {
        if (StringUtils.isEmpty(uuid) || uuid.isBlank()) {
            return false;
        }
        boolean valid = false;
        try {
            //noinspection ResultOfMethodCallIgnored
            UUID.fromString(uuid);
            valid = true;
        } catch (IllegalArgumentException ignored) {}

        return valid;
    }

    private void allocateOldWay(QuotaChangeRequest request, Service service, Person performer, boolean suppressSummon,
                                RequestQuotaValidatedBody validatedBody) {
        final Optional<ResourceRequestAllocationWorkflow> strategy = getWorkflow(service);
        strategy.ifPresent(resourceRequestAllocationWorkflow -> TransactionWrapper.INSTANCE.execute(() -> {
            final QuotaRequestChangeValues changeValues = new QuotaRequestChangeValues();

            final Set<ResourceKey> resourcesWithMappings = resourcesWithMappings(service, Objects.requireNonNull(
                    request.getCampaignId()));

            Set<QuotaChangeRequest.ChangeKey> changeKeys = validatedBody.getChanges().stream()
                    .map(change -> change.getChange().getKey())
                    .collect(Collectors.toSet());

            List<QuotaChangeRequest.Change> readChangesByRequestAndProviderForUpdate = requestDao
                    .readChangesByRequestAndProviderForUpdate(request.getId(), service.getId());

            Errors<AllocationError> resourceMappingErrors = validateChangesIsMarkedUpRight(resourcesWithMappings, changeKeys,
                    readChangesByRequestAndProviderForUpdate);

            if (resourceMappingErrors.hasErrors()) {
                throw MultiMessageException.illegalArgument(resourceMappingErrors.map(AllocationError::getMessage));
            }

            final Set<QuotaChangeRequest.Change> changesToAllocateOldWay = readChangesByRequestAndProviderForUpdate.stream()
                    .filter(c -> !resourcesWithMappings.contains(ResourceKey.from(c)))
                    .filter(preorderChangeManager::isResourceCanBeAllocated)
                    .peek(c -> changeValues.setAllocating(request, c.getId(), c.getAmountReady()))
                    .collect(Collectors.toSet());
            if (changesToAllocateOldWay.isEmpty()) {
                return;
            }
            preorderChangeManager.updateRequestChanges(new PerformerContext(performer), changeValues, suppressSummon);
            resourceRequestAllocationWorkflow.onAllocationRequest(request, changesToAllocateOldWay, performer, suppressSummon);
        }));
    }

    private void allocateToResourcesModelWithAccounts(QuotaChangeRequest request,
                                                      RequestQuotaValidatedBody validatedBody,
                                                      Service service, Person performer, boolean suppressSummon) {
        final PerformerContext performerContext = new PerformerContext(performer);

        final Optional<ResourcesModelMapper> mapperForProvider = resourcesModelMapperManager.getMapperForProvider(service);
        if (mapperForProvider.isPresent()) {
            final Optional<QuotaRequestDelivery> requestDelivery = TransactionWrapper.INSTANCE.execute(() -> {
                final Set<ResourceKey> resourcesWithMappings = resourcesWithMappings(service, Objects.requireNonNull(
                        request.getCampaignId()));
                Set<QuotaChangeRequest.ChangeKey> changeKeySet = validatedBody.getChanges().stream()
                        .map(change -> change.getChange().getKey())
                        .collect(Collectors.toSet());
                final List<QuotaChangeRequest.Change> lockedChanges = requestDao
                        .readChangesByRequestAndProviderForUpdate(request.getId(), service.getId());
                Errors<AllocationError> errors = validateChangesIsMarkedUpRight(resourcesWithMappings, changeKeySet,
                        lockedChanges);
                if (errors.hasErrors()) {
                    throw MultiMessageException.forbidden(errors.map(AllocationError::getMessage));
                }

                List<QuotaChangeRequest.Change> changesToUpdate = lockedChanges.stream()
                        .filter(change -> change.getAmountReady() > change.getAmountAllocated()
                                && change.getAmountAllocating() == change.getAmountAllocated())
                        .collect(Collectors.toList());

                final ResourcesModelMapper resourcesModelMapper = mapperForProvider.get();
                final QuotaRequestDeliveryContext deliveryContext = resourcesModelMapper
                        .mapNonAllocatingToExternalResourcesWithAccounts(request, changesToUpdate, service, performer,
                                validatedBody);

                if (deliveryContext.getAffectedChanges().isEmpty()) {
                    return Optional.empty();
                }

                deliveryDao.create(deliveryContext.getQuotaRequestDelivery());

                final QuotaRequestChangeValues allocatingUpdate = new QuotaRequestChangeValues();
                for (QuotaChangeRequest.Change change : deliveryContext.getAffectedChanges()) {
                    allocatingUpdate.setAllocating(request, change.getId(), change.getAmountReady());
                }
                preorderChangeManager.updateRequestChanges(performerContext, allocatingUpdate, suppressSummon);

                return Optional.of(deliveryContext.getQuotaRequestDelivery());
            });

            if (Objects.requireNonNull(requestDelivery).isEmpty()) {
                return;
            }

            final Errors<LocalizableString> errors = deliverInDWithAccountsTransactional(request, requestDelivery.get(),
                    performerContext, suppressSummon);

            if (errors.hasErrors()) {
                throw MultiMessageException.unknown(errors);
            }
        } else {
            throw MultiMessageException.unknown(Errors.of(
                    LocalizableString.of("request.allocation.service.account.service.not.support.manual.allocation")));
        }
    }

    private Errors<LocalizableString> deliverInDWithAccountsTransactional(
            QuotaChangeRequest request,
            QuotaRequestDelivery quotaRequestDelivery,
            PerformerContext performerContext, boolean suppressSummon) {

        ProvideResponseDto provideResponseDto;
        ProvideRequestDto provideRequest = toProvideRequestDto(quotaRequestDelivery);
        try {
            provideResponseDto = dApiHelper.provide(provideRequest);
        } catch (Exception exception) {
            return dApiErrorHandler(request, exception);
        }

        if (provideResponseDto == null || provideResponseDto.getOperations()
                .isEmpty()) {
            LOG.error("Empty operations field for provide response {}", provideRequest.getDeliveryId());
            return Errors.of(LocalizableString.of("incorrect.resources.model.response"));
        }

        return TransactionWrapper.INSTANCE.execute(() -> {
            final QuotaRequestDelivery lockedDelivery = deliveryDao.readForUpdate(quotaRequestDelivery.getId());
            if (lockedDelivery.getResolveStatus() == QuotaRequestDeliveryResolveStatus.RESOLVED) {
                return Errors.of();
            }

            final List<QuotaChangeRequest.Change> lockedChanges = requestDao
                    .readChangesByRequestAndProviderForUpdate(request.getId(), lockedDelivery.getProviderId());

            boolean isResolved = true;
            final QuotaRequestDelivery.Builder updatedDeliveryBuilder = lockedDelivery.toBuilder();
            for (ProvideOperationDto operation : provideResponseDto.getOperations()) {
                if (operation.getStatus() != ProvideOperationStatusDto.SUCCESS) {
                    isResolved = false;
                }

                for (ProvideRequestedQuota requestedQuota : operation.getRequestedQuotas()) {
                    String accountId = operation.getAccountId();
                    if (accountId == null) {
                        LOG.error("Null accountId field for provide response {} ", provideRequest.getDeliveryId());
                    }

                    Set<DeliverableFolderOperationDto> folderOperationLogs = operation.getFolderOperationLog();

                    if (folderOperationLogs == null || folderOperationLogs.isEmpty()) {
                        LOG.error("Null folderOperationLog field for provide response {} ", provideRequest.getDeliveryId());
                    }

                    DeliveryAndProvideMetaRequestDto meta = operation.getMeta();
                    if (meta == null) {
                        LOG.error("Null meta field for provide response {} ", provideRequest.getDeliveryId());
                    }

                    addNullToEmptyCollection(folderOperationLogs).forEach(folderOperationLog ->
                            addNullToEmptyCollection(meta != null ? meta.getBigOrderIds() : null).forEach(bigOrderId -> {
                                final DeliveryResult deliveryResult = DeliveryResult.builder()
                                        .folderId(folderOperationLog != null ? UUID.fromString(folderOperationLog.getFolderId()) : null)
                                        .bigOrderId(bigOrderId != null ? bigOrderId : -1)
                                        .resourceId(UUID.fromString(requestedQuota.getResourceId()))
                                        .folderOperationId(folderOperationLog != null
                                                ? UUID.fromString(folderOperationLog.getId())
                                                : null)
                                        .timestamp(folderOperationLog != null ? folderOperationLog.getTimestamp() : null)
                                        .accountId(accountId != null ? UUID.fromString(accountId) : null)
                                        .accountOperationId(UUID.fromString(operation.getOperationId()))
                                        .build();
                                updatedDeliveryBuilder.addDeliveryResult(deliveryResult);
                            })
                    );
                }
            }

            final QuotaRequestDelivery updatedDelivery = updatedDeliveryBuilder
                    .resolved(isResolved)
                    .resolveStatus(isResolved ? QuotaRequestDeliveryResolveStatus.RESOLVED :
                            QuotaRequestDeliveryResolveStatus.IN_ALLOCATING_PROCESS)
                    .resolvedAt(Instant.now())
                    .build();

            deliveryDao.update(updatedDelivery);

            final QuotaRequestChangeValues changeValues = getChangeValuesWithAllocated(request, lockedChanges, updatedDelivery);

            preorderChangeManager.updateRequestChanges(performerContext, changeValues, suppressSummon);
            requestDao.setShowAllocationNote(List.of(request.getId()), true);
            return Errors.of();
        });
    }

    @NotNull
    private Errors<LocalizableString> dApiErrorHandler(QuotaChangeRequest request, Exception exception) {
        final Throwable cause = exception.getCause();
        LOG.error("Exception on delivery request for id {}", request.getId(), exception);
        if (cause instanceof WebApplicationException) {
            final Response response = ((WebApplicationException) cause).getResponse();
            final String entity = response.hasEntity() ? response.readEntity(String.class) : "no response";
            return Errors.of(LocalizableString.of("unknown.resources.model.response", response.getStatus(), entity));
        } else {
            return Errors.of(LocalizableString.of("unknown.resources.model.delivery.error"));
        }
    }
    @NotNull
    private ProvideRequestDto toProvideRequestDto(QuotaRequestDelivery quotaRequestDelivery) {
        ProvideRequestDto.Builder builder = ProvideRequestDto.builder();

        builder.deliveryId(quotaRequestDelivery.getId().toString())
                .authorUid(Long.toString(quotaRequestDelivery.getAuthorUid()));

        long abcServiceId = quotaRequestDelivery.getAbcServiceId();
        List<ProvideDeliveryDto> deliveryList = new ArrayList<>();
        for (ExternalResource externalResource : quotaRequestDelivery.getExternalResources()) {
            ProvideDeliveryDto.Builder deliveryBuilder = ProvideDeliveryDto.builder();

            deliveryBuilder.serviceId(abcServiceId)
                    .providerId(Objects.requireNonNull(externalResource.getProviderId()).toString())
                    .folderId(Objects.requireNonNull(externalResource.getFolderId()).toString())
                    .accountId(Objects.requireNonNull(externalResource.getAccountId()).toString())
                    .resourceId(externalResource.getResourceId().toString())
                    .delta(DeliverableDeltaDto.builder()
                            .amount(externalResource.getAmount())
                            .unitKey(externalResource.getUnitKey())
                            .build())
                    .meta(DeliverableMetaRequestDto.builder()
                            .bigOrderId(externalResource.getBigOrderId())
                            .campaignId(quotaRequestDelivery.getCampaignId())
                            .quotaRequestId(quotaRequestDelivery.getQuotaRequestId())
                            .build());

            deliveryList.add(deliveryBuilder.build());
        }

        builder.deliverables(deliveryList);

        return builder.build();
    }

    private void allocateToResourcesModel(final QuotaChangeRequest request,
                                          final Service service,
                                          final Person performer,
                                          final boolean suppressSummon) {
        final List<QuotaRequestDelivery> unresolvedDeliveries = deliveryDao
                .getUnresolvedByQuotaRequestIdAndProviderId(request.getId(), service.getId());
        final PerformerContext performerContext = new PerformerContext(performer);

        final Errors<LocalizableString> retryErrors = Errors.of();
        if (!unresolvedDeliveries.isEmpty()) {
            for (QuotaRequestDelivery unresolvedDelivery : unresolvedDeliveries) {
                LOG.info("Retrying unresolved delivery {}", unresolvedDelivery.getId());
                final Errors<LocalizableString> errors = deliverInDTransactional(request, unresolvedDelivery,
                        performerContext, suppressSummon);
                retryErrors.add(errors);
            }
        }

        final Optional<ResourcesModelMapper> mapperForProvider = resourcesModelMapperManager.getMapperForProvider(service);

        if (mapperForProvider.isPresent()) {
            final Optional<QuotaRequestDelivery> requestDelivery = TransactionWrapper.INSTANCE.execute(() -> {
                final ResourcesModelMapper resourcesModelMapper = mapperForProvider.get();
                final QuotaRequestChangeValues allocatingUpdate = new QuotaRequestChangeValues();
                final List<QuotaChangeRequest.Change> lockedChanges = requestDao
                        .readChangesByRequestAndProviderForUpdate(request.getId(), service.getId());
                final QuotaRequestDeliveryContext deliveryContext = resourcesModelMapper
                        .mapNonAllocatingToExternalResources(request, lockedChanges, service, performer);
                if (deliveryContext.getAffectedChanges().isEmpty()) {
                    return Optional.empty();
                }
                deliveryDao.create(deliveryContext.getQuotaRequestDelivery());
                for (QuotaChangeRequest.Change change : deliveryContext.getAffectedChanges()) {
                    allocatingUpdate.setAllocating(request, change.getId(), change.getAmountReady());
                }
                preorderChangeManager.updateRequestChanges(performerContext, allocatingUpdate, suppressSummon);

                return Optional.of(deliveryContext.getQuotaRequestDelivery());
            });
            if (Objects.requireNonNull(requestDelivery).isEmpty()) {
                LOG.info("No available resources found for resources model delivery");
                if (retryErrors.hasErrors()) {
                    throw MultiMessageException.unknown(retryErrors);
                }
                return;
            }

            final Errors<LocalizableString> errors = deliverInDTransactional(request, requestDelivery.get(),
                    performerContext, suppressSummon);

            if (errors.hasErrors()) {
                throw MultiMessageException.unknown(errors.add(retryErrors));
            }
        } else if (retryErrors.hasErrors()) {
            throw MultiMessageException.unknown(retryErrors);
        }
    }

    private Errors<LocalizableString> deliverInDTransactional(QuotaChangeRequest request,
                                                              QuotaRequestDelivery requestDelivery,
                                                              PerformerContext performerContext,
                                                              boolean suppressSummon) {
        final DeliveryRequestDto deliveryRequestDto = DeliveryRequestDto.from(requestDelivery);
        final DeliveryResponseDto responseDto;
        try {
            responseDto = dApiHelper.deliver(deliveryRequestDto);
        } catch (Exception exception) {
            return dApiErrorHandler(request, exception);
        }

        if (responseDto.getDeliverables().isEmpty()) {
            LOG.error("Empty deliverables field for delivery response {}", requestDelivery.getId());
            return Errors.of(LocalizableString.of("incorrect.resources.model.response"));
        }

        return TransactionWrapper.INSTANCE.execute(() -> {
            final QuotaRequestDelivery lockedDelivery = deliveryDao.readForUpdate(requestDelivery.getId());
            if (lockedDelivery.getResolveStatus() == QuotaRequestDeliveryResolveStatus.RESOLVED) {
                return Errors.of();
            }

            final List<QuotaChangeRequest.Change> lockedChanges = requestDao
                    .readChangesByRequestAndProviderForUpdate(request.getId(), lockedDelivery.getProviderId());

            final Set<SimpleResourceModelMapper.InternalKey> availableChangesKeys = lockedChanges.stream()
                    .filter(c -> c.getAmountAllocating() > c.getAmountAllocated())
                    .map(SimpleResourceModelMapper::toInternalKey)
                    .collect(Collectors.toSet());

            final Set<SimpleResourceModelMapper.InternalKey> deliveryKeys = lockedDelivery.getInternalResources()
                    .stream()
                    .map(SimpleResourceModelMapper::toInternalKey)
                    .collect(Collectors.toSet());

            final Set<SimpleResourceModelMapper.InternalKey> missingKeys = Sets.difference(deliveryKeys, availableChangesKeys);
            if (!missingKeys.isEmpty()) {
                LOG.warn("Missing changes for delivery {}: {}", lockedDelivery.getId(), missingKeys);
            }

            final QuotaRequestDelivery.Builder updatedDeliveryBuilder = lockedDelivery.toBuilder();
            for (DeliverableResponseDto deliverableResponseDto : responseDto.getDeliverables()) {
                final DeliveryResult deliveryResult = DeliveryResult.builder()
                        .resourceId(UUID.fromString(deliverableResponseDto.getResourceId()))
                        .bigOrderId(deliverableResponseDto.getMeta().getBigOrderId())
                        .folderId(deliverableResponseDto.getFolderId().map(UUID::fromString).orElse(null))
                        .folderOperationId(UUID.fromString(deliverableResponseDto.getFolderOperationLog().getId()))
                        .timestamp(deliverableResponseDto.getFolderOperationLog().getTimestamp())
                        .build();
                updatedDeliveryBuilder.addDeliveryResult(deliveryResult);
            }
            final QuotaRequestDelivery updatedDelivery = updatedDeliveryBuilder
                    .resolved(true)
                    .resolveStatus(QuotaRequestDeliveryResolveStatus.RESOLVED)
                    .resolvedAt(Instant.now())
                    .build();

            deliveryDao.update(updatedDelivery);

            final QuotaRequestChangeValues changeValues = getChangeValuesWithAllocated(request, lockedChanges, updatedDelivery);

            preorderChangeManager.updateRequestChanges(performerContext, changeValues, suppressSummon);
            requestDao.setShowAllocationNote(List.of(request.getId()), true);
            return Errors.of();
        });
    }

    @NotNull
    private QuotaRequestChangeValues getChangeValuesWithAllocated(QuotaChangeRequest request,
                                                                  List<QuotaChangeRequest.Change> lockedChanges,
                                                                  QuotaRequestDelivery updatedDelivery) {
        final Map<SimpleResourceModelMapper.InternalKey, QuotaChangeRequest.Change> changeByInternalKey = lockedChanges
                .stream()
                .collect(Collectors.toMap(SimpleResourceModelMapper::toInternalKey, Function.identity()));

        final QuotaRequestChangeValues changeValues = new QuotaRequestChangeValues();
        for (InternalResource internalResource : updatedDelivery.getInternalResources()) {
            final SimpleResourceModelMapper.InternalKey internalKey = SimpleResourceModelMapper
                    .toInternalKey(internalResource);

            final QuotaChangeRequest.Change change = changeByInternalKey.get(internalKey);
            if (change != null) {
                final long delta = Math.min(internalResource.getAmount(),
                        Math.max(change.getAmountAllocating() - change.getAmountAllocated(), 0L));
                changeValues.setAllocated(request, change, change.getAmountAllocated() + delta);
            }
        }
        return changeValues;
    }


    private Optional<ResourceRequestAllocationWorkflow> getWorkflow(Service service) {
        final Optional<Provider> provider;
        if (PROVIDERS_FOR_YP_ALLOCATION.contains(service.getKey())) {
            provider = Optional.of(Provider.YP);
        } else {
            provider = Provider.fromService(service);
        }
        return provider.map(strategyByProviderKey::get);
    }

    public SetMultimap<Long, ProviderAllocationInfo> providerToAllocate(Collection<QuotaChangeRequest> requests, Person person) {
        final SetMultimap<Long, ProviderAllocationInfo> providersToAllocateByRequestId = HashMultimap.create();
        final Set<Long> usedCampaigns = requests.stream()
                .map(QuotaChangeRequest::getCampaignId)
                .filter(Objects::nonNull)
                .collect(Collectors.toSet());

        final ListMultimap<Long, ResourcesModelMapping> mappingsByCampaignId = Multimaps.index(
                resourcesModelMappingDao.getResourcesMappingsForCampaigns(usedCampaigns),
                ResourcesModelMapping::getCampaignId
        );

        for (QuotaChangeRequest request : requests) {
            if (request.getCampaignId() == null) {
                continue;
            }

            final Set<ResourceKey> resourcesWithMappings = mappingsByCampaignId.get(request.getCampaignId())
                    .stream()
                    .map(ResourceKey::from)
                    .collect(Collectors.toSet());

            final Set<Service> requestServices = request.getChanges()
                    .stream()
                    .map(c -> c.getResource().getService())
                    .collect(Collectors.toSet());

            final Set<ProviderAllocationInfo> providerToAllocate = new HashSet<>();

            for (Service service : requestServices) {
                final Errors<AllocationError> allocationErrors = canUserAllocateReadyQuotaInService(person, request,
                        service, resourcesWithMappings);
                final boolean hasNotTransientErrors = allocationErrors.getErrors()
                        .stream()
                        .anyMatch(ae -> !ae.isTransient());
                if (!hasNotTransientErrors) {
                    final List<LocalizableString> notes = allocationErrors.getErrors()
                            .stream()
                            .map(AllocationError::getMessage)
                            .collect(Collectors.toList());
                    final boolean hasAllocatingResources = hasAllocatingResources(request, service, resourcesWithMappings);
                    providerToAllocate.add(new ProviderAllocationInfo(service, allocationErrors.isEmpty(), notes, hasAllocatingResources));
                }
            }
            providersToAllocateByRequestId.putAll(request.getId(), providerToAllocate);
        }

        return providersToAllocateByRequestId;
    }

    public Errors<AllocationError> canUserAllocateReadyQuotaInService(final Person person,
                                                                      final QuotaChangeRequest request,
                                                                      final Service service) {
        if (request.getCampaignId() == null) {
            return Errors.of(AllocationError.notTransientError(LocalizableString.of("request.cannot.be.allocated.without.campaign")));
        }
        final Set<ResourceKey> resourceWithMapping = resourcesWithMappings(service, request.getCampaignId());
        return canUserAllocateReadyQuotaInService(person, request, service, resourceWithMapping);
    }

    private Errors<AllocationError> canUserAllocateReadyQuotaInService(final Person person,
                                                                       final QuotaChangeRequest request,
                                                                       final Service service,
                                                                       final Set<ResourceKey> resourcesWithMappings) {
        if (!service.getSettings().isManualQuotaAllocation()) {
            return Errors.of(AllocationError.notTransientError(
                    LocalizableString.of("manual.allocation.unavailable.for", service.getName())
            ));
        }

        if (Objects.requireNonNull(request.getCampaign()).getType() != Campaign.Type.AGGREGATED) {
            return Errors.of(AllocationError.notTransientError(
                    LocalizableString.of("only.aggregated.campaign.request.can.be.allocated")
            ));
        }

        final Errors<AllocationError> errors = Errors.of();

        if (request.getStatus() != QuotaChangeRequest.Status.CONFIRMED) {
            errors.add(AllocationError.transientError(
                    LocalizableString.of("only.confirmed.request.can.be.allocated")
            ));
        }

        final Map<Boolean, List<QuotaChangeRequest.Change>> changesByMappingExistence = request.getChanges()
                .stream()
                .filter(c -> c.getResource().getService().equals(service))
                .filter(c -> c.getAmountReady() > c.getAmountAllocated())
                .collect(Collectors.partitioningBy(c -> resourcesWithMappings.contains(ResourceKey.from(c))));

        final Map<Boolean, List<QuotaChangeRequest.Change>> changesWithoutMappingByAvailability = changesByMappingExistence.getOrDefault(false, Collections.emptyList())
                .stream()
                .collect(Collectors.groupingBy(c -> c.getAmountAllocated() == c.getAmountAllocating()));

        final List<QuotaChangeRequest.Change> changesWithMapping = changesByMappingExistence.getOrDefault(true, Collections.emptyList());

        if (changesWithMapping.isEmpty()) {
            if (!changesWithoutMappingByAvailability.getOrDefault(false, Collections.emptyList()).isEmpty()) {
                errors.add(AllocationError.transientError(
                        LocalizableString.of("quota.allocation.already.requested")
                ));
            } else if (changesWithoutMappingByAvailability.getOrDefault(true, Collections.emptyList()).isEmpty()) {
                errors.add(AllocationError.notTransientError(LocalizableString.of("no.changes.to.allocate")));
            }
        }

        if (!changesWithMapping.isEmpty() && service.getSettings().getResourcesMappingBeanName() == null) {
            errors.add(AllocationError.transientError(
                    LocalizableString.of("no.allocation.bean.for.resources.model.provider", service.getName())
            ));
        }

        if (!canUserAllocateQuota(request, person)) {
            errors.add(AllocationError.transientError(
                    LocalizableString.of("only.request.author.or.manager.can.allocate.quota")
            ));
        }

        return errors;
    }

    public Set<String> getDeliverableProviders(QuotaChangeRequest request) {
        Set<String> result = new HashSet<>();
        if (request.getType() != QuotaChangeRequest.Type.RESOURCE_PREORDER || request.getCampaignId() == null) {
            // Irrelevant for other request types
            return result;
        }
        if (request.getStatus() != QuotaChangeRequest.Status.CONFIRMED) {
            // No delivery for unconfirmed requests
            return result;
        }
        if (request.getProject().getPathToRoot().stream()
                .anyMatch(p -> Project.TRASH_PROJECT_KEY.equals(p.getPublicKey()))) {
            // No delivery for request in unexportable service
            return result;
        }
        if (Objects.requireNonNull(request.getCampaign()).getType() != Campaign.Type.AGGREGATED) {
            // No delivery for non-aggregated campaigns
            return result;
        }
        Map<DeliverableResource, List<ResourcesModelMapping>> mappings = resourcesModelMappingCache
                .getByCampaignId(request.getCampaignId());
        request.getChanges().forEach(change -> {
            if (!(change.getAmountReady() > 0 && change.getAmountAllocating() < change.getAmountReady())) {
                // Nothing left to allocate, skip this change
                return;
            }
            Service service = change.getResource().getService();
            if (!(service.getSettings().isManualQuotaAllocation()
                    && service.getSettings().getResourcesMappingBeanName() != null)) {
                // Delivery is disabled for provider, skip this change
                return;
            }
            if (!mappings.containsKey(new DeliverableResource(change.getResource(), change.getSegments()))) {
                // No delivery mapping for this resource, skip the change
                return;
            }
            result.add(service.getKey());
        });
        return result;
    }

    public static boolean hasAllocatingResources(QuotaChangeRequest request, Service service, Set<ResourceKey> resourcesWithMappings) {
        return request.getChanges()
                .stream()
                .filter(c -> c.getResource().getService().equals(service))
                .filter(c -> !resourcesWithMappings.contains(ResourceKey.from(c)))
                .anyMatch(c -> c.getAmountAllocating() > c.getAmountAllocated());
    }

    public static boolean canUserAllocateQuota(QuotaChangeRequest request, Person person) {
        return person.getLogin().equals(request.getAuthor().getLogin())
                || Hierarchy.get().getDispenserAdminsReader().getDispenserAdmins().contains(person)
                || Hierarchy.get().getProjectReader().hasRole(person, request.getProject(), Role.QUOTA_MANAGER)
                || Hierarchy.get().getProjectReader().hasRole(person, request.getProject(), Role.STEWARD);
    }

    private static class ResourceKey {
        private final Resource resource;
        private final Set<Segment> segments;

        private ResourceKey(Resource resource, Set<Segment> segments) {
            this.resource = resource;
            this.segments = segments;
        }

        public Resource getResource() {
            return resource;
        }

        public Set<Segment> getSegments() {
            return segments;
        }

        public static ResourceKey from(QuotaChangeRequest.Change change) {
            return new ResourceKey(change.getResource(), change.getSegments());
        }

        public static ResourceKey from(ResourcesModelMapping resourcesModelMapping) {
            return new ResourceKey(resourcesModelMapping.getResource(), resourcesModelMapping.getSegments());
        }

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

        @Override
        public int hashCode() {
            return Objects.hash(resource, segments);
        }
    }

    private static class ChangeKey {

        private final String serviceKey;
        private final String resourceKey;
        private final Set<String> segmentKeys;
        private final Long bigOrderId;

        public ChangeKey(RequestQuotaAllocationBody.Change change, String serviceKey) {
            this.serviceKey = serviceKey;
            this.resourceKey = change.getResourceKey();
            this.segmentKeys = change.getSegmentKeys();
            this.bigOrderId = change.getOrderId();
        }

        public ChangeKey(QuotaChangeRequest.Change change, String serviceKey) {
            this.serviceKey = serviceKey;
            this.resourceKey = change.getResource().getPublicKey();
            this.segmentKeys = change.getSegments().stream().map(Segment::getPublicKey).collect(Collectors.toSet());
            this.bigOrderId = change.getBigOrder() != null ? change.getBigOrder().getId() : null;
        }

        public String getServiceKey() {
            return serviceKey;
        }

        public String getResourceKey() {
            return resourceKey;
        }

        public Set<String> getSegmentKeys() {
            return segmentKeys;
        }

        public Long getBigOrderId() {
            return bigOrderId;
        }

        @Override
        public boolean equals(Object o) {
            if (this == o) {
                return true;
            }
            if (o == null || getClass() != o.getClass()) {
                return false;
            }
            ChangeKey changeKey = (ChangeKey) o;
            return Objects.equals(serviceKey, changeKey.serviceKey) &&
                    Objects.equals(resourceKey, changeKey.resourceKey) &&
                    Objects.equals(segmentKeys, changeKey.segmentKeys) &&
                    Objects.equals(bigOrderId, changeKey.bigOrderId);
        }

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

        @Override
        public String toString() {
            return "ChangeKey{" +
                    "serviceKey='" + serviceKey + '\'' +
                    ", resourceKey='" + resourceKey + '\'' +
                    ", segmentKeys=" + segmentKeys +
                    ", bigOrderId=" + bigOrderId +
                    '}';
        }

    }

    private static <T> Collection<T> addNullToEmptyCollection(Collection<T> collection) {
        return isEmpty(collection) ? Collections.singletonList(null) : collection;
    }
}
