package me.qwq.doghouse.controller.admin;

import com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper;
import com.baomidou.mybatisplus.core.metadata.IPage;
import com.github.yulichang.wrapper.MPJLambdaWrapper;

import lombok.extern.slf4j.Slf4j;
import me.qwq.doghouse.config.DoghouseProperties;
import me.qwq.doghouse.controller.common.BaseController;
import me.qwq.doghouse.entity.Asset;
import me.qwq.doghouse.entity.Post;
import me.qwq.doghouse.entity.config.SiteConfig;
import me.qwq.doghouse.enums.FileTypeEnum;
import me.qwq.doghouse.exception.RException;
import me.qwq.doghouse.pojo.dto.LayPageReq;
import me.qwq.doghouse.pojo.dto.LayPageResp;
import me.qwq.doghouse.pojo.dto.R;
import me.qwq.doghouse.service.AssetService;
import me.qwq.doghouse.util.DateUtils;

import org.apache.commons.lang3.StringUtils;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Controller;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.ResponseBody;
import org.springframework.web.multipart.MultipartFile;
import org.sqids.Sqids;

import java.awt.image.BufferedImage;
import java.io.File;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.Paths;
import java.nio.file.StandardCopyOption;
import java.security.SecureRandom;
import java.time.LocalDateTime;
import java.util.ArrayList;
import java.util.Calendar;
import java.util.List;
import java.util.Objects;
import java.util.concurrent.TimeUnit;

import javax.imageio.ImageIO;

/**
 * Asset controller.
 * @author Doghole
 */
@Controller
@RequestMapping("/admin/v2/asset")
@Slf4j
public class AssetController extends BaseController {

    private static final int IMAGE_ENCODE_TIMEOUT_SECONDS = 15;

    private static final SecureRandom SECURE_RANDOM = new SecureRandom();

    private static final Sqids SQIDS = Sqids.builder().build();

    @Autowired
    DoghouseProperties doghouseProperties;

    @Autowired
    AssetService assetService;

    @Autowired
    SiteConfig siteConfig;

    /**
     * Main page.
     * @return
     */
    @GetMapping({"", "/", "/index"})
    String index() {
        return "admin/asset/index";
    }

    @ResponseBody
    @GetMapping("/list")
    public LayPageResp<?> getAssetList(LayPageReq<Asset> pageReq) {

        MPJLambdaWrapper<Asset> ew = new MPJLambdaWrapper<Asset>()
                .selectAll(Asset.class)
                .leftJoin(Post.class, Post::getPostId, Asset::getFromPostId)
                .orderByDesc(Asset::getCreateTime);

        IPage<Asset> page = assetService.page(pageReq, ew);

        LayPageResp<Asset> result = new LayPageResp<>(page);
        return result;
    }

