/* (C) 2022 YiRing, Inc. */
package com.yiring.common.web;

import cn.hutool.core.io.FileUtil;
import cn.hutool.core.io.IoUtil;
import cn.hutool.core.util.IdUtil;
import cn.hutool.core.util.StrUtil;
import com.alibaba.fastjson2.JSONObject;
import com.github.xiaoymin.knife4j.annotations.ApiSupport;
import com.yiring.common.annotation.DownloadResponse;
import com.yiring.common.config.MinioConfig;
import com.yiring.common.constant.DateFormatter;
import com.yiring.common.core.Minio;
import com.yiring.common.core.Result;
import com.yiring.common.core.Status;
import com.yiring.common.param.DownloadParam;
import com.yiring.common.param.UploadChunkParam;
import com.yiring.common.service.FileManageService;
import com.yiring.common.service.UploadProcessService;
import com.yiring.common.util.FileUtils;
import com.yiring.common.vo.ImageInfo;
import io.minio.GetObjectResponse;
import io.minio.StatObjectResponse;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.Parameter;
import io.swagger.v3.oas.annotations.enums.ParameterIn;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.servlet.http.HttpServletResponse;
import jakarta.validation.constraints.NotBlank;
import java.io.ByteArrayInputStream;
import java.io.File;
import java.io.FileOutputStream;
import java.io.InputStream;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.Paths;
import java.sql.Timestamp;
import java.time.LocalDateTime;
import java.util.List;
import java.util.Objects;
import java.util.stream.Stream;
import lombok.Cleanup;
import lombok.RequiredArgsConstructor;
import lombok.SneakyThrows;
import lombok.extern.slf4j.Slf4j;
import org.springdoc.core.annotations.ParameterObject;
import org.springframework.http.MediaType;
import org.springframework.util.FileCopyUtils;
import org.springframework.validation.annotation.Validated;
import org.springframework.web.bind.annotation.*;
import org.springframework.web.multipart.MultipartFile;

/**
 * Minio S3
 *
 * @author Jim
 */

@Slf4j
@Validated
@ApiSupport(order = -9997)
@Tag(name = "文件管理", description = "file")
@RequiredArgsConstructor
@RestController
@RequestMapping("/common/file/")
public class MinioController {

    final Minio minio;
    final MinioConfig config;
    final UploadProcessService service;
    final FileManageService fileManageService;

