package ru.yandex.canvas.service;

import java.io.IOException;
import java.net.URL;
import java.util.Collection;
import java.util.Date;
import java.util.List;
import java.util.Objects;
import java.util.Optional;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.stream.IntStream;

import javax.validation.constraints.NotNull;

import com.google.common.base.Stopwatch;
import com.google.common.util.concurrent.ThreadFactoryBuilder;
import com.mongodb.DuplicateKeyException;
import org.apache.commons.lang3.StringUtils;
import org.jetbrains.annotations.Nullable;
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.FindAndModifyOptions;
import org.springframework.data.mongodb.core.MongoOperations;
import org.springframework.data.mongodb.core.query.Criteria;
import org.springframework.data.mongodb.core.query.Query;
import org.springframework.data.mongodb.core.query.Update;
import org.springframework.retry.annotation.Retryable;
import org.springframework.web.multipart.MultipartFile;

import ru.yandex.canvas.controllers.FilesController;
import ru.yandex.canvas.exceptions.BadRequestException;
import ru.yandex.canvas.exceptions.ConflictException;
import ru.yandex.canvas.exceptions.InternalServerError;
import ru.yandex.canvas.exceptions.StillageFileNotFoundException;
import ru.yandex.canvas.model.CropParameters;
import ru.yandex.canvas.model.File;
import ru.yandex.canvas.model.Files;
import ru.yandex.canvas.model.avatars.AvatarsPutCanvasResult;
import ru.yandex.canvas.model.direct.Privileges;
import ru.yandex.canvas.model.stillage.StillageFileInfo;
import ru.yandex.canvas.model.stock.StockCategory;
import ru.yandex.canvas.model.stock.StockFile;

import static java.util.concurrent.TimeUnit.MILLISECONDS;
import static java.util.stream.Collectors.toList;
import static ru.yandex.canvas.service.TankerKeySet.ERROR;

/**
 * Almost the same as FilesService, but for userId instead of clientId.
 * <p>
 * Temporary solution™ for ~1 year (starting from 2018/02/14).
 */
public class UserFileService {
    private static final Logger logger = LoggerFactory.getLogger(UserFileService.class);
    private static final Marker PERFORMANCE = MarkerFactory.getMarker("PERFORMANCE");

    private final StillageService stillageService;
    private final UserStockService userStockService;
    private final UserAuthService userAuthService;
    private final AvatarsService avatarsService;
    private final MongoOperations mongoOperation;
    private final ExecutorService batchExecutor;
    private final SessionParams sessionParams;

    public UserFileService(final MongoOperations mongoOperation,
                           final UserStockService userStockService,
                           final StillageService stillageService,
                           final UserAuthService userAuthService,
                           final AvatarsService avatarsService, SessionParams sessionParams) {
        this.stillageService = stillageService;
        this.userAuthService = userAuthService;
        this.avatarsService = avatarsService;
        this.userStockService = userStockService;
        this.mongoOperation = mongoOperation;
        this.sessionParams = sessionParams;
        this.batchExecutor = Executors.newCachedThreadPool(new ThreadFactoryBuilder()
                .setNameFormat("file-service-%d").build());
    }

    private static Query findFileByIdQuery(@NotNull final String id, final long userId) {
        return new Query(Criteria.where("id").is(id).andOperator(Criteria.where("userId").is(userId)
                .andOperator(Criteria.where("archive").is(false))));
    }

    /**
     * a parallel version for {@link #uploadFileInternal(String, MultipartFile, CropParameters, long)}
     *
     * @param cropParameters for each file. Must be same length as files. Ensured at {@link FilesController#uploadFilesFromCropped(String, List, List, long, long)}
     * @return DTO for uploaded files
     */
    public Files uploadFiles(@Nullable final String originalFileId, final List<MultipartFile> files,
                             @Nullable final List<CropParameters> cropParameters, final long userId) {
        userAuthService.requirePermission(Privileges.Permission.USER_FILES);
        // poor man's zip
        List<CompletableFuture<File>> futures =
                IntStream.range(0, Math.max(files.size(), cropParameters == null ? 0 : cropParameters.size()))
                        .mapToObj(i -> CompletableFuture.supplyAsync(
                                () -> uploadFileInternal(originalFileId, files.get(i),
                                        cropParameters == null ? null : cropParameters.get(i), userId, false),
                                batchExecutor))
                        .collect(toList());

        final List<File> uploaded = CompletableFuture.allOf(futures.toArray(new CompletableFuture[futures.size()]))
                .thenApply(v -> futures.stream().map(CompletableFuture::join) // IGNORE-BAD-JOIN DIRECT-149116
                        .collect(toList()))
                .join(); // IGNORE-BAD-JOIN DIRECT-149116

        return new Files(uploaded, uploaded.size());
    }