    /**
     * Upload file.
     */
    @ResponseBody
    @PostMapping("/uploadFile")
    R<?> uploadFile(
            @RequestParam(name = "file") MultipartFile file,
            @RequestParam(name = "postId", required = false) Long postId) {

        if (file == null || file.isEmpty()) {
            return R.badRequest("上传文件不能为空");
        }

        String suffix = getSuffix(file);
        if (StringUtils.isBlank(suffix)) {
            return R.badRequest("无法识别文件扩展名");
        }
        if (FileTypeEnum.isUploadDenied(suffix)) {
            return R.badRequest("不允许上传该类型文件");
        }

        String prefix = generateFilePrefix();
        String filename = prefix + '.' + suffix;
        String originalFilename = StringUtils.defaultIfBlank(file.getOriginalFilename(), filename);
        String originalPrefix = getOriginalPrefix(originalFilename, suffix);

        Calendar calendar = Calendar.getInstance();
        String yearAndMonth = String.format("%d/%02d", calendar.get(Calendar.YEAR), calendar.get(Calendar.MONTH) + 1);
        Path uploadRoot = Paths.get(doghouseProperties.getFileUploadPath()).toAbsolutePath().normalize();
        Path filePlacedDirPath = uploadRoot.resolve(yearAndMonth).normalize();
        Path storedPath = null;
        Path destPath = filePlacedDirPath.resolve(filename).normalize();

        try {
            if (!filePlacedDirPath.startsWith(uploadRoot) || !destPath.startsWith(filePlacedDirPath)) {
                throw RException.badRequest("非法上传路径");
            }

            Files.createDirectories(filePlacedDirPath);
            try (var input = file.getInputStream()) {
                Files.copy(input, destPath, StandardCopyOption.REPLACE_EXISTING);
            }
            storedPath = destPath;

            String fileUrl = getSqlPath(yearAndMonth, filename);
            FileTypeEnum fileType = FileTypeEnum.getFileType(suffix);
            LocalDateTime now = LocalDateTime.now();

            Asset asset = new Asset()
                    .setFromPostId(postId)
                    .setTitle(originalFilename)
                    .setCreateTime(now)
                    .setFileType(fileType)
                    .setFileSize(Files.size(storedPath))
                    .setUpdateTime(now)
                    .setPath(fileUrl);

            if (fileType == FileTypeEnum.IMAGE) {
                storedPath = prepareImageAsset(asset, suffix, storedPath, prefix, originalPrefix, yearAndMonth);
            }

            if (assetService.save(asset)) {
                return R.ok(asset);
            }
            deleteQuietly(storedPath);
            throw RException.badRequest("文件上传成功但保存失败");
        }
        catch (IOException e) {
            deleteQuietly(storedPath);
            deleteQuietly(destPath);
            log.error("文件上传失败", e);
            return R.internalServerError("文件上传失败");
        }
        catch (RuntimeException e) {
            deleteQuietly(storedPath);
            deleteQuietly(destPath);
            throw e;
        }
    }

    /**
     * Update asset base info.
     * @param asset
     * @return
     */
    @ResponseBody
    @PostMapping("/update")
    R<?> update(Asset asset) {
        if (Objects.isNull(asset) || Objects.isNull(asset.getAssetId())) {
            throw RException.badRequest("提供的资源不能为空！");
        }
        LambdaUpdateWrapper<Asset> uw = new LambdaUpdateWrapper<>();
        uw.eq(Asset::getAssetId, asset.getAssetId())
          .set(Asset::getTitle, asset.getTitle())
          .set(Asset::getDescription, asset.getDescription());
        return R.judge(assetService.update(uw), "更新资源信息失败！");
    }

    /**
     * Delete asset.
     * @param assetId
     * @return
     */
    @ResponseBody
    @PostMapping("/delete")
    R<?> delete(Long assetId) {
        if (Objects.isNull(assetId)) {
            throw RException.badRequest("提供的资源不能为空！");
        }
        Asset asset = assetService.getById(assetId);
        if (Objects.isNull(asset)) {
            log.info("试图删除已不存在于数据库内的资源");
            return R.ok();
        }

        Path file = resolveAssetUploadPath(asset);
        if (Files.exists(file)) {
            try {
                Files.delete(file);
            }
            catch (IOException e) {
                log.warn("删除文件失败 {}", file, e);
                throw RException.badRequest("删除文件失败");
            }
        }
        return R.judge(assetService.removeById(assetId), "删除文件失败");
    }

    /**
     * Unlink asset.
     * @param assetId
     * @return
     */
    @ResponseBody
    @PostMapping("/unlink")
    R<?> unlink(Long assetId) {
        if (Objects.isNull(assetId)) {
            throw RException.badRequest("提供的资源不能为空！");
        }
        Asset asset = assetService.getById(assetId);
        if (Objects.isNull(asset)) {
            log.info("试图操作已不存在于数据库内的资源");
            return R.ok();
        }
        if (Objects.isNull(asset.getFromPostId())) {
            return R.ok("该资源并未绑定 Post");
        }

        return R.judge(
                assetService.update(new LambdaUpdateWrapper<Asset>()
                    .eq(Asset::getAssetId, asset.getAssetId())
                    .set(Asset::getFromPostId, null)), "解绑失败");
    }

