package ru.yandex.canvas.service;

import java.util.Collection;
import java.util.Collections;
import java.util.Date;
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.concurrent.CompletableFuture;
import java.util.concurrent.Executor;
import java.util.concurrent.Executors;
import java.util.function.Consumer;
import java.util.stream.Collectors;
import java.util.stream.Stream;

import javax.annotation.Nullable;
import javax.validation.constraints.NotNull;

import com.google.common.base.Stopwatch;
import com.google.common.util.concurrent.ThreadFactoryBuilder;
import com.mongodb.client.result.UpdateResult;
import org.apache.commons.lang3.StringUtils;
import org.bson.types.ObjectId;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.slf4j.Marker;
import org.slf4j.MarkerFactory;
import org.springframework.data.domain.Sort;
import org.springframework.data.mongodb.core.MongoOperations;
import org.springframework.data.mongodb.core.query.Criteria;
import org.springframework.data.mongodb.core.query.Field;
import org.springframework.data.mongodb.core.query.Query;
import org.springframework.data.mongodb.core.query.Update;
import org.springframework.util.CollectionUtils;

import ru.yandex.canvas.exceptions.BatchNotFoundException;
import ru.yandex.canvas.exceptions.CreativeNotFoundException;
import ru.yandex.canvas.exceptions.NotFoundException;
import ru.yandex.canvas.model.CreativeDocument;
import ru.yandex.canvas.model.CreativeDocumentBatch;
import ru.yandex.canvas.model.CreativeDocumentBatches;
import ru.yandex.canvas.model.direct.Privileges;
import ru.yandex.canvas.model.screenshooter.CreativeWithClientId;
import ru.yandex.canvas.repository.mongo.MongoOperationsWrapper;
import ru.yandex.canvas.repository.mongo.QueryBuilder;
import ru.yandex.canvas.service.rtbhost.helpers.CreativesDspUploadFacade;

import static java.util.Collections.emptyList;
import static java.util.Collections.singletonList;
import static java.util.Comparator.naturalOrder;
import static java.util.concurrent.TimeUnit.MILLISECONDS;
import static java.util.stream.Collectors.toList;
import static java.util.stream.Collectors.toSet;
import static org.springframework.data.mongodb.core.query.Criteria.where;

/**
 * @author skirsanov
 */
public class CreativesService implements OnCreativeService<CreativeDocument> {
    private static final Logger logger = LoggerFactory.getLogger(CreativesService.class);
    private static final Marker PERFORMANCE = MarkerFactory.getMarker("PERFORMANCE");

    protected final MongoOperationsWrapper mongoOperation;
    protected final SequenceService sequenceService;
    protected final ScreenshooterService screenshooterService;
    protected final AuthService authService;
    protected final FileService fileService;
    protected final RTBHostExportService rtbHostExportService;
    protected final Executor batchCreateExecutor;
    private final CreativesDspUploadFacade creativesDspUploadFacade;
    private final CreativeDocumentModifierFacade creativeDocumentModifierFacade;
    private final CreativeBatchDocumentsConsumerFacade creativeBatchDocumentsConsumerFacade;

    public CreativesService(MongoOperations mongoOperation,
                            SequenceService sequenceService,
                            FileService fileService,
                            ScreenshooterService screenshooterService,
                            RTBHostExportService rtbHostExportService,
                            AuthService authService,
                            CreativesDspUploadFacade creativesDspUploadFacade,
                            CreativeDocumentModifierFacade creativeDocumentModifierFacade,
                            CreativeBatchDocumentsConsumerFacade creativeBatchDocumentsConsumerFacade) {
        this.mongoOperation = new MongoOperationsWrapper(mongoOperation, "canvas_creatives_service");
        this.fileService = fileService;
        this.sequenceService = sequenceService;
        this.screenshooterService = screenshooterService;
        this.rtbHostExportService = rtbHostExportService;
        this.authService = authService;
        this.creativesDspUploadFacade = creativesDspUploadFacade;
        this.creativeDocumentModifierFacade = creativeDocumentModifierFacade;
        this.creativeBatchDocumentsConsumerFacade = creativeBatchDocumentsConsumerFacade;
        batchCreateExecutor = Executors.newCachedThreadPool(new ThreadFactoryBuilder()
                .setNameFormat("create-batch-%d").build());
    }


