package com.pandoli365.bibimbap.controller.api;

import com.pandoli365.bibimbap.data.GameAssetOwner;
import com.pandoli365.bibimbap.data.GameUploadAudit;
import com.pandoli365.bibimbap.mapper.GameUploadAuditMapper;
import com.pandoli365.bibimbap.security.CsrfTokens;
import com.pandoli365.bibimbap.security.PermissionGate;
import com.pandoli365.bibimbap.security.PermissionKeys;
import com.pandoli365.bibimbap.security.Roles;
import com.pandoli365.bibimbap.security.ZipRejectException;
import com.pandoli365.bibimbap.security.ZipSecurity;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpSession;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
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.RestController;
import org.springframework.web.multipart.MultipartFile;

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.util.ArrayList;
import java.util.Comparator;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import java.util.UUID;
import java.util.stream.Stream;

@RestController
@RequestMapping("/api/game-files")
public class GameUploadController {

    private static final Logger log = LoggerFactory.getLogger(GameUploadController.class);
    private static final long WEBGL_ORIGINAL_MAX_BYTES = 512L * 1024 * 1024;
    private static final long THUMBNAIL_MAX_BYTES = 10L * 1024 * 1024;
    private static final int AUDIT_NAME_MAX_LEN = 255;

    @Value("${app.upload.game-storage-path:src/main/resources/static}")
    private String uploadStoragePath;

    private final PermissionGate permissionGate;
    private final GameUploadAuditMapper auditMapper;

    public GameUploadController(PermissionGate permissionGate, GameUploadAuditMapper auditMapper) {
        this.permissionGate = permissionGate;
        this.auditMapper = auditMapper;
    }

    @GetMapping("/ping")
    public ResponseEntity<Map<String, Object>> ping() {
        return ResponseEntity.ok(Map.of("status", "ok"));
    }

    @PostMapping
    public ResponseEntity<Map<String, Object>> uploadGameFiles(
            @RequestParam("files") MultipartFile[] files,
            @RequestParam(name = "path", required = false) String path,
            HttpServletRequest request,
            HttpSession session
    ) throws IOException {
        if (!CsrfTokens.isValid(request)) {
            return ResponseEntity.status(HttpStatus.FORBIDDEN).body(CsrfTokens.errorBody());
        }
        if (!permissionGate.isAuthenticated(session)) {
            return ResponseEntity.status(HttpStatus.UNAUTHORIZED)
                    .body(Map.of("message", "login is required"));
        }
        if (files == null || files.length == 0) {
            return ResponseEntity.badRequest().body(Map.of("message", "files is required"));
        }

        Path root = gameRoot();
        List<Map<String, String>> uploadedFiles = new ArrayList<>();
        for (MultipartFile file : files) {
            if (file == null || file.isEmpty()) {
                continue;
            }

            Path targetFile;
            try {
                targetFile = resolveTargetFile(root, path, file, files.length);
            } catch (IllegalArgumentException e) {
                return ResponseEntity.badRequest().body(Map.of("message", "invalid path"));
            }

            Files.createDirectories(targetFile.getParent());
            Files.copy(file.getInputStream(), targetFile, StandardCopyOption.REPLACE_EXISTING);
            String relativePath = root.relativize(targetFile).toString().replace('\\', '/');
            uploadedFiles.add(Map.of(
                    "fileName", targetFile.getFileName().toString(),
                    "path", relativePath,
                    "url", "/game/" + relativePath
            ));
        }

        Map<String, Object> response = new LinkedHashMap<>();
        response.put("storagePath", root.toString());
        response.put("files", uploadedFiles);
        return ResponseEntity.ok(response);
    }

