package ru.yandex.direct.web.entity.mobilecontent.service;

import java.util.Collection;
import java.util.EnumSet;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import java.util.Set;
import java.util.function.Function;

import javax.annotation.Nullable;

import com.google.common.collect.ImmutableMap;
import one.util.streamex.StreamEx;
import org.springframework.stereotype.Component;

import ru.yandex.direct.core.entity.banner.model.Banner;
import ru.yandex.direct.core.entity.banner.model.BannerWithHref;
import ru.yandex.direct.core.entity.banner.model.BannerWithSystemFields;
import ru.yandex.direct.core.entity.banner.model.CpcVideoBanner;
import ru.yandex.direct.core.entity.banner.model.ImageBanner;
import ru.yandex.direct.core.entity.banner.model.MobileAppBanner;
import ru.yandex.direct.core.entity.banner.model.NewReflectedAttribute;
import ru.yandex.direct.core.entity.banner.repository.BannerRelationsRepository;
import ru.yandex.direct.core.entity.banner.service.BannerService;
import ru.yandex.direct.core.entity.banner.service.BannersUpdateOperation;
import ru.yandex.direct.core.entity.banner.service.BannersUpdateOperationFactory;
import ru.yandex.direct.core.entity.banner.service.DatabaseMode;
import ru.yandex.direct.core.entity.campaign.model.Campaign;
import ru.yandex.direct.core.entity.campaign.model.StrategyName;
import ru.yandex.direct.core.entity.campaign.service.CampaignService;
import ru.yandex.direct.core.entity.feature.service.FeatureService;
import ru.yandex.direct.core.entity.mobileapp.MobileAppDefects;
import ru.yandex.direct.core.entity.mobileapp.model.MobileApp;
import ru.yandex.direct.core.entity.mobileapp.model.MobileAppTracker;
import ru.yandex.direct.core.entity.mobileapp.service.MobileAppService;
import ru.yandex.direct.core.entity.uac.service.trackingurl.ParserType;
import ru.yandex.direct.core.entity.uac.service.trackingurl.TrackingUrlParseService;
import ru.yandex.direct.dbutil.model.ClientId;
import ru.yandex.direct.dbutil.sharding.ShardHelper;
import ru.yandex.direct.feature.FeatureName;
import ru.yandex.direct.model.ModelChanges;
import ru.yandex.direct.multitype.entity.LimitOffset;
import ru.yandex.direct.operation.EmptyOperation;
import ru.yandex.direct.operation.Operation;
import ru.yandex.direct.utils.converter.Converter;
import ru.yandex.direct.validation.result.Defect;
import ru.yandex.direct.web.core.entity.mobilecontent.model.DisplayedAttribute;
import ru.yandex.direct.web.core.entity.mobilecontent.model.TrackingSystem;
import ru.yandex.direct.web.entity.mobilecontent.model.PropagationRequest;
import ru.yandex.direct.web.entity.mobilecontent.service.PropagationUtils.TrackingUrl;

import static com.google.common.base.Preconditions.checkArgument;
import static com.google.common.base.Preconditions.checkNotNull;
import static ru.yandex.direct.core.entity.banner.service.BannerUtils.getValueIfAssignable;
import static ru.yandex.direct.utils.converter.Converters.immutableSetConverter;
import static ru.yandex.direct.web.core.entity.mobilecontent.converter.WebCoreMobileAppConverter.DISPLAYED_ATTRIBUTE_SET_CONVERTER;
import static ru.yandex.direct.web.entity.mobilecontent.model.PropagationMode.APPLY_TO_ANY_RELATED_BANNERS_AND_REPLACE_ALL;
import static ru.yandex.direct.web.entity.mobilecontent.model.PropagationMode.APPLY_TO_BANNERS_WITH_SAME_TRACKING_URL_AND_REPLACE_CHANGED;
import static ru.yandex.direct.web.entity.mobilecontent.model.PropagationMode.DO_NOT_APPLY_TO_BANNERS;
import static ru.yandex.direct.web.entity.mobilecontent.service.PropagationUtils.createNewBannerTrackingUrl;