    @NotNull
    public List<CreativeDocument> getCreatives(@NotNull Collection<Long> creativeIds) {
        Objects.requireNonNull(creativeIds);
        if (creativeIds.isEmpty()) {
            return emptyList();
        } else {

            Stopwatch getCreativesStopWatch = Stopwatch.createStarted();

            List<CreativeDocument> creatives = transformToCreatives(creativeIds,
                    mongoOperation.find(new Query(where("items._id").in(creativeIds)
                                    .andOperator(where("archive").is(false))),
                            CreativeDocumentBatch.class));
            logger.info(PERFORMANCE, "get_creatives:mongo:{}", getCreativesStopWatch.elapsed(MILLISECONDS));
            return creatives;
        }
    }

    @NotNull
    public List<CreativeDocument> getCreatives(@NotNull Collection<Long> creativeIds, long clientId) {
        Objects.requireNonNull(creativeIds);
        if (creativeIds.isEmpty()) {
            return emptyList();
        } else {

            Stopwatch getClientCreativesStopWatch = Stopwatch.createStarted();

            List<CreativeDocument> creatives = transformToCreatives(creativeIds,
                    mongoOperation.find(new Query(where("items._id").in(creativeIds)
                                    .andOperator(where("clientId").is(clientId),
                                            where("archive").is(false))),
                            CreativeDocumentBatch.class));
            logger.info(PERFORMANCE, "get_client_creatives:mongo:{}",
                    getClientCreativesStopWatch.elapsed(MILLISECONDS));
            return creatives;
        }
    }

    private List<CreativeDocument> transformToCreatives(Collection<Long> creativeIds,
                                                        Collection<CreativeDocumentBatch> batches) {
        return batches.stream().flatMap(batch -> batch.getItems().stream()
                .filter(doc -> creativeIds.contains(doc.getId()))
                .filter(CreativeDocument::getAvailable)
                .peek(creative -> creativeDocumentConsumer(batch).accept(creative)))
                .collect(toList());
    }


    /**
     * @param id of the creative
     * @return creative document with this id, if it exists and user has GET allowed on him
     */
    @NotNull
    public Optional<CreativeDocument> get(long id) {
        return transformToCreatives(
                singletonList(id),
                findBatches(id).stream().
                        filter(creativeDocumentBatch ->
                                authService.checkPermissionDifferentClient(creativeDocumentBatch.getClientId(),
                                        Privileges.Permission.CREATIVE_GET))
                        .collect(toList())
        ).stream().findFirst();
    }

    /**
     * getCreativeInternal but returned archived creatives also
     */
    @NotNull
    public Optional<CreativeDocument> getCreativeInternalForPreview(long id, String batchId) {
        return transformToCreatives(
                singletonList(id),
                batchId == null ? findBatchArchivedAlso(id) : getBatchByBatchId(batchId)
        ).stream().findFirst();
    }

    /**
     * Returns batch with given batchId, archived also
     */
    private Collection<CreativeDocumentBatch> getBatchByBatchId(String batchId) {
        return mongoOperation.find(
                new Query(where("_id").is(convertBatchId(batchId))),
                CreativeDocumentBatch.class);
    }

    /*
     * В стародавние времена батчи сохранялись с целочисленным id.
     * Чтобы находить такие батчи, строковый batchId нужно переконвертировать в целочисленный.
     *
     * Подобная проблема однажды уже решалась тут: https://st.yandex-team.ru/CANVAS-895
     */
    private Object convertBatchId(String batchId) {
        if (!ObjectId.isValid(batchId) && StringUtils.isNumeric(batchId)) {
            return Integer.valueOf(batchId);
        } else {
            return batchId;
        }
    }

    /**
     * Returns batch with given creativeId, if any
     */
    private Collection<CreativeDocumentBatch> findBatches(long creativeId) {
        return mongoOperation.find(new Query(where("items._id").is(creativeId)
                .andOperator(where("archive").is(false))), CreativeDocumentBatch.class);
    }

    /**
     * Returns batches with given creativeIds, if any
     */
    private Collection<CreativeDocumentBatch> findBatches(List<Long> creativeIds) {
        if (CollectionUtils.isEmpty(creativeIds)) {
            return Collections.emptyList();
        }

        return mongoOperation.find(new Query(Criteria.where("items._id").in(creativeIds)
                .andOperator(Criteria.where("archive").is(false))), CreativeDocumentBatch.class);
    }