    @PostMapping("/webgl-zip")
    public ResponseEntity<Map<String, Object>> uploadWebglZip(
            @RequestParam(name = "file", required = false) MultipartFile file,
            @RequestParam(name = "replaceUuid", required = false) String replaceUuid,
            @RequestParam(name = "mode", required = false) String mode,
            HttpServletRequest request,
            HttpSession session
    ) throws IOException {
        if (!CsrfTokens.isValid(request)) {
            return ResponseEntity.status(HttpStatus.FORBIDDEN).body(CsrfTokens.errorBody());
        }
        log.info("WebGL zip upload request received. fileName={}, size={}, contentType={}",
                file == null ? null : file.getOriginalFilename(),
                file == null ? null : file.getSize(),
                file == null ? null : file.getContentType());
        Long userId = sessionUserId(session);
        if (!permissionGate.isAuthenticated(session) || userId == null) {
            return ResponseEntity.status(HttpStatus.UNAUTHORIZED)
                    .body(Map.of("message", "로그인이 필요합니다."));
        }
        if ("jam".equalsIgnoreCase(mode)
                && !permissionGate.has(session, PermissionKeys.GAME_JAM_MANAGE.name())) {
            return ResponseEntity.status(HttpStatus.FORBIDDEN)
                    .body(Map.of("status", 403, "message", "권한이 없습니다."));
        }
        if (file == null || file.isEmpty()) {
            return ResponseEntity.badRequest().body(Map.of("message", "WebGL zip 파일을 선택해 주세요."));
        }

        String originalName = sanitizeAuditName(file.getOriginalFilename());
        long uploadBytes = file.getSize();

        try {
            ZipSecurity.assertZipType(file);
        } catch (ZipRejectException e) {
            audit(userId, null, "REJECTED", e.reason().name(), originalName, uploadBytes, null, null);
            return ResponseEntity.badRequest().body(Map.of("message", e.getMessage()));
        }
        try {
            ZipSecurity.assertMagic(file);
        } catch (ZipRejectException e) {
            audit(userId, null, "REJECTED", e.reason().name(), originalName, uploadBytes, null, null);
            return ResponseEntity.badRequest().body(Map.of("message", e.getMessage()));
        }
        if (uploadBytes > WEBGL_ORIGINAL_MAX_BYTES) {
            audit(userId, null, "REJECTED", ZipRejectException.Reason.TOO_LARGE.name(),
                    originalName, uploadBytes, null, null);
            return ResponseEntity.status(HttpStatus.PAYLOAD_TOO_LARGE)
                    .body(Map.of("message", "업로드 파일 크기가 허용 한도를 초과했습니다."));
        }

        String newUuid;
        String normalizedReplace = normalizeGameUuid(replaceUuid);
        if (normalizedReplace != null) {
            GameAssetOwner owner = auditMapper.findGameByWebglUuid(normalizedReplace);
            boolean isAdmin = Roles.ADMIN.equals(session.getAttribute("role"));
            if (owner == null || (!userId.equals(owner.getUserId()) && !isAdmin)) {
                audit(userId, normalizedReplace, "REJECTED", null, originalName, uploadBytes, null, null);
                return ResponseEntity.status(HttpStatus.FORBIDDEN)
                        .body(Map.of("status", 403,
                                "message", "교체 대상 게임의 소유자만 재업로드할 수 있습니다."));
            }
            newUuid = normalizedReplace;
        } else {
            newUuid = UUID.randomUUID().toString();
        }

        Path root = gameRoot();
        Path tmpDir = root.resolve(".tmp").resolve(newUuid).normalize();
        if (!tmpDir.startsWith(root)) {
            audit(userId, newUuid, "REJECTED", ZipRejectException.Reason.ZIP_SLIP.name(),
                    originalName, uploadBytes, null, null);
            return ResponseEntity.badRequest().body(Map.of("message", "저장 경로가 올바르지 않습니다."));
        }
        Path finalDir = root.resolve(newUuid).normalize();
        if (!finalDir.startsWith(root)) {
            audit(userId, newUuid, "REJECTED", ZipRejectException.Reason.ZIP_SLIP.name(),
                    originalName, uploadBytes, null, null);
            return ResponseEntity.badRequest().body(Map.of("message", "저장 경로가 올바르지 않습니다."));
        }
        Path backupDir = root.resolve(".tmp").resolve(newUuid + ".old").normalize();

        try {
            deleteRecursively(tmpDir);
            Files.createDirectories(tmpDir);
            ZipSecurity.ExtractResult extractResult = ZipSecurity.extractZip(file, tmpDir);

            Path indexFile = findIndexFile(tmpDir);
            if (indexFile == null) {
                deleteRecursively(tmpDir);
                audit(userId, newUuid, "REJECTED", ZipRejectException.Reason.NO_INDEX.name(),
                        originalName, uploadBytes, null, null);
                return ResponseEntity.badRequest()
                        .body(Map.of("message", "zip 안에서 index.html을 찾지 못했습니다."));
            }
            ZipSecurity.assertUnityBuild(tmpDir);

            String relIndex = tmpDir.relativize(indexFile).toString().replace('\\', '/');

            Files.createDirectories(finalDir.getParent());
            boolean hadExisting = Files.exists(finalDir);
            if (hadExisting) {
                deleteRecursively(backupDir);
                Files.move(finalDir, backupDir);
            }
            try {
                Files.move(tmpDir, finalDir, StandardCopyOption.ATOMIC_MOVE);
            } catch (java.nio.file.AtomicMoveNotSupportedException ex) {
                Files.move(tmpDir, finalDir, StandardCopyOption.REPLACE_EXISTING);
            }
            deleteRecursively(backupDir);

            String webglPath = "/game/" + newUuid + "/" + relIndex;
            String deployPath = "/game/" + newUuid + "/";
            Map<String, Object> response = new LinkedHashMap<>();
            response.put("status", 200);
            response.put("message", "WebGL 파일이 압축 해제되었습니다.");
            response.put("gameUuid", newUuid);
            response.put("webglPath", webglPath);
            response.put("deployPath", deployPath);
            response.put("entryCount", extractResult.entryCount());
            response.put("extractedBytes", extractResult.extractedBytes());
            log.info("WebGL zip upload completed. gameUuid={}, webglPath={}, entryCount={}, extractedBytes={}",
                    newUuid, webglPath, extractResult.entryCount(), extractResult.extractedBytes());
            audit(userId, newUuid, "SUCCESS", null, originalName, uploadBytes,
                    extractResult.entryCount(), extractResult.extractedBytes());
            return ResponseEntity.ok(response);
        } catch (ZipRejectException e) {
            deleteRecursively(tmpDir);
            audit(userId, newUuid, "REJECTED", e.reason().name(), originalName, uploadBytes, null, null);
            HttpStatus status = e.reason() == ZipRejectException.Reason.TOO_LARGE
                    ? HttpStatus.PAYLOAD_TOO_LARGE
                    : HttpStatus.BAD_REQUEST;
            return ResponseEntity.status(status).body(Map.of("message", e.getMessage()));
        } catch (IOException e) {
            deleteRecursively(tmpDir);
            throw e;
        } finally {
            // 불변식: 실패 시 tmp/backup 만 정리하고 기존 finalDir 는 무손상 유지.
            // swap 도중 구버전만 backup 으로 대피된 상태(finalDir 비어있음)면 복원한다.
            try {
                deleteRecursively(tmpDir);
                if (Files.exists(backupDir)) {
                    if (!Files.exists(finalDir)) {
                        Files.move(backupDir, finalDir);
                    } else {
                        deleteRecursively(backupDir);
                    }
                }
            } catch (IOException cleanupEx) {
                log.warn("webgl-zip cleanup failed for {}: {}", newUuid, cleanupEx.getMessage());
            }
        }
    }