    /**
     * Get lowercase filename suffix, without ".".
     */
    public static String getSuffix(MultipartFile file) {
        if (file == null) {
            return null;
        }
        String fileName = file.getOriginalFilename();
        if (StringUtils.isBlank(fileName)) {
            return null;
        }
        int lastIndexOfDot = fileName.lastIndexOf('.');
        if (lastIndexOfDot == -1 || lastIndexOfDot == fileName.length() - 1) {
            return "";
        }
        return FileTypeEnum.normalizeExtension(fileName.substring(lastIndexOfDot + 1));
    }

    /**
     * Use timestamp + random Sqids as filename prefix.
     * @return
     */
    public static String generateFilePrefix() {
        String dateStr = DateUtils.format(LocalDateTime.now(), "yyyyMMdd_HHmmssSSS_");

        StringBuilder sb = new StringBuilder();
        sb.append(dateStr);

        List<Long> list = new ArrayList<>();
        list.add(Math.abs(SECURE_RANDOM.nextLong()));

        String id = SQIDS.encode(list);
        sb.append(id);
        return sb.toString();
    }

    private Path prepareImageAsset(
            Asset asset,
            String suffix,
            Path originalFile,
            String finalPrefix,
            String originalPrefix,
            String yearAndMonth) throws IOException {

        BufferedImage image = readRasterImage(suffix, originalFile);
        if (image != null) {
            asset.setImageHeight(image.getHeight())
                 .setImageWidth(image.getWidth());
        }

        if (!Boolean.TRUE.equals(siteConfig.getImageCompress()) || !FileTypeEnum.isImageCompressible(suffix)) {
            return originalFile;
        }

        File avifFile = getAvif(suffix, originalFile.toString(), finalPrefix, originalFile.getParent());
        File webpFile = getWebp(suffix, originalFile.toString(), finalPrefix, originalFile.getParent());
        File finalFile = chooseCompressedFile(originalFile.toFile(), avifFile, webpFile);

        if (finalFile == null) {
            return originalFile;
        }

        String finalSuffix = getFileSuffix(finalFile.getName());
        asset
                .setPath(getSqlPath(yearAndMonth, finalFile.getName()))
                .setFileSize(finalFile.length())
                .setTitle(originalPrefix + '.' + finalSuffix);

        deleteQuietly(originalFile);
        return finalFile.toPath();
    }

    private static BufferedImage readRasterImage(String suffix, Path file) throws IOException {
        if (!mustBeReadableRaster(suffix)) {
            return null;
        }
        BufferedImage image = ImageIO.read(file.toFile());
        if (image == null) {
            throw RException.badRequest("不支持或损坏的图片文件");
        }
        return image;
    }

    private static boolean mustBeReadableRaster(String suffix) {
        suffix = FileTypeEnum.normalizeExtension(suffix);
        return "jpg".equals(suffix)
                || "jpeg".equals(suffix)
                || "png".equals(suffix)
                || "gif".equals(suffix)
                || "bmp".equals(suffix)
                || "webp".equals(suffix);
    }

    private static File chooseCompressedFile(File originalFile, File avifFile, File webpFile) {
        File finalFile = null;
        if (isSmallerFile(avifFile, originalFile)) {
            finalFile = avifFile;
        }
        if (isSmallerFile(webpFile, finalFile == null ? originalFile : finalFile)) {
            deleteQuietly(finalFile);
            finalFile = webpFile;
        }
        else {
            deleteQuietly(webpFile);
        }

        if (finalFile != avifFile) {
            deleteQuietly(avifFile);
        }
        return finalFile;
    }

    private static boolean isSmallerFile(File candidate, File base) {
        return candidate != null
                && candidate.exists()
                && candidate.isFile()
                && base != null
                && candidate.length() > 0
                && candidate.length() < base.length();
    }

    private static File getAvif(String suffix, String originalFilePath, String finalPrefix, Path filePlacedDirPath) {
        suffix = FileTypeEnum.normalizeExtension(suffix);
        if (!"jpg".equals(suffix) && !"jpeg".equals(suffix) && !"png".equals(suffix)) {
            return null;
        }

        String avifFilename = finalPrefix + ".avif";
        String avifPath = filePlacedDirPath.resolve(avifFilename).toString();
        List<String> avifCommand = List.of(
                "avifenc",
                "-q", "60",
                "--qalpha", "80",
                "-s", "6",
                "-j", "all",
                originalFilePath,
                avifPath);

        return executeFile(avifCommand, avifPath);
    }