    /**
     * Returns batch with given creativeId, if any, archived also
     */
    private Collection<CreativeDocumentBatch> findBatchArchivedAlso(final long creativeId) {
        return mongoOperation.find(new Query(Criteria.where("items._id").is(creativeId)), CreativeDocumentBatch.class);
    }

    /**
     * Returns batch with given creativeId, if any, archived also
     */
    public Collection<CreativeDocumentBatch> findBatchesArchivedAlso(List<Long> creativeIds) {
        if (CollectionUtils.isEmpty(creativeIds)) {
            return Collections.emptyList();
        }

        return mongoOperation.find(new Query(Criteria.where("items._id").in(creativeIds)), CreativeDocumentBatch.class);
    }

    @Override
    public List<Long> filterPresent(List<Long> creativeIds) {
        HashSet<Long> idsHashSet = new HashSet<>(creativeIds);
        return findBatchesArchivedAlso(creativeIds).stream()
                .flatMap(b -> b.getItems().stream().map(CreativeDocument::getId))
                .filter(id -> idsHashSet.contains(id)).collect(Collectors.toList());
    }

    @Override
    public List<CreativeDocument> fetchByIds(List<Long> creativeIds) {
        if (CollectionUtils.isEmpty(creativeIds)) {
            return Collections.emptyList();
        }

        Collection<CreativeDocumentBatch> adBuilderBatches = findBatchesArchivedAlso(creativeIds);
        Set<Long> idsSet = new HashSet<>(creativeIds); // contains optimization
        return adBuilderBatches.stream()
                .flatMap(b -> b.getItems().stream()
                        .map(c -> creativeBatchDocumentsConsumerFacade.enrichCreativeFromBatch(c, b)))
                .filter(i -> idsSet.contains(i.getId()))
                .collect(toList());
    }

    @Override
    public Map<Long, List<CreativeDocument>> fetchForTypeByIdsGroupedByClient(List<Long> creativeIds) {
        if (CollectionUtils.isEmpty(creativeIds)) {
            return Collections.emptyMap();
        }

        Set<Long> idsSet = new HashSet<>(creativeIds); // contains optimization
        return findBatchesArchivedAlso(creativeIds).stream().collect(Collectors.groupingBy(
                CreativeDocumentBatch::getClientId,
                Collectors.flatMapping(b -> b.getItems().stream()
                                .map(c -> creativeBatchDocumentsConsumerFacade.enrichCreativeFromBatch(c, b))
                                .filter(i -> idsSet.contains(i.getId())),
                        Collectors.toList())
        ));
    }

    @Override
    public Class<CreativeDocument> worksOn() {
        return CreativeDocument.class;
    }

    /**
     * @param batchCreatives map with key as batchID and value as collection of corresponding creativeIDs
     */
    public List<CreativeDocument> getList(Map<String, List<Long>> batchCreatives, long clientId) {
        Objects.requireNonNull(batchCreatives);

        authService.requirePermission(Privileges.Permission.CREATIVE_GET);

        if (batchCreatives.isEmpty()) {
            return emptyList();
        }
        return mongoOperation.find(new Query(where("clientId").is(clientId)
                .andOperator(where("id").in(batchCreatives.keySet().stream()
                                .map(batchId -> {
                                    // some older batches have numeric IDs
                                    // convert them from str to get right query
                                    // see https://st.yandex-team.ru/CANVAS-895
                                    if (StringUtils.isNumeric(batchId)) {
                                        return Long.valueOf(batchId);
                                    } else {
                                        return batchId;
                                    }
                                }).collect(toSet())),
                        where("archive").is(false))), CreativeDocumentBatch.class)
                .stream()
                .flatMap(batch -> {
                    batch.getItems().forEach(creativeDocumentConsumer(batch));
                    return batch.getItems().stream().filter(creative -> batchCreatives.get(batch.getId())
                            .contains(creative.getId()));
                })
                .filter(CreativeDocument::getAvailable)
                .collect(toList());
    }