    @PostMapping("/thumbnail")
    public ResponseEntity<Map<String, Object>> uploadThumbnail(
            @RequestParam(name = "file", required = false) MultipartFile file,
            @RequestParam(name = "gameUuid", required = false) String gameUuid,
            HttpServletRequest request,
            HttpSession session
    ) throws IOException {
        if (!CsrfTokens.isValid(request)) {
            return ResponseEntity.status(HttpStatus.FORBIDDEN).body(CsrfTokens.errorBody());
        }
        if (!permissionGate.isAuthenticated(session)) {
            return ResponseEntity.status(HttpStatus.UNAUTHORIZED)
                    .body(Map.of("message", "로그인이 필요합니다."));
        }
        if (file == null || file.isEmpty()) {
            return ResponseEntity.badRequest().body(Map.of("message", "썸네일 이미지를 선택해 주세요."));
        }
        if (file.getSize() > THUMBNAIL_MAX_BYTES) {
            return ResponseEntity.status(HttpStatus.PAYLOAD_TOO_LARGE)
                    .body(Map.of("message", "썸네일 이미지는 10MB 이하로 올려 주세요."));
        }

        String extension = imageExtension(file);
        if (extension == null) {
            return ResponseEntity.badRequest()
                    .body(Map.of("message", "PNG, JPG, WEBP, GIF 이미지만 사용할 수 있습니다."));
        }

        String normalizedGameUuid = normalizeGameUuid(gameUuid);
        if (normalizedGameUuid == null) {
            return ResponseEntity.badRequest().body(Map.of("message", "WebGL zip을 먼저 업로드해 주세요."));
        }

        Path root = gameRoot();
        Path targetDir = root.resolve(normalizedGameUuid).normalize();
        Path targetFile = targetDir.resolve("thumbnail" + extension).normalize();
        if (!targetDir.startsWith(root) || !targetFile.startsWith(targetDir)) {
            return ResponseEntity.badRequest().body(Map.of("message", "저장 경로가 올바르지 않습니다."));
        }
        if (!Files.isDirectory(targetDir)) {
            return ResponseEntity.badRequest().body(Map.of("message", "WebGL zip을 먼저 업로드해 주세요."));
        }

        deleteExistingThumbnail(targetDir);
        Files.copy(file.getInputStream(), targetFile, StandardCopyOption.REPLACE_EXISTING);
        String thumbnailUrl = "/game/" + root.relativize(targetFile).toString().replace('\\', '/');

        Map<String, Object> response = new LinkedHashMap<>();
        response.put("status", 200);
        response.put("message", "썸네일 이미지가 저장되었습니다.");
        response.put("thumbnailUrl", thumbnailUrl);
        return ResponseEntity.ok(response);
    }

