package ru.yandex.canvas.service.video;

import java.net.MalformedURLException;
import java.net.URL;
import java.text.SimpleDateFormat;
import java.time.Instant;
import java.util.ArrayList;
import java.util.Collections;
import java.util.Date;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.LinkedBlockingQueue;
import java.util.concurrent.ThreadPoolExecutor;
import java.util.concurrent.TimeUnit;
import java.util.stream.Collectors;
import java.util.stream.IntStream;

import javax.annotation.Nullable;
import javax.annotation.ParametersAreNonnullByDefault;

import com.google.common.cache.CacheBuilder;
import com.google.common.cache.CacheLoader;
import com.google.common.cache.LoadingCache;
import com.google.common.util.concurrent.ThreadFactoryBuilder;
import org.jetbrains.annotations.NotNull;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.web.client.HttpClientErrorException;
import org.springframework.web.client.RestTemplate;

import ru.yandex.canvas.exceptions.InternalServerError;
import ru.yandex.canvas.exceptions.ValidationErrorsException;
import ru.yandex.canvas.model.CropParameters;
import ru.yandex.canvas.model.File;
import ru.yandex.canvas.model.avatars.AvatarsPutCanvasResult;
import ru.yandex.canvas.model.stillage.StillageFileInfo;
import ru.yandex.canvas.model.video.files.AudioSource;
import ru.yandex.canvas.model.video.files.Movie;
import ru.yandex.canvas.model.video.vc.feed.VideoConstructorFeed;
import ru.yandex.canvas.model.video.vc.feed.VideoConstructorFeedCropParams;
import ru.yandex.canvas.model.video.vc.feed.VideoConstructorFeedCropParamsType;
import ru.yandex.canvas.model.video.vc.feed.VideoConstructorFeedField;
import ru.yandex.canvas.model.video.vc.feed.VideoConstructorFeedFieldType;
import ru.yandex.canvas.model.video.vc.feed.VideoConstructorFeedParsed;
import ru.yandex.canvas.model.video.vc.feed.VideoConstructorFeedRow;
import ru.yandex.canvas.model.video.vc.feed.VideoConstructorFeeds;
import ru.yandex.canvas.repository.video.VideoConstructorFeedsRepository;
import ru.yandex.canvas.service.AvatarsService;
import ru.yandex.canvas.service.DirectService;
import ru.yandex.canvas.service.FileService;
import ru.yandex.canvas.service.FileValidator;
import ru.yandex.canvas.service.StillageService;
import ru.yandex.canvas.service.TankerKeySet;
import ru.yandex.canvas.service.VideoLimitsInterface;

@ParametersAreNonnullByDefault
public class VideoConstructorFeedsService {
    private static final Logger logger = LoggerFactory.getLogger(VideoConstructorFeedsService.class);

    private final VideoConstructorFeedsRepository videoConstructorFeedsRepository;
    private final StillageService stillageService;
    private final AvatarsService avatarsService;
    private final FileService fileService;
    private final MovieServiceInterface movieService;
    private final VideoFileUploadServiceInterface videoFileUploadService;
    private final VideoLimitsService videoLimitsService;
    private final DirectService directService;
    private final AudioService audioService;
    private final RestTemplate restTemplate;

    private final ExecutorService executor = new ThreadPoolExecutor(5, 5, 1, TimeUnit.MINUTES,
            new LinkedBlockingQueue<>(),
            new ThreadFactoryBuilder()
                    .setDaemon(true)
                    .setNameFormat("feeds-thread-%d")
                    .build());
    private final LoadingCache<String, VideoConstructorFeedParsed> parsedFeedsCache;
    private final VideoGeometryService videoGeometryService;