/**
 * Создаёт операцию обновления баннеров, связанных с приложением.
 * <p>
 * Создание операции обновления связанных баннеров должно вызываться до самого обновления приложения,
 * т.к. для определения параметров применения изменений, при инициализации из БД получается старое состояние приложение.
 * <p>
 * Операция может не создасться, тогда вернётся ошибка, которую можно показать пользователю.
 * <p>
 * По-сути создаётся операция обновления баннеров {@link BannersUpdateOperation} или
 * пустая операция {@link EmptyOperation}. Вызывающий код должен выполнить операцию обновления приложения и
 * операцию обновления связанных баннеров вместе. Т.е. сначала выполнить {@code prepare()} у обеих операций, затем,
 * если подготовка была успешна, выполнить {@code apply()}.
 */
@Component
public class MobileAppUpdatePropagationOperationFactory {
    private static final int COUNT_OF_BANNER_THRESHOLD = 10_000;
    private static final long CPI_GOAL_ID = 4L;
    private final ShardHelper shardHelper;
    private final BannerService bannerService;
    private final BannersUpdateOperationFactory bannersUpdateOperationFactory;
    private final BannerRelationsRepository bannerRelationsRepository;
    private final MobileAppService mobileAppService;
    private final CampaignService campaignService;
    private final TrackingUrlParseService trackingUrlParseService;
    private final FeatureService featureService;

    private static final Map<DisplayedAttribute, NewReflectedAttribute> ATTRIBUTE_TO_CORE_MAPPING = ImmutableMap.of(
            DisplayedAttribute.RATING, NewReflectedAttribute.RATING,
            DisplayedAttribute.PRICE, NewReflectedAttribute.PRICE,
            DisplayedAttribute.ICON, NewReflectedAttribute.ICON,
            DisplayedAttribute.RATING_VOTES, NewReflectedAttribute.RATING_VOTES
    );
    private static final Converter<Collection<DisplayedAttribute>, Set<NewReflectedAttribute>>
            ATTRIBUTE_TO_CORE_SET_CONVERTER = immutableSetConverter(ATTRIBUTE_TO_CORE_MAPPING);

    public MobileAppUpdatePropagationOperationFactory(
            ShardHelper shardHelper,
            BannerService bannerService,
            BannersUpdateOperationFactory bannersUpdateOperationFactory,
            BannerRelationsRepository bannerRelationsRepository,
            MobileAppService mobileAppService,
            CampaignService campaignService,
            TrackingUrlParseService trackingUrlParseService,
            FeatureService featureService) {
        this.shardHelper = shardHelper;
        this.bannerService = bannerService;
        this.bannersUpdateOperationFactory = bannersUpdateOperationFactory;
        this.bannerRelationsRepository = bannerRelationsRepository;
        this.mobileAppService = mobileAppService;
        this.campaignService = campaignService;
        this.trackingUrlParseService = trackingUrlParseService;
        this.featureService = featureService;
    }

    Result createPropagationOperation(Long operatorUid, ClientId clientId, PropagationRequest propagationRequest) {
        return createPropagationOperation(operatorUid, clientId, propagationRequest, COUNT_OF_BANNER_THRESHOLD);
    }