    public File uploadFile(@Nullable final String originalFileId, final MultipartFile file,
                           @Nullable final CropParameters cropParameters, final long userId, final boolean isTurbo) {
        userAuthService.requirePermission(Privileges.Permission.USER_FILES);
        return uploadFileInternal(originalFileId, file, cropParameters, userId, isTurbo);
    }

    public File uploadFile(URL fileUrl, final long userId) {
        userAuthService.requirePermission(Privileges.Permission.USER_FILES);
        final String fileName = new java.io.File(fileUrl.getPath()).getName();
        final StillageFileInfo stillageFileInfo = stillageService.uploadFile(fileName, fileUrl);
        validateFile(stillageFileInfo);

        final AvatarsPutCanvasResult.SizesInfo sizes = avatarsService.upload(stillageFileInfo.getUrl()).getSizes();

        return saveFile(stillageFileInfo, sizes, null, fileName, userId, null, null);
    }

    public File uploadFile(final String stillageFileId, final long userId) {
        userAuthService.requirePermission(Privileges.Permission.USER_FILES);

        final StillageFileInfo stillageFileInfo =
                stillageService.getById(stillageFileId).orElseThrow(StillageFileNotFoundException::new);
        validateFile(stillageFileInfo);

        final AvatarsPutCanvasResult.SizesInfo sizes = avatarsService.upload(stillageFileInfo.getUrl()).getSizes();

        return saveFile(stillageFileInfo, sizes, null, stillageFileId, userId, null, null);
    }

    public File saveFileFromStock(@NotNull final StockFile stockFile, final long userId) {
        userAuthService.requirePermission(Privileges.Permission.USER_FILES);

        Objects.requireNonNull(stockFile);

        final File file = new File();

        stockFile.getCategories().stream().findFirst()
                .flatMap(userStockService::getCategory)
                .map(StockCategory::getName)
                .ifPresent(file::setName);

        file.setUserId(userId);
        file.setDate(new Date());
        file.setStockFileId(stockFile.getId());

        file.setStillageFileInfo(stockFile.getStillageFileInfo());
        file.setStillageFileId(stockFile.getStillageFileInfo().getId());

        file.setUrl(stockFile.getUrl());
        file.setThumbnailUrl(stockFile.getThumbnailUrl());
        file.setPreviewUrl(stockFile.getPreviewUrl());
        file.setLargePreviewUrl(stockFile.getLargePreviewUrl());
        file.setCropSourceUrl(stockFile.getCropSourceUrl());
        return saveFile(file);
    }

    @SuppressWarnings("SameParameterValue")
    private File saveFile(final StillageFileInfo stillageFileInfo, final AvatarsPutCanvasResult.SizesInfo sizes,
                          @Nullable final String originalFileId, @Nullable final String fileName,
                          final long userId, @Nullable CropParameters cropParameters, @Nullable String ideaId) {
        final File file = new File();

        file.setUserId(userId);
        file.setDate(new Date());
        file.setOriginalFileId(originalFileId);
        file.setCropParameters(cropParameters);
        file.setName(fileName);
        file.setIdeaId(ideaId);

        file.setStillageFileInfo(stillageFileInfo);
        file.setStillageFileId(stillageFileInfo.getId());

        file.setUrl(avatarsService.getReadServiceUri() + sizes.getOrig().getPath());
        file.setThumbnailUrl(avatarsService.getReadServiceUri() + sizes.getThumbnail().getPath());
        file.setPreviewUrl(avatarsService.getReadServiceUri() + sizes.getPreview().getPath());
        file.setLargePreviewUrl(avatarsService.getReadServiceUri() + sizes.getLargePreview().getPath());
        file.setCropSourceUrl(avatarsService.getReadServiceUri() + sizes.getCropSource().getPath());

        return saveFile(file);
    }