    public CreativeDocumentBatch createBatch(CreativeDocumentBatch batchDoc, long clientId) {
        Stopwatch createBatchTotalStopWatch = Stopwatch.createStarted();

        authService.requirePermission(Privileges.Permission.CREATIVE_CREATE);

        fileService.updateFilesDates(batchDoc.getItems().stream()
                .flatMap(item -> item.getData().getMediaSets().values().stream())
                .flatMap(ms -> ms.getItems().stream())
                .flatMap(mediaSetItem -> mediaSetItem.getItems().stream())
                .flatMap(subItem -> Stream.of(subItem.getCroppedFileId(), subItem.getFileId()))
                .collect(toSet()), clientId);

        batchDoc.setDate(new Date());
        batchDoc.setClientId(clientId);

        long lastId = sequenceService.getNextCreativeIds(batchDoc.getItems().size());

        for (CreativeDocument item : batchDoc.getItems()) {
            item.setId(lastId--);
        }

        // Filter out only available items and do stuff for them.
        // Leftovers are just saved to the DB with no changes.
        // NB: we still fill IDs for unavailable items because front-end relies on that -- see loop above
        List<CreativeDocument> availableItems =
                batchDoc.getItems().stream().filter(CreativeDocument::getAvailable).collect(toList());

        CompletableFuture.allOf(
                availableItems.stream()
                        .map(item -> processCreativeDocument(batchDoc, item, clientId))
                        .toArray(CompletableFuture[]::new))
                .thenAccept(aVoid -> {
                    Stopwatch createBatchMongoStopWatch = Stopwatch.createStarted();
                    mongoOperation.insert(batchDoc);

                    availableItems.forEach(creativeDocumentConsumer(batchDoc));

                    logger.info(PERFORMANCE, "create_batch:mongo:{}", createBatchMongoStopWatch.elapsed(MILLISECONDS));
                }).join(); // IGNORE-BAD-JOIN DIRECT-149116

        logger.info(PERFORMANCE, "create_batch:total:{}", createBatchTotalStopWatch.elapsed(MILLISECONDS));

        batchDoc.setTotal(batchDoc.getItems().size());

        return batchDoc;
    }

    private CompletableFuture<Void> processCreativeDocument(CreativeDocumentBatch batchDoc, CreativeDocument item,
                                                            long clientId) {
        return CompletableFuture.runAsync(
                () -> creativeDocumentModifierFacade.processCreativeDocument(batchDoc.getName(), item, clientId),
                batchCreateExecutor);
    }

    public void updateBatchName(CreativeDocumentBatch batchDoc, @NotNull String batchId, long clientId) {
        Stopwatch createBatchTotalStopWatch = Stopwatch.createStarted();

        authService.requirePermission(Privileges.Permission.CREATIVE_CREATE);

        UpdateResult writeResult = mongoOperation.updateFirst(Query.query(
                where("id").is(batchId)
                        .and("clientId").is(clientId)
                        .and("archive").is(false)),
                Update.update("name", batchDoc.getName()),
                CreativeDocumentBatch.class);

        if (writeResult.getMatchedCount() == 0) {
            throw new NotFoundException("CreativeUploadData batch not found (batchId = " + batchId + ")");
        }

        logger.info(PERFORMANCE, "create_batch:update_name:{}", createBatchTotalStopWatch.elapsed(MILLISECONDS));
    }

    public void delete(String id, long clientId) {
        authService.requirePermission(Privileges.Permission.CREATIVE_CREATE);

        mongoOperation.updateFirst(MongoHelper.findByIdQuery(id, clientId),
                new Update().set("archive", true),
                CreativeDocumentBatch.class);
    }

    public CreativeWithClientId<CreativeDocument> getCreativeWithClientIdOrThrow(Long creativeId) {
        CreativeDocumentBatch batch = findBatchArchivedAlso(creativeId).stream()
                .findFirst()
                .orElseThrow(CreativeNotFoundException::new);
        batch.getItems().forEach(creativeDocumentConsumer(batch));

        final CreativeDocument creative = batch.getItems()
                .stream()
                .filter(batchCreative -> batchCreative.getId() == creativeId)
                .findFirst()
                .orElseThrow(CreativeNotFoundException::new);

        return new CreativeWithClientId<>(creative, batch.getClientId());
    }

    /**
     * Ставит на креативах флаг adminRejectReason.
     * В целом это можно сделать через один запрос без выкачивания всего в память, но на текущий момент
     * спринговый драйвер монги очень плохо умеет arrayFilters внутри update-запроса.
     */
    public UpdateResult updateAdminRejectReason(Collection<Long> creativeIds, @Nullable String reason) {
        Query query = new Query(Criteria.where("items._id").in(creativeIds));
        Update update;
        if (reason != null) {
            update = new Update().set("items.$[c].adminRejectReason", reason);
        } else {
            update = new Update().unset("items.$[c].adminRejectReason");
        }
        Query filter = QueryBuilder.builder().and(Criteria.where("c._id").in(creativeIds)).build();
        return mongoOperation.updateNestedArrays(query, update, List.of(filter), CreativeDocumentBatch.class);
    }