    /**
     * Создаёт операцию обновления баннеров, связанных с приложением {@code propagationRequest.getMobileAppId()}
     *
     * @param countOfBannerThreshold максимально разрешённое кол-во баннеров, для обновления; если превышается,
     *                               то операция не создаётся, а возвращается ошибка
     * @return результат, содержащий либо ошибку, либо созданную операцию
     */
    Result createPropagationOperation(Long operatorUid, ClientId clientId,
                                      PropagationRequest propagationRequest, int countOfBannerThreshold) {
        if (propagationRequest.getPropagationMode() == DO_NOT_APPLY_TO_BANNERS) {
            return Result.operation(new EmptyOperation<>());
        }

        // на этот момент в базе старые данные приложения, так что в maybeMobileApp тоже будут старые данные приложения
        Optional<MobileApp> maybeMobileApp =
                mobileAppService.getMobileApp(clientId, propagationRequest.getMobileAppId());
        if (!maybeMobileApp.isPresent()) {
            return Result.error(MobileAppDefects.appNotFound());
        }
        MobileApp mobileApp = maybeMobileApp.get();
        // здесь берутся id только кампаний из проф. директа, исключая UAC
        List<Long> campaignIds = campaignService.getNonArchivedMobileAppCampaignIds(clientId, mobileApp.getId());

        if (campaignIds.isEmpty()) {
            return Result.operation(new EmptyOperation<>());
        }

        boolean checkTrackingSystem = featureService
                .isEnabledForClientId(clientId, FeatureName.RMP_CPI_UNDER_KNOWN_TRACKING_SYSTEM_ONLY);
        boolean isConversionTrackingUrl = propagationRequest.getTrackingUrl() != null && (!checkTrackingSystem ||
                propagationRequest.getTrackingSystem() != null &&
                        !propagationRequest.getTrackingSystem().equals(TrackingSystem.OTHER.name()));
        if (!isConversionTrackingUrl) {
            // меняется трекинговая система на неизвестную, значит нужно проверить стратегию кампаний
            List<Campaign> campaignsWithStrategies = campaignService.getCampaignsWithStrategies(clientId, campaignIds);
            boolean anyCampaignHasCpiStrategy = StreamEx.of(campaignsWithStrategies)
                    .map(Campaign::getStrategy)
                    .anyMatch(strategy -> strategy.getStrategyName() == StrategyName.AUTOBUDGET_AVG_CPI
                            || (strategy.getStrategyName() == StrategyName.AUTOBUDGET
                            && strategy.getStrategyData() != null && strategy.getStrategyData().getGoalId() != null
                            && strategy.getStrategyData().getGoalId() == CPI_GOAL_ID));
            if (anyCampaignHasCpiStrategy) {
                return propagationRequest.getTrackingUrl() == null
                        ? Result.error(MobileAppDefects.canNotRemoveTrackingUrlInCpiCampaign())
                        : Result.error(MobileAppDefects.canNotChangeTrackingSystemInCpiCampaign());
            }
        }

        int shard = shardHelper.getShardByClientIdStrictly(clientId);
        long bannersOnApp = bannerRelationsRepository.getNonArchivedBannerIds(shard, campaignIds).size();
        if (bannersOnApp == 0) {
            return Result.operation(new EmptyOperation<>());
        } else if (bannersOnApp > countOfBannerThreshold) {
            return Result.error(MobileAppDefects.tooManyBanners());
        }

        List<Banner> banners = bannerService.getNonArchivedBannersByCampaignIds(shard, operatorUid, clientId,
                campaignIds,
                LimitOffset.limited(countOfBannerThreshold));

        // Для приложения с трекинговой системой проверяем, что баннеры также будут иметь трекинговую ссылку
        if (checkTrackingSystem && isConversionTrackingUrl && propagationRequest.getPropagationMode() != APPLY_TO_ANY_RELATED_BANNERS_AND_REPLACE_ALL) {
            if (hasUntrackableHref(banners)) {
                return Result.error(MobileAppDefects.canNotChangeTrackingSystemWithUntrackableBannerHref());
            }
        }

        List<ModelChanges<BannerWithSystemFields>> changes = createModelChanges(propagationRequest, mobileApp,
                banners);

        if (changes.isEmpty()) {
            return Result.operation(new EmptyOperation<>());
        } else {
            return Result.operation(bannersUpdateOperationFactory.createFullUpdateOperation(changes, operatorUid,
                    clientId, DatabaseMode.ONLY_MYSQL));
        }
    }

    private boolean hasUntrackableHref(List<Banner> banners) {
        Function<Banner, String> extractHref = banner -> getValueIfAssignable(banner, BannerWithHref.HREF);

        return StreamEx.of(banners)
                .map(banner -> extractHref.apply(banner))
                .nonNull()
                .anyMatch(href -> {
                    return trackingUrlParseService.getTrackingSystem(href, ParserType.TRACKING_URL) == null;
                });
    }