    /**
     * Internal function DO NOT check permissions and COULD BE called from any thread
     */
    private File uploadFileInternal(@Nullable final String originalFileId, final MultipartFile file,
                                    @Nullable final CropParameters cropParameters, final long userId,
                                    final boolean isTurbo) {
        if (file.isEmpty()) {
            throw new BadRequestException();
        }
        try {
            final String fileName = file.getOriginalFilename();
            final byte[] data = file.getBytes();

            CompletableFuture<StillageFileInfo> uploadingToStillage = CompletableFuture.supplyAsync(
                    () -> stillageService.uploadFile(fileName, data), batchExecutor);
            CompletableFuture<AvatarsPutCanvasResult.SizesInfo> gettingSizes = CompletableFuture.supplyAsync(
                    () -> avatarsService.upload(fileName, data).getSizes(), batchExecutor);
            StillageFileInfo stillageFileInfo = uploadingToStillage.join(); // IGNORE-BAD-JOIN DIRECT-149116

            //Валидация вынесена в тред servlet container'а, чтобы показывать сообщения об ошибках на корректном языке (Thread local Locale)
            validateFile(stillageFileInfo, isTurbo);
            return saveFile(stillageFileInfo, gettingSizes.join(), // IGNORE-BAD-JOIN DIRECT-149116
                    originalFileId, fileName, userId, cropParameters,
                    null);
        } catch (IOException e) {
            logger.error("error during upload", e);
            throw new InternalServerError();
        }
    }

    /**
     * Upsert given {@link File} by it's {@link File#userId} and {@link File#stillageFileId}.
     * <p>
     * For already existing files always un-archives and updates  {@link File#date} to current time.
     * If the file was not manually renamed via UI also updates it's name to newly given too.
     *
     * @param file the file data to save
     * @return upserted file
     * <p>
     * Retried on {@link DuplicateKeyException} and {@link org.springframework.dao.DuplicateKeyException}
     * due to a possible race condition with key collision.
     */
    @Retryable(value = {DuplicateKeyException.class, org.springframework.dao.DuplicateKeyException.class})
    private File saveFile(final File file) {
        final Stopwatch createFileStopWatch = Stopwatch.createStarted();

        final File existing = mongoOperation.findOne(
                // There is unique index on these fields
                new Query(Criteria
                        .where("userId")
                        .is(file.getUserId().orElseThrow(() -> new IllegalStateException("userId is required")))
                        .and("stillageFileId").is(file.getStillageFileId())
                        .and("name").is(file.getName())
                ),
                File.class);

        final File result;
        if (existing != null) {
            existing.setArchive(false);
            existing.setDate(new Date());
            if (file.getTurboParameters() != null) {
                existing.setTurboParameters(file.getTurboParameters());
            }
            result = existing;
        } else {
            result = file;
        }

        // There should be a proper explanation why this race condition does not pose any risk.
        // The point is that any kind of operation is idempotent and therefore the worst that can happen is
        // that the final state of a File may correspond not to the latest operation but to one of the previous
        // as if it was the latest actually. Therefore, no broken state may arise.
        mongoOperation.save(result);

        logger.info(PERFORMANCE, "create_file:mongo:{}", createFileStopWatch.elapsed(MILLISECONDS));

        return result;
    }

    public Optional<File> getById(final String id, final long userId) {
        userAuthService.requirePermission(Privileges.Permission.USER_FILES);

        final Stopwatch getFileMongoStopWatch = Stopwatch.createStarted();

        final Query query = new Query(Criteria.where("id").is(id).andOperator(Criteria.where("userId").is(userId)));

        final Optional<File> result = Optional.ofNullable(mongoOperation.findOne(query, File.class));

        logger.info(PERFORMANCE, "get_file:mongo:{}", getFileMongoStopWatch.elapsed(MILLISECONDS));

        return result;
    }