    public VideoConstructorFeedsService(VideoConstructorFeedsRepository videoConstructorFeedsRepository,
                                        StillageService stillageService,
                                        AvatarsService avatarsService,
                                        FileService fileService,
                                        MovieServiceInterface movieService,
                                        VideoFileUploadServiceInterface videoFileUploadService,
                                        VideoLimitsService videoLimitsService,
                                        DirectService directService,
                                        AudioService audioService,
                                        RestTemplate restTemplate, VideoGeometryService videoGeometryService) {
        this.videoConstructorFeedsRepository = videoConstructorFeedsRepository;
        this.stillageService = stillageService;
        this.avatarsService = avatarsService;
        this.fileService = fileService;
        this.movieService = movieService;
        this.videoFileUploadService = videoFileUploadService;
        this.videoLimitsService = videoLimitsService;
        this.directService = directService;
        this.audioService = audioService;
        this.restTemplate = restTemplate;
        this.videoGeometryService = videoGeometryService;
        this.parsedFeedsCache = CacheBuilder.newBuilder()
                .expireAfterAccess(30, TimeUnit.SECONDS)
                .maximumSize(10)
                .build(new CacheLoader<>() {
                    @Override
                    public VideoConstructorFeedParsed load(@NotNull String feedId) {
                        return getParsedFeedById(feedId);
                    }
                });
    }

    public VideoConstructorFeedParsed getParsedFeedById(String feedId) {
        VideoConstructorFeed feed = videoConstructorFeedsRepository.findByIdInternal(feedId);
        return getParsedFeed(feed);
    }

    public VideoConstructorFeedParsed getParsedFeed(VideoConstructorFeed feed) {
        byte[] feedContent = restTemplate.getForObject(feed.getUrl(), byte[].class);

        VideoConstructorFeedParsed feedParsed = new VideoConstructorFeedParsed(feedContent);
        feed.getFields().forEach(field -> feedParsed.setFieldType(field.getName(), field.getType()));
        return feedParsed;
    }

    public VideoConstructorFeed upload(byte[] content, String filename, Long clientId, Long userId) {
        Date creationTime = Date.from(Instant.now());

        logger.info("parse feed file '{}'", filename);
        VideoConstructorFeedParsed feedParsed = null;
        try {
            feedParsed = new VideoConstructorFeedParsed(content);
        } catch (Exception ignored) {
            // ошибка про невозможность парсинга файла выкидывается в валидации
        }

        logger.info("validate feed file");
        validateFeed(feedParsed);
        if (feedParsed == null) {
            return null;
        }

        // сохраняем исходный файл
        logger.info("uploading raw feed file to mds");
        String uploadRawFileUrl = uploadFeed(getMdsFilenameForRawFeed(), filename, content);

        // пока делаем загрузку картинок синхронно тут, возможно надо сделать в виде отдельной джобы
        logger.info("converting external urls");
        convertExternalUrls(feedParsed, clientId);

        // сохраняем файл с преобразованными ссылками
        logger.info("uploading processed feed file to mds");
        String uploadFileUrl = uploadFeed(getProcessedFeedNameForMds(), filename, feedParsed);

        logger.info("saving feed record");
        return saveFeed(filename, userId, clientId, creationTime, false, uploadRawFileUrl, uploadFileUrl,
                feedParsed.getRowsCount(), feedParsed.getFields(), null, null);
    }

    private static String getMdsFilenameForRawFeed() {
        return getFeedNameForMds("yandex_direct_vc_raw_feed");
    }

    private static String getProcessedFeedNameForMds() {
        return getFeedNameForMds("yandex_direct_vc_processed_feed");
    }

    public static String getResultFeedNameForMds() {
        return getFeedNameForMds("yandex_direct_vc_result_feed");
    }

    private static String getFeedNameForMds(String preffix) {
        Date currentDate = Date.from(Instant.now());
        SimpleDateFormat dateFormat = new SimpleDateFormat("yyyy_MM_dd_HH_mm");
        return String.format("%s_%s.csv", preffix, dateFormat.format(currentDate));
    }

    public String uploadFeed(String filename, String attachmentFilename, VideoConstructorFeedParsed feedParsed) {
        return uploadFeed(filename, attachmentFilename, feedParsed.write().toByteArray());
    }

    public String uploadFeed(String filename, String attachmentFilename, byte[] content) {
        return stillageService.uploadFile(filename, content, true, attachmentFilename).getUrl();
    }