    private List<ModelChanges<BannerWithSystemFields>> createModelChanges(PropagationRequest propagationRequest,
                                                                          MobileApp formerMobileApp,
                                                                          List<Banner> banners) {
        PropagationParameters parameters = createPropagationParameters(formerMobileApp, propagationRequest);
        Function<String, Optional<TrackingUrl>> trackingClickUrlTransformer =
                parameters.getTrackingClickUrlTransformer();
        Map<NewReflectedAttribute, Boolean> reflectedAttributeChanges = parameters.getReflectedAttributeChanges();
        Function<Banner, String> extractHref = banner -> getValueIfAssignable(banner, BannerWithHref.HREF);
        Function<Banner, String> extractImpressionUrl = banner ->
                getValueIfAssignable(banner, MobileAppBanner.IMPRESSION_URL);
        Function<String, Optional<TrackingUrl>> impressionUrlTransformer =
                parameters.getTrackingImpressionUrlTransformer();

        return StreamEx.of(banners)
                .mapToEntry(Function.identity(), banner -> trackingClickUrlTransformer.apply(extractHref.apply(banner)))
                .filterValues(Optional::isPresent) // оставляем баннеры, к которым должна быть применена пропагация
                .mapValues(Optional::get) // оставляем баннеры, к которым должна быть применена пропагация
                .mapKeyValue((banner, newTrackingUrl) -> {
                    if (banner instanceof MobileAppBanner) {
                        TrackingUrl newImpressionUrl = impressionUrlTransformer
                                .apply(extractImpressionUrl.apply(banner))
                                .orElse(TrackingUrl.absent());
                        return (new ModelChanges<>(banner.getId(), MobileAppBanner.class)
                                .process(newTrackingUrl.getTrackingUrl(), MobileAppBanner.HREF)
                                .process(reflectedAttributeChanges, MobileAppBanner.REFLECTED_ATTRIBUTES))
                                .process(newImpressionUrl.getTrackingUrl(), MobileAppBanner.IMPRESSION_URL)
                                .castModelUp(BannerWithSystemFields.class);
                    } else if (banner instanceof ImageBanner) {
                        ImageBanner imageBanner = (ImageBanner) banner;
                        return (new ModelChanges<>(imageBanner.getId(), imageBanner.getClass())
                                .process(newTrackingUrl.getTrackingUrl(), ImageBanner.HREF))
                                .process(imageBanner.getIsMobileImage(), ImageBanner.IS_MOBILE_IMAGE)
                                .castModelUp(BannerWithSystemFields.class);
                    } else if (banner instanceof CpcVideoBanner) {
                        CpcVideoBanner cpcVideoBanner = (CpcVideoBanner) banner;
                        return (new ModelChanges<>(cpcVideoBanner.getId(), cpcVideoBanner.getClass())
                                .process(newTrackingUrl.getTrackingUrl(), CpcVideoBanner.HREF))
                                .process(cpcVideoBanner.getIsMobileVideo(), CpcVideoBanner.IS_MOBILE_VIDEO)
                                .castModelUp(BannerWithSystemFields.class);
                    } else if (banner instanceof BannerWithHref) {
                        return (new ModelChanges<>(banner.getId(), BannerWithHref.class)
                                .process(newTrackingUrl.getTrackingUrl(), BannerWithHref.HREF))
                                .castModelUp(BannerWithSystemFields.class);
                    } else {
                        return new ModelChanges<>(banner.getId(), BannerWithSystemFields.class);
                    }
                })
                .toList();
    }