    public Optional<File> turbonizedById(final String id, final long userId) {
        userAuthService.requirePermission(Privileges.Permission.USER_FILES);

        final Stopwatch getFileMongoStopWatch = Stopwatch.createStarted();

        final Query query = new Query(Criteria.where("id").is(id).andOperator(Criteria.where("userId").is(userId)));

        File file = mongoOperation.findOne(query, File.class);

        if (file != null) {
            turbonizeFile(file);
        }

        logger.info(PERFORMANCE, "get_file_turbo:mongo:{}", getFileMongoStopWatch.elapsed(MILLISECONDS));

        return Optional.ofNullable(file);
    }

    public void turbonizeFile(File file) {
        if (file.getTurboParameters() == null) {
            file.setTurboParameters(
                    avatarsService.uploadTurbo(file.getUrl()).toTurboParameters(avatarsService.getReadServiceUri()));
            saveFile(file);
        }
    }

    public Files find(final long userId, final String name, final boolean descOrder,
                      final int offset, final int limit) {
        userAuthService.requirePermission(Privileges.Permission.USER_FILES);

        final Criteria criteria;

        if (StringUtils.isNotBlank(name)) {
            criteria = Criteria.where("userId").is(userId).andOperator(Criteria.where("archive").is(false)
                            .andOperator(Criteria.where("name").regex(".*" + name + ".*")),
                    Criteria.where("originalFileId").is(null));
        } else {
            criteria = Criteria.where("userId").is(userId)
                    .andOperator(Criteria.where("archive").is(false),
                            Criteria.where("originalFileId").is(null));
        }
        final Query query = new Query(criteria);

        final long total = mongoOperation.count(query, File.class);

        final Stopwatch findFileStopWatch = Stopwatch.createStarted();

        List<File> files = mongoOperation.find(query
                        .with(Sort.by(descOrder ? Sort.Direction.DESC : Sort.Direction.ASC, "date"))
                        .skip(offset).limit(limit),
                File.class);
        logger.info(PERFORMANCE, "find_files:mongo:{}", findFileStopWatch.elapsed(MILLISECONDS));

        return new Files(files, total);
    }

    /**
     * Only rename is supported
     *
     * @throws ConflictException if file with such name and stillageFileId is present for current user
     */
    public File update(@NotNull final File file, final long userId) {
        userAuthService.requirePermission(Privileges.Permission.USER_FILES);

        final Stopwatch saveFileStopWatch = Stopwatch.createStarted();

        final File result;
        try {
            result = mongoOperation.findAndModify(findFileByIdQuery(file.getId(), userId),
                    new Update().set("name", file.getName()),
                    new FindAndModifyOptions().returnNew(true), File.class);
        } catch (org.springframework.dao.DuplicateKeyException e) {
            throw new ConflictException(ERROR.key("files-modify-conflict"), e);
        }
        logger.info(PERFORMANCE, "save_file:mongo:{}", saveFileStopWatch.elapsed(MILLISECONDS));

        return result;
    }

    void updateFilesDates(@NotNull final Collection<String> fileIDs, final long userId) {
        if (!fileIDs.isEmpty()) {
            final Stopwatch updateFilesDatesStopWatch = Stopwatch.createStarted();

            mongoOperation.updateMulti(new Query(Criteria.where("id").in(fileIDs)
                            .and("userId").is(userId)),
                    new Update().set("date", new Date()).set("archive", false), File.class);

            logger.info(PERFORMANCE, "update_files_dates:mongo:{}", updateFilesDatesStopWatch.elapsed(MILLISECONDS));
        }
    }

    public void delete(@NotNull final String id, final long userId) {
        userAuthService.requirePermission(Privileges.Permission.USER_FILES);

        final Stopwatch deleteFileStopWatch = Stopwatch.createStarted();

        mongoOperation.updateFirst(findFileByIdQuery(id, userId), new Update().set("archive", true), File.class);

        logger.info(PERFORMANCE, "delete_file:mongo:{}", deleteFileStopWatch.elapsed(MILLISECONDS));
    }

    private void validateFile(final StillageFileInfo stillageAnswer) {
        validateFile(stillageAnswer, false);
    }

    /**
     * Difference from original method: animated gifs are allowed
     */
    private void validateFile(final StillageFileInfo stillageAnswer, boolean isTurbo) {
        logger.trace("validating uploaded file: {}", stillageAnswer);
        FileValidator validator = new ImageValidator(stillageAnswer, isTurbo, true);
        validator.validate();
    }
}