    public VideoConstructorFeeds find(final long clientId, final boolean descOrder) {
        return videoConstructorFeedsRepository.find(clientId, descOrder);
    }

    public VideoConstructorFeed getById(final String id, final long clientId) {
        return videoConstructorFeedsRepository.findById(id, clientId);
    }

    public VideoConstructorFeedRow getRowById(final String id, final int rowNum) {
        VideoConstructorFeedParsed feedParsed = parsedFeedsCache.getUnchecked(id);
        return feedParsed.getRow(rowNum);
    }

    public boolean delete(String id, long clientId) {
        return videoConstructorFeedsRepository.delete(id, clientId);
    }

    /*
    Создаем новый фид с кропнутыми изображениями
     */
    public VideoConstructorFeed createFeedCropped(String feedId,
                                                  List<VideoConstructorFeedCropParams> feedCropParams,
                                                  long clientId) {
        logger.info("start cropping of feed {}", feedId);
        VideoConstructorFeed feedOrig = videoConstructorFeedsRepository.findById(feedId, clientId);
        if (feedOrig == null) {
            throw new IllegalArgumentException(String.format("Can't find feed with id=%s for client=%d.", feedId,
                    clientId));
        }
        VideoConstructorFeedParsed feedParsed = getParsedFeed(feedOrig);

        List<String> urls = new ArrayList<>();
        List<CropParameters> cropParams = new ArrayList<>();
        List<Integer> cellRows = new ArrayList<>();
        List<String> cellFields = new ArrayList<>();

        // добавляем "одиночные" элементы для кроппа с типом CELL
        feedCropParams.stream()
                .filter(feedCropParam -> feedCropParam.getType() == VideoConstructorFeedCropParamsType.CELL)
                .forEach(feedCropParam -> {
                    urls.add(feedParsed.getCellValue(feedCropParam.getRow(), feedCropParam.getField()));
                    cropParams.add(feedCropParam.getParams());

                    cellRows.add(feedCropParam.getRow());
                    cellFields.add(feedCropParam.getField());
                });

        // отображение: название поля -> номера строк элементов с "точечным кропом"
        Map<String, Set<Integer>> fieldToSingleCellsCrop = feedCropParams.stream()
                .filter(feedCropParam -> feedCropParam.getType() == VideoConstructorFeedCropParamsType.CELL)
                .collect(Collectors.groupingBy(
                        VideoConstructorFeedCropParams::getField,
                        Collectors.mapping(VideoConstructorFeedCropParams::getRow, Collectors.toSet())));

        // добавляем целые поля с типом FIELD
        feedCropParams.stream()
                .filter(feedCropParam -> feedCropParam.getType() == VideoConstructorFeedCropParamsType.FIELD)
                .forEach(feedCropParam -> IntStream.range(0, feedParsed.getRowsCount())
                        // проверяем, что элемент еще не кропнут типом CELL
                        .filter(rowNum -> !fieldToSingleCellsCrop.containsKey(feedCropParam.getField()) ||
                                !fieldToSingleCellsCrop.get(feedCropParam.getField()).contains(rowNum))
                        .forEach(rowNum -> {
                            urls.add(feedParsed.getCellValue(rowNum, feedCropParam.getField()));
                            cropParams.add(feedCropParam.getParams());

                            cellRows.add(rowNum);
                            cellFields.add(feedCropParam.getField());
                        }));

        List<String> fileNames = Collections.nCopies(urls.size(), "feed_image");
        // делаем кроп, используя собственный тред-пул
        // пока что у файлов не выставляем originalFileId
        List<File> croppedFiles = fileService.uploadFilesByTrustedUrls(urls, fileNames, null, cropParams,
                clientId, true, true, false, executor);

        // заменяем старые изображения на новые кропнутые
        IntStream.range(0, croppedFiles.size()).forEach(idx -> {
            int rowIdx = cellRows.get(idx);
            int fieldIdx = feedParsed.getFieldIdx(cellFields.get(idx));
            feedParsed.getRowValues(rowIdx)[fieldIdx] = croppedFiles.get(idx).getUrl();
        });

        // сохраняем фид
        String feedCroppedUrl = uploadFeed(getProcessedFeedNameForMds(), feedOrig.getName(), feedParsed);
        Date creationTime = Date.from(Instant.now());
        logger.info("creating cropped feed from {}", feedId);
        return saveFeed(feedOrig.getName(), feedOrig.getUserId(), clientId, creationTime,
                feedOrig.getArchive(), feedOrig.getUrl(), feedCroppedUrl, feedOrig.getRowsCount(),
                feedOrig.getFields(), feedId, feedCropParams);
    }