    private static File getWebp(String suffix, String originalFilePath, String finalPrefix, Path filePlacedDirPath) {
        suffix = FileTypeEnum.normalizeExtension(suffix);
        if (!FileTypeEnum.isImageCompressible(suffix)) {
            return null;
        }

        String webpFilename = finalPrefix + ".webp";
        String webpPath = filePlacedDirPath.resolve(webpFilename).toString();
        List<String> webpCommand =
            !"gif".equals(suffix) ?
                    List.of("cwebp",
                            "-z", "9",
                            "-q", "100",
                            "-near_lossless", "60",
                            "-m", "6",
                            "-mt", originalFilePath,
                            "-o", webpPath):
                    List.of("gif2webp",
                            "-q", "90",
                            "-lossy", "-min_size",
                            "-m", "0",
                            "-mt", originalFilePath,
                            "-o", webpPath);
        return executeFile(webpCommand, webpPath);
    }

    private static File executeFile(List<String> command, String filePath) {
        ProcessBuilder pb = new ProcessBuilder(command);
        pb.redirectOutput(ProcessBuilder.Redirect.DISCARD);
        pb.redirectError(ProcessBuilder.Redirect.DISCARD);
        try {
            Process process = pb.start();
            boolean finished = process.waitFor(IMAGE_ENCODE_TIMEOUT_SECONDS, TimeUnit.SECONDS);
            if (!finished) {
                process.destroyForcibly();
                log.warn("Execution timed out, command {}", command);
                return null;
            }

            int exitCode = process.exitValue();
            if (exitCode == 0) {
                File out = new File(filePath);
                if (!out.exists()) {
                    log.warn("Command finished with code 0, but output file not found: {}", filePath);
                    return null;
                }
                return out;
            }
            log.warn("Execution failed with exit code: {}, command {}", exitCode, command);
            return null;
        }
        catch (IOException e) {
            log.warn("Cannot execute command {}, {}", command, e.getMessage());
            return null;
        }
        catch (InterruptedException e) {
            Thread.currentThread().interrupt();
            log.warn("Execution interrupted, command {}", command);
            return null;
        }
    }

    private String getSqlPath(String yearAndMonth, String filename) {
        return Paths.get(doghouseProperties.getFileSqlPath(), yearAndMonth, filename)
                .toString()
                .replace('\\', '/');
    }

    private Path resolveAssetUploadPath(Asset asset) {
        Path uploadRoot = Paths.get(doghouseProperties.getFileUploadPath()).toAbsolutePath().normalize();
        Path file = Paths.get(doghouseProperties.getFileUploadPath(asset)).toAbsolutePath().normalize();
        if (!file.startsWith(uploadRoot)) {
            throw RException.badRequest("非法资源路径");
        }
        return file;
    }

    private static String getOriginalPrefix(String originalFilename, String suffix) {
        int suffixLength = StringUtils.length(suffix);
        int dotIndex = originalFilename.length() - suffixLength - 1;
        if (dotIndex <= 0 || dotIndex >= originalFilename.length()) {
            return originalFilename;
        }
        return originalFilename.substring(0, dotIndex);
    }

    private static String getFileSuffix(String filename) {
        int lastIndexOfDot = filename.lastIndexOf('.');
        if (lastIndexOfDot == -1 || lastIndexOfDot == filename.length() - 1) {
            return "";
        }
        return filename.substring(lastIndexOfDot + 1);
    }

    private static void deleteQuietly(Path file) {
        if (file == null) {
            return;
        }
        try {
            Files.deleteIfExists(file);
        }
        catch (IOException e) {
            log.warn("Cannot delete file {}", file, e);
        }
    }

    private static void deleteQuietly(File file) {
        if (file != null) {
            deleteQuietly(file.toPath());
        }
    }
}