    private Path gameRoot() {
        return Paths.get(uploadStoragePath).toAbsolutePath().normalize().resolve("game").normalize();
    }

    private Long sessionUserId(HttpSession session) {
        if (session == null) {
            return null;
        }
        Object userId = session.getAttribute("userId");
        if (userId instanceof Number number) {
            return number.longValue();
        }
        if (userId instanceof String text) {
            try {
                return Long.parseLong(text);
            } catch (NumberFormatException e) {
                return null;
            }
        }
        return null;
    }

    private void audit(Long actorId, String gameUuid, String outcome, String rejectReason,
                       String originalName, Long uploadBytes, Integer entryCount, Long extractedBytes) {
        try {
            GameUploadAudit a = new GameUploadAudit();
            a.setActorId(actorId);
            a.setGameUuid(gameUuid);
            a.setOutcome(outcome);
            a.setRejectReason(rejectReason);
            a.setOriginalName(originalName);
            a.setUploadBytes(uploadBytes);
            a.setEntryCount(entryCount);
            a.setExtractedBytes(extractedBytes);
            auditMapper.insertAudit(a);
        } catch (Exception ex) {
            log.warn("upload audit insert failed: {}", ex.getMessage());
        }
    }

    private String sanitizeAuditName(String name) {
        if (name == null) {
            return null;
        }
        return name.length() > AUDIT_NAME_MAX_LEN ? name.substring(0, AUDIT_NAME_MAX_LEN) : name;
    }