    /**
     * minio 上传文件，成功返回文件 url
     */
    @Operation(summary = "文件上传")
    @PostMapping(value = "upload", consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
    public Result<String> upload(@Parameter(name = "文件", required = true) @RequestPart("file") MultipartFile file) {
        try {
            String link = fileManageService.upload(file);
            return Result.ok(link);
        } catch (Exception e) {
            log.error(e.getMessage(), e);
            throw Status.BAD_REQUEST.exception();
        }
    }

    /**
     * 文件分片上传
     * 说明：此接口需要前端配合切片和处理上传逻辑，后端只负责接收和合并
     */
    @SneakyThrows
    @Operation(summary = "文件分片上传")
    @PostMapping(value = "uploadChunk", consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
    public Result<String> chunk(
        @ParameterObject @Validated UploadChunkParam chunkParam,
        @Parameter(name = "分片文件", required = true) @RequestPart("chunk") MultipartFile chunk
    ) {
        // 获取临时目录
        String base = "Upload";
        String tmpDir = System.getProperty("java.io.tmpdir");
        // 存储桶
        String bucket = config.getBucket();

        // 获取文件临时目录
        Path fileTempDir = Paths.get(tmpDir, base, chunkParam.getMd5());
        if (!fileTempDir.toFile().exists()) {
            // 文件临时目录不存在则创建
            Files.createDirectories(fileTempDir);
        }

        // 获取分片文件路径
        String partSuffix = ".part";
        String chunkFileName = chunkParam.getName() + "_" + chunkParam.getChunkIndex() + partSuffix;
        String chunkFilePath = Paths.get(fileTempDir.toString(), chunkFileName).toString();
        boolean isLastChunk = chunkParam.getChunkIndex().equals(chunkParam.getChunks() - 1);

        // 校验分片文件是否存在
        File chunkFile = new File(chunkFilePath);
        if (!isLastChunk && chunkFile.exists() && chunkFile.length() == chunkParam.getChunkSize()) {
            // 分片文件已存在，直接返回
            return Result.ok();
        }

        // 保存分片文件
        chunk.transferTo(chunkFile);

        // 校验分片文件是否为最后一个分片
        if (isLastChunk) {
            // 获取目录下所有分片文件
            List<File> parts = Stream
                .of(FileUtil.ls(fileTempDir.toString()))
                .filter(temp -> temp.getName().endsWith(partSuffix))
                .sorted((a, b) -> {
                    // 按照分片索引排序
                    String aIndex = a.getName().replaceAll(".*_(\\d+)\\" + partSuffix, "$1");
                    String bIndex = b.getName().replaceAll(".*_(\\d+)\\" + partSuffix, "$1");
                    return Integer.compare(Integer.parseInt(aIndex), Integer.parseInt(bIndex));
                })
                .toList();
            // 校验分片数是否一致
            if (parts.size() != chunkParam.getChunks()) {
                // 文件分片数不一致，无法合并
                throw Status.BAD_REQUEST.exception();
            }

            // 合并后的文件
            String object = StrUtil.join("/", base, chunkParam.getMd5(), chunkParam.getName());

            // 获取合并后的文件
            File file = Paths.get(fileTempDir.toString(), chunkParam.getName()).toFile();
            if (file.exists()) {
                // 合并后的文件已存在，校验 MD5 是否一致
                if (Objects.equals(chunkParam.getMd5(), FileUtils.getFileMd5(file))) {
                    // MD5 一致
                    String infoFile = object + ".info";
                    try {
                        GetObjectResponse response = minio.getObject(bucket, infoFile);
                        JSONObject info = JSONObject.parseObject(IoUtil.read(response, StandardCharsets.UTF_8));
                        return Result.ok(minio.getURI(info.getString("targetObject"), bucket));
                    } catch (Exception e) {
                        log.warn("获取合并后的文件描述信息失败, {}: {}, 重新执行合并操作", infoFile, e.getMessage());
                        // 删除文件，重新执行合并操作
                        FileUtil.del(file);
                    }
                }
            }

            // 循环以追加的方式合并分片文件
            @Cleanup
            FileOutputStream outputStream = new FileOutputStream(file);
            for (File part : parts) {
                byte[] chunkData = FileCopyUtils.copyToByteArray(part);
                outputStream.write(chunkData);
            }

            // 合并完成后上传文件
            minio.putObject(file, object);

            // 上传文件描述信息
            String infoObject = object + ".info";
            JSONObject info = new JSONObject();
            info.put("name", chunkParam.getName());
            info.put("size", chunkParam.getSize());
            info.put("type", chunkParam.getType());
            info.put("ext", chunkParam.getExt());
            info.put("md5", chunkParam.getMd5());
            info.put("chunks", chunkParam.getChunks());
            info.put("extra", chunkParam.getExtra());
            info.put("sourceObject", object);

            // Video/Audio: 在文件名上追加时长，视频生成封面图
            object = service.handleMedia(object, file);

            // 补充转换后的文件信息
            info.put("targetObject", object);
            info.put("datetime", LocalDateTime.now().format(DateFormatter.DATE_TIME));

            // 上传文件描述信息
            @Cleanup
            InputStream infoInputStream = new ByteArrayInputStream(
                info.toJSONString().getBytes(StandardCharsets.UTF_8)
            );
            minio.putObject(infoInputStream, MediaType.APPLICATION_JSON_VALUE, infoObject);

            try {
                // 删除临时文件
                FileUtil.del(fileTempDir.toFile());
            } catch (Exception e) {
                // 删除失败，设置为在 JVM 退出时删除
                fileTempDir.toFile().deleteOnExit();
            }

            // 返回文件路径
            return Result.ok(minio.getURI(object, bucket));
        }

        // 非合并操作时，仅返回成功
        return Result.ok();
    }

    @Operation(summary = "Base64 图片上传")
    @Parameter(
        name = "base64Image",
        description = "Base64 图片信息",
        example = "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAQAAAALCAYAAABYpyyrAAAAAXNSR0IArs4c6QAAAARnQU1BAACxjwv8YQUAAAAJcEhZcwAAFiUAABYlAUlSJPAAAAATSURBVBhXYzAwNP6PjIeKgPF/ABj+RUX4hZfVAAAAAElFTkSuQmCC",
        required = true,
        in = ParameterIn.QUERY
    )
    @PostMapping(value = "uploadBase64Image")
    public Result<String> uploadBase64Image(@NotBlank(message = "图片 Base64 信息不能为空") String base64Image) {
        // 解析 Base64 图片信息
        try (ImageInfo image = FileUtils.parseBase64ImageText(base64Image)) {
            // 获取文件信息以及默认存储地址
            String uuid = IdUtil.getSnowflakeNextIdStr();
            String object = minio.buildUploadPath(
                uuid,
                "." + image.getWidth() + "x" + image.getHeight() + "." + image.getSuffix()
            );

            // 上传原文件
            minio.putObject(image.getStream(), image.getContentType(), object);
            return Result.ok(minio.getDefaultURI(object));
        } catch (Exception e) {
            log.error(e.getMessage(), e);
            throw Status.BAD_REQUEST.exception();
        }
    }

    /**
     * MinIO 文件下载（非公开桶）
     *
     * @param response HttpServletResponse
     * @param param    请求参数
     */
    @DownloadResponse
    @Operation(summary = "文件下载")
    @GetMapping(value = "download", produces = MediaType.APPLICATION_OCTET_STREAM_VALUE)
    public void download(HttpServletResponse response, @ParameterObject @Validated DownloadParam param) {
        try (GetObjectResponse object = minio.getObject(param.getBucket(), param.getObject())) {
            StatObjectResponse statObject = minio.objectStat(param.getBucket(), param.getObject());
            long lastModified = Timestamp.from(statObject.lastModified().toInstant()).getTime();
            FileUtils.download(
                response,
                object,
                statObject.size(),
                FileUtil.getName(statObject.object()),
                statObject.contentType(),
                lastModified
            );
        } catch (Exception e) {
            throw Status.BAD_REQUEST.exception(e.getMessage());
        }
    }
}