    public VideoConstructorFeed saveFeed(String name, Long userId, Long clientId, Date creationTime, Boolean archive,
                                         String urlRawData, String url, Integer rowsCount,
                                         List<VideoConstructorFeedField> fields,
                                         @Nullable String originalFeedId,
                                         @Nullable List<VideoConstructorFeedCropParams> cropParams) {
        VideoConstructorFeed record = new VideoConstructorFeed()
                .withName(name)
                .withUserId(userId)
                .withClientId(clientId)
                .withCreationTime(creationTime)
                .withArchive(archive)
                .withUrlRawData(urlRawData)
                .withUrl(url)
                .withRowsCount(rowsCount)
                .withFields(fields)
                .withOriginalFeedId(originalFeedId)
                .withCropParams(cropParams);
        return videoConstructorFeedsRepository.save(record);
    }

    private void validateFeed(@Nullable VideoConstructorFeedParsed videoConstructorFeedParsed) {
        FileValidator validator = new VideoConstructorFeedValidator(videoConstructorFeedParsed);
        validator.validate();
    }

    private void validateFeedItem(int rowNumber,
                                  String fieldName,
                                  @Nullable StillageFileInfo itemInfo,
                                  @Nullable VideoConstructorFeedFieldType actualType,
                                  @Nullable VideoConstructorFeedFieldType expectedType,
                                  @Nullable VideoMetaData videoMetaData,
                                  @Nullable VideoLimitsInterface limits,
                                  @Nullable Set<String> features) {
        FileValidator validator = new VideoConstructorFeedItemValidator(rowNumber, fieldName, itemInfo, actualType,
                expectedType, videoMetaData, limits, features, videoGeometryService);
        validator.validate();
    }

    private StillageFileInfo getStillageFileInfoWithValudateLink(@NotNull final String fileName,
                                                                 @NotNull final URL fileUrl,
                                                                 int rowNumber,
                                                                 String fieldName) {
        try {
            return stillageService.uploadFile(fileName, fileUrl);
        } catch (HttpClientErrorException ex) {
            throw new ValidationErrorsException(Collections.singletonList(
                    TankerKeySet.VIDEO_VALIDATION_MESSAGES.interpolate("video-constructor-feed-invalid-url",
                            rowNumber, fieldName)));
        }
    }