    private PropagationParameters createPropagationParameters(MobileApp formerMobileApp, PropagationRequest request) {
        checkArgument(request.getPropagationMode() == APPLY_TO_ANY_RELATED_BANNERS_AND_REPLACE_ALL
                || request.getPropagationMode() == APPLY_TO_BANNERS_WITH_SAME_TRACKING_URL_AND_REPLACE_CHANGED);

        Set<NewReflectedAttribute> requestedReflectedAttrs =
                ATTRIBUTE_TO_CORE_SET_CONVERTER.convert(request.getDisplayedAttributes());
        Set<NewReflectedAttribute> oldReflectedAttrs =
                DISPLAYED_ATTRIBUTE_SET_CONVERTER
                        .andThen(ATTRIBUTE_TO_CORE_SET_CONVERTER)
                        .convert(formerMobileApp.getDisplayedAttributes());

        if (request.getPropagationMode() == APPLY_TO_ANY_RELATED_BANNERS_AND_REPLACE_ALL) {
            return new PropagationParameters(
                    computeAttrsChangesMask(requestedReflectedAttrs, null),
                    bannerTrackingUrl -> Optional.of(TrackingUrl.of(request.getTrackingUrl())),
                    bannerTrackingImpressionUrl -> Optional.of(TrackingUrl.of(request.getTrackingImpressionUrl())));
        } else {
            return new PropagationParameters(
                    computeAttrsChangesMask(requestedReflectedAttrs, oldReflectedAttrs),
                    bannerTrackingUrl -> createNewBannerTrackingUrl(
                            getFormerAppTrackingUrl(formerMobileApp),
                            request.getTrackingUrl(),
                            bannerTrackingUrl),
                    bannerTrackingImpressionUrl -> createNewBannerTrackingUrl(
                            getFormerAppTrackingImpressionUrl(formerMobileApp),
                            request.getTrackingImpressionUrl(),
                            bannerTrackingImpressionUrl));
        }
    }

    private Map<NewReflectedAttribute, Boolean> computeAttrsChangesMask(
            Set<NewReflectedAttribute> requestedReflectedAttrs,
            @Nullable Set<NewReflectedAttribute> oldReflectedAttrs) {
        return StreamEx.of(EnumSet.allOf(NewReflectedAttribute.class))
                .filter(attr -> oldReflectedAttrs == null
                        || oldReflectedAttrs.contains(attr) != requestedReflectedAttrs.contains(attr))
                .mapToEntry(requestedReflectedAttrs::contains)
                .toMapAndThen(ImmutableMap::copyOf);
    }

    @Nullable
    private String getFormerAppTrackingUrl(MobileApp mobileApp) {
        return mobileApp.getTrackers().stream()
                .findFirst()
                .map(MobileAppTracker::getUrl)
                .orElse(null);
    }

    @Nullable
    private String getFormerAppTrackingImpressionUrl(MobileApp mobileApp) {
        return mobileApp.getTrackers().stream()
                .findFirst()
                .map(MobileAppTracker::getImpressionUrl)
                .orElse(null);
    }

    private static class PropagationParameters {
        private final Map<NewReflectedAttribute, Boolean> reflectedAttributeChanges;
        private final Function<String, Optional<TrackingUrl>> trackingClickUrlTransformer;
        private final Function<String, Optional<TrackingUrl>> trackingImpressionUrlTransformer;

        private PropagationParameters(
                Map<NewReflectedAttribute, Boolean> reflectedAttributeChanges,
                Function<String, Optional<TrackingUrl>> trackingClickUrlTransformer,
                Function<String, Optional<TrackingUrl>> trackingImpressionUrlTransformer) {
            this.reflectedAttributeChanges = reflectedAttributeChanges;
            this.trackingClickUrlTransformer = trackingClickUrlTransformer;
            this.trackingImpressionUrlTransformer = trackingImpressionUrlTransformer;
        }

        Map<NewReflectedAttribute, Boolean> getReflectedAttributeChanges() {
            return reflectedAttributeChanges;
        }

        Function<String, Optional<TrackingUrl>> getTrackingClickUrlTransformer() {
            return trackingClickUrlTransformer;
        }

        Function<String, Optional<TrackingUrl>> getTrackingImpressionUrlTransformer() {
            return trackingImpressionUrlTransformer;
        }
    }

    @SuppressWarnings("WeakerAccess")
    static class Result {
        private final Operation<?> operation;
        private final Defect error;

        public Result(Operation<?> operation, Defect error) {
            checkArgument((operation == null) != (error == null));
            this.operation = operation;
            this.error = error;
        }

        public static Result operation(Operation<?> operation) {
            return new Result(operation, null);
        }

        public static Result error(Defect error) {
            return new Result(null, error);
        }

        public boolean hasOperation() {
            return operation != null;
        }

        public Operation<?> getOperation() {
            return checkNotNull(operation);
        }

        public Defect getError() {
            return checkNotNull(error);
        }
    }
}