    public void exportToRTBHost(@NotNull List<CreativeDocument> docs) {
        rtbHostExportService.exportToRtbHost(docs, creativesDspUploadFacade);
    }

    /**
     * Возвращает из mongo сохраненные батчи согласно условию.
     *
     * @param clientId  ID клиента
     * @param offset    Отступ, для формирования страниц
     * @param limit     Число элементов на одной странице
     * @param include   Какие поля нужно включить
     * @param exclude   Какие поля нужно исключить
     * @param sortBy    Поле для сортировки
     * @param descOrder Порядок сортировки
     * @param presetIds Список ID шаблонов, пустой если фильтрация не требуется
     */
    public CreativeDocumentBatches getBatches(long clientId,
                                              int offset, int limit,
                                              @NotNull Collection<String> include,
                                              @NotNull Collection<String> exclude,
                                              String sortBy, boolean descOrder,
                                              @NotNull Collection<Integer> presetIds) {
        Stopwatch getBatchesTotalStopWatch = Stopwatch.createStarted();

        authService.requirePermission(Privileges.Permission.CREATIVE_GET);

        Stopwatch getBatchesMongoStopWatch = Stopwatch.createStarted();

        Criteria criteria = where("clientId").is(clientId).and("archive").is(false);

        Collection<Integer> presetIdsToLookup = new HashSet<>(presetIds);

        if (!presetIds.isEmpty()) {
            Criteria presetIdCriteria = where("items").elemMatch(where("presetId").in(presetIdsToLookup));
            criteria.andOperator(presetIdCriteria);
        }

        long total = mongoOperation.count(new Query(criteria), CreativeDocumentBatch.class);

        logger.info(PERFORMANCE, "get_batches:count:{}", getBatchesMongoStopWatch.elapsed(MILLISECONDS));
        getBatchesMongoStopWatch.reset();
        getBatchesMongoStopWatch.start();

        Query query = new Query(criteria);
        Field fields = query.fields();
        include.forEach(fields::include);
        exclude.forEach(fields::exclude);

        List<CreativeDocumentBatch> batches = mongoOperation.find(query
                        .with(Sort.by(descOrder ? Sort.Direction.DESC : Sort.Direction.ASC, sortBy))
                        .skip(offset)
                        .limit(limit),
                CreativeDocumentBatch.class);

        logger.info(PERFORMANCE, "get_batches:find:{}", getBatchesMongoStopWatch.elapsed(MILLISECONDS));

        batches.stream().filter(batch -> batch.getItems() != null)
                .forEach(this::postProcessBatchBeforeReturn);

        logger.info(PERFORMANCE, "get_batches:total:{}", getBatchesTotalStopWatch.elapsed(MILLISECONDS));
        return new CreativeDocumentBatches(total, batches);
    }

    public CreativeDocumentBatch getBatch(String id, long clientId) {
        Stopwatch getBatchTotalStopWatch = Stopwatch.createStarted();

        authService.requirePermission(Privileges.Permission.CREATIVE_GET);

        Stopwatch getBatchMongoStopWatch = Stopwatch.createStarted();

        CreativeDocumentBatch batch = mongoOperation.findOne(MongoHelper.findByIdQuery(id, clientId),
                CreativeDocumentBatch.class);

        logger.info(PERFORMANCE, "get_batch:mongo:{}", getBatchMongoStopWatch.elapsed(MILLISECONDS));

        if (batch == null) {
            throw new BatchNotFoundException();
        }

        postProcessBatchBeforeReturn(batch);

        logger.info(PERFORMANCE, "get_batch:total:{}", getBatchTotalStopWatch.elapsed(MILLISECONDS));
        return batch;
    }

    protected void postProcessBatchBeforeReturn(CreativeDocumentBatch batch) {
        batch.getItems().forEach(creativeDocumentConsumer(batch));
        batch.setTotal(batch.getItems().size());
        batch.getItems().sort(naturalOrder());
    }

    private Consumer<CreativeDocument> creativeDocumentConsumer(CreativeDocumentBatch batch) {
        return creative -> creativeBatchDocumentsConsumerFacade.enrichCreativeFromBatch(creative, batch);
    }

}