    private Path findIndexFile(Path targetDir) throws IOException {
        try (Stream<Path> paths = Files.walk(targetDir)) {
            return paths
                    .filter(Files::isRegularFile)
                    .filter(path -> "index.html".equalsIgnoreCase(path.getFileName().toString()))
                    .min(Comparator
                            .comparingInt((Path path) -> targetDir.relativize(path).getNameCount())
                            .thenComparing(path -> targetDir.relativize(path).toString()))
                    .orElse(null);
        }
    }

    private String imageExtension(MultipartFile file) {
        String contentType = file.getContentType();
        if (contentType != null) {
            switch (contentType.toLowerCase(Locale.ROOT)) {
                case "image/png":
                    return ".png";
                case "image/jpeg":
                case "image/jpg":
                    return ".jpg";
                case "image/webp":
                    return ".webp";
                case "image/gif":
                    return ".gif";
                default:
                    break;
            }
        }

        String originalName = file.getOriginalFilename();
        if (originalName == null) {
            return null;
        }
        String cleanName = Paths.get(originalName).getFileName().toString().toLowerCase(Locale.ROOT);
        if (cleanName.endsWith(".png")) {
            return ".png";
        }
        if (cleanName.endsWith(".jpg") || cleanName.endsWith(".jpeg")) {
            return ".jpg";
        }
        if (cleanName.endsWith(".webp")) {
            return ".webp";
        }
        if (cleanName.endsWith(".gif")) {
            return ".gif";
        }
        return null;
    }

    private String normalizeGameUuid(String gameUuid) {
        if (gameUuid == null || gameUuid.isBlank()) {
            return null;
        }
        try {
            return UUID.fromString(gameUuid.trim()).toString();
        } catch (IllegalArgumentException e) {
            return null;
        }
    }

    private void deleteExistingThumbnail(Path targetDir) throws IOException {
        try (Stream<Path> paths = Files.list(targetDir)) {
            for (Path path : paths.toList()) {
                String fileName = path.getFileName().toString().toLowerCase(Locale.ROOT);
                if (Files.isRegularFile(path) && isThumbnailFile(fileName)) {
                    Files.deleteIfExists(path);
                }
            }
        }
    }

    private boolean isThumbnailFile(String fileName) {
        return fileName.equals("thumbnail.png")
                || fileName.equals("thumbnail.jpg")
                || fileName.equals("thumbnail.webp")
                || fileName.equals("thumbnail.gif");
    }

    private void deleteRecursively(Path path) throws IOException {
        if (!Files.exists(path)) {
            return;
        }
        try (Stream<Path> paths = Files.walk(path)) {
            for (Path target : paths.sorted(Comparator.reverseOrder()).toList()) {
                Files.deleteIfExists(target);
            }
        }
    }

    private Path resolveTargetFile(Path root, String path, MultipartFile file, int fileCount) {
        String safePath = path == null ? "" : path.trim().replace('\\', '/');
        while (safePath.startsWith("/")) {
            safePath = safePath.substring(1);
        }

        boolean usePathAsDirectory = safePath.isBlank() || safePath.endsWith("/") || fileCount > 1;
        Path targetFile = usePathAsDirectory
                ? root.resolve(safePath).resolve(uniqueFileName(file.getOriginalFilename()))
                : root.resolve(safePath);
        targetFile = targetFile.normalize();
        if (!targetFile.startsWith(root) || targetFile.getParent() == null) {
            throw new IllegalArgumentException("invalid path");
        }
        return targetFile;
    }

    private String uniqueFileName(String originalFileName) {
        String cleanName = originalFileName == null ? "file" : Paths.get(originalFileName).getFileName().toString();
        cleanName = cleanName.replaceAll("[^A-Za-z0-9._-]", "_");
        if (cleanName.isBlank() || ".".equals(cleanName) || "..".equals(cleanName)) {
            cleanName = "file";
        }
        return UUID.randomUUID() + "_" + cleanName;
    }
}