    private FieldUrlWithType getInternalUrlWithType(String urlValue,
                                                    @Nullable VideoConstructorFeedFieldType expectedType,
                                                    int rowNumber,
                                                    String fieldName,
                                                    Long clientId,
                                                    Set<String> features) {
        final URL url;
        try {
            url = new URL(urlValue);
        } catch (MalformedURLException e) {
            logger.error("unexpected invalid url {}, {}", urlValue, e);
            throw new InternalServerError();
        }
        StillageFileInfo stillageFileInfo = getStillageFileInfoWithValudateLink("feed_item", url,
                rowNumber, fieldName);

        final VideoConstructorFeedFieldType type;
        String contentGroup = stillageFileInfo.getContentGroup();
        VideoMetaData videoMetaData = null;
        VideoLimitsInterface limits = null;

        // определяем тип объекта, который лежит по данному урлу
        // пока поддерживаем только картинки и видео
        if (contentGroup.equalsIgnoreCase("IMAGE")) {
            type = VideoConstructorFeedFieldType.IMAGE;
        } else if (contentGroup.equalsIgnoreCase("VIDEO")) {
            type = VideoConstructorFeedFieldType.VIDEO;

            stillageFileInfo = movieService.processFileInfo(stillageFileInfo, "feed_item",
                    VideoCreativeType.VIDEO_CONSTRUCTOR_FEED);
            videoMetaData = ((VideoFileUploadService) videoFileUploadService).parseMetaData(stillageFileInfo);
            limits = videoLimitsService.getLimits(VideoCreativeType.VIDEO_CONSTRUCTOR_FEED, features, null);
        } else if (contentGroup.equalsIgnoreCase("AUDIO")) {
            type = VideoConstructorFeedFieldType.AUDIO;
            limits = videoLimitsService.getLimits(VideoCreativeType.VIDEO_CONSTRUCTOR_FEED, features, null);
        } else {
            type = null;
        }

        validateFeedItem(rowNumber, fieldName, stillageFileInfo, type, expectedType, videoMetaData, limits, features);

        if (type == VideoConstructorFeedFieldType.IMAGE) {
            AvatarsPutCanvasResult avatarsResult = avatarsService.upload(stillageFileInfo.getUrl());
            fileService.saveFile(stillageFileInfo, avatarsResult.getSizes(), null, "feed_image",
                    clientId, null, null, true);
            return new FieldUrlWithType(avatarsResult.getSizes().getOrig().getUrl(), type);
        } else if (type == VideoConstructorFeedFieldType.VIDEO) {
            Movie movie = movieService.upload(stillageFileInfo, "feed_video", clientId,
                    VideoCreativeType.VIDEO_CONSTRUCTOR_FEED, null);
            return new FieldUrlWithType(movie.getStillageUrl(), type);
        } else if (type == VideoConstructorFeedFieldType.AUDIO) {
            AudioSource audioSource = audioService.upload(stillageFileInfo, "feed_audio", clientId,
                    VideoCreativeType.VIDEO_CONSTRUCTOR_FEED, null);
            return new FieldUrlWithType(audioSource.getStillageUrl(), type);
        }
        return new FieldUrlWithType(null, null);
    }

    private void convertExternalUrls(VideoConstructorFeedParsed feedParsed, Long clientId) {
        Set<String> features = directService.getFeatures(clientId, null);

        List<CompletableFuture<Void>> tasks = new ArrayList<>();
        String[] firstRow = feedParsed.getRowValues(0);

        for (int i = 0; i < feedParsed.getFieldsCount(); ++i) {
            if (firstRow[i] != null && (firstRow[i].startsWith("http://") || firstRow[i].startsWith("https://"))) {
                String fieldName = feedParsed.getFieldNames()[i];

                FieldUrlWithType internalUrlWithType = getInternalUrlWithType(firstRow[i], null,
                        0, fieldName, clientId, features);
                firstRow[i] = internalUrlWithType.getUrl();
                // проверяем, что у всех остальных элементов в той же колонке такой же тип, как и у первого
                VideoConstructorFeedFieldType firstRowType = internalUrlWithType.getType();
                feedParsed.setFieldType(fieldName, firstRowType);

                for (int j = 1; j < feedParsed.getRowsCount(); ++j) {
                    String[] curRow = feedParsed.getRowValues(j);
                    int finalI = i;
                    tasks.add(CompletableFuture.runAsync(
                            () -> curRow[finalI] = getInternalUrlWithType(curRow[finalI], firstRowType,
                                    finalI, fieldName, clientId, features).getUrl(), executor));
                }
            }
        }

        CompletableFuture.allOf(tasks.toArray(CompletableFuture[]::new)).join(); // IGNORE-BAD-JOIN DIRECT-149116
    }

    private static class FieldUrlWithType {
        private final String url;
        private final VideoConstructorFeedFieldType type;

        public FieldUrlWithType(@Nullable String url, @Nullable VideoConstructorFeedFieldType type) {
            this.url = url;
            this.type = type;
        }

        public String getUrl() {
            return url;
        }

        public VideoConstructorFeedFieldType getType() {
            return type;
        }
    }
}
