package com.pandoli365.bibimbap.security;

import java.io.IOException;
import java.io.InputStream;
import java.io.OutputStream;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.HashSet;
import java.util.Locale;
import java.util.Set;
import java.util.regex.Pattern;
import java.util.stream.Stream;
import java.util.zip.ZipEntry;
import java.util.zip.ZipInputStream;

import org.springframework.web.multipart.MultipartFile;

import com.pandoli365.bibimbap.security.ZipRejectException.Reason;

/**
 * Unity WebGL zip 업로드의 보안/포맷 검증 정석 구현.
 *
 * <p>방어 대상: zip-slip(상위경로 탈출), 심링크 추적 탈출, zip bomb(엔트리 수/엔트리당/누적
 * 해제 크기), 포맷 위장(확장자·MIME·magic). 압축비 검사는 의도적으로 사용하지 않고
 * 절대 크기 3중 상한(엔트리당 256MB + 누적 512MB + 엔트리수 8000)으로 bomb 를 차단한다.
 * 이를 위해 {@link ZipEntry#getCompressedSize()} 는 호출하지 않는다(신뢰 불가 메타).
 *
 * <p>모든 거부는 {@link ZipRejectException} (unchecked) 으로 한국어 사용자 메시지와 함께
 * throw 한다. 인스턴스화하지 않는 static 유틸이다.
 */
public final class ZipSecurity {

    /** 엔트리당 해제 상한(256MB). */
    private static final long ENTRY_MAX_BYTES = 256L * 1024 * 1024;
    /** 누적 해제 상한(512MB). */
    private static final long EXTRACTED_MAX_BYTES = 512L * 1024 * 1024;
    /** 엔트리 수 상한. */
    private static final int MAX_ENTRIES = 8_000;
    /** 엔트리명 최대 길이. */
    private static final int MAX_ENTRY_NAME_LEN = 255;
    /** 엔트리 경로 분절 깊이 상한. */
    private static final int MAX_ENTRY_DEPTH = 32;

    /** 복사 버퍼 크기. */
    private static final int COPY_BUFFER = 8192;

    /** MIME 화이트리스트(소문자 비교). octet-stream 은 브라우저가 zip 을 보낼 때 흔하므로 확장자로 보완. */
    private static final Set<String> ALLOWED_MIME = Set.of(
            "application/zip",
            "application/x-zip-compressed",
            "multipart/x-zip",
            "application/octet-stream");

    /** 윈도우 드라이브 문자 접두(C: 등). 백슬래시는 별도 거부하므로 콜론 형태만 검사. */
    private static final Pattern WINDOWS_DRIVE = Pattern.compile("^[A-Za-z]:");

    private ZipSecurity() {
    }

    /** 검증을 통과한 zip 해제 결과. */
    public record ExtractResult(int entryCount, long extractedBytes) {
    }

    /**
     * MIME(화이트리스트 또는 null) AND 확장자(.zip) 검증. 실패 시 {@link Reason#NOT_ZIP}.
     *
     * <p>확장자는 필수이고, MIME 가 non-null 이면서 화이트리스트에 없으면 거부한다. MIME 가
     * null 인 경우(클라이언트 미설정)는 확장자로 보완 허용한다.
     */
    public static void assertZipType(MultipartFile file) {
        String originalFilename = file.getOriginalFilename();
        boolean hasZipExt = originalFilename != null
                && originalFilename.toLowerCase(Locale.ROOT).endsWith(".zip");
        if (!hasZipExt) {
            throw new ZipRejectException(Reason.NOT_ZIP, "zip 파일만 업로드할 수 있습니다.");
        }

        String contentType = file.getContentType();
        if (contentType != null && !ALLOWED_MIME.contains(contentType.toLowerCase(Locale.ROOT))) {
            throw new ZipRejectException(Reason.NOT_ZIP, "zip 파일만 업로드할 수 있습니다.");
        }
    }

    /**
     * 첫 4바이트 magic 검사. {@code PK\x03\x04}(일반 zip) 또는 {@code PK\x05\x06}(빈 zip) 만
     * 통과. 그 외/4바이트 미만 → {@link Reason#MAGIC_FAIL}.
     *
     * <p>스트림은 {@link MultipartFile#getInputStream()} 으로 새로 열어 4바이트만 peek 한다.
     */
    public static void assertMagic(MultipartFile file) throws IOException {
        byte[] head = new byte[4];
        int read;
        try (InputStream in = file.getInputStream()) {
            read = in.readNBytes(head, 0, 4);
        }
        if (read < 4) {
            throw new ZipRejectException(Reason.MAGIC_FAIL, "올바른 zip 형식이 아닙니다.");
        }
        boolean localFile = (head[0] & 0xFF) == 0x50 && (head[1] & 0xFF) == 0x4B
                && (head[2] & 0xFF) == 0x03 && (head[3] & 0xFF) == 0x04;
        boolean emptyArchive = (head[0] & 0xFF) == 0x50 && (head[1] & 0xFF) == 0x4B
                && (head[2] & 0xFF) == 0x05 && (head[3] & 0xFF) == 0x06;
        if (!localFile && !emptyArchive) {
            throw new ZipRejectException(Reason.MAGIC_FAIL, "올바른 zip 형식이 아닙니다.");
        }
    }

    /**
     * zip 을 {@code targetDir} 하위로 해제한다. zip-slip + 심링크 추적 + 3중 크기 상한을 적용한다.
     *
     * <p>{@code targetDir} 은 호출자가 이미 경계검증한 임시 디렉터리여야 한다.
     *
     * @return 엔트리 수와 누적 해제 바이트
     */
    public static ExtractResult extractZip(MultipartFile file, Path targetDir) throws IOException {
        long extractedBytes = 0;
        int entryCount = 0;

        try (ZipInputStream zip = new ZipInputStream(file.getInputStream())) {
            ZipEntry entry;
            while ((entry = zip.getNextEntry()) != null) {
                entryCount++;
                if (entryCount > MAX_ENTRIES) {
                    throw new ZipRejectException(Reason.TOO_MANY_ENTRIES, "zip 파일의 항목 수가 너무 많습니다.");
                }

                String name = entry.getName();
                validateEntryName(name);

                Path target = targetDir.resolve(name).normalize();
                if (!target.startsWith(targetDir)) {
                    throw new ZipRejectException(Reason.ZIP_SLIP, "허용되지 않은 경로의 항목이 포함되어 있습니다.");
                }

                if (entry.isDirectory()) {
                    assertParentNotSymlink(target, targetDir);
                    Files.createDirectories(target);
                } else {
                    Path parent = target.getParent();
                    if (parent == null) {
                        throw new ZipRejectException(Reason.BAD_ENTRY_NAME, "잘못된 항목 이름이 포함되어 있습니다.");
                    }
                    Files.createDirectories(parent);
                    assertParentNotSymlink(target, targetDir);

                    long copied = copyEntry(zip, target, extractedBytes);
                    extractedBytes += copied;

                    // 쓰기 후 실경로 재검증: 부모가 심링크로 targetDir 밖을 가리키면 차단.
                    if (!target.toRealPath().startsWith(targetDir.toRealPath())) {
                        throw new ZipRejectException(Reason.SYMLINK, "심볼릭 링크 항목은 허용되지 않습니다.");
                    }
                }
                zip.closeEntry();
            }
        }

        if (entryCount == 0) {
            throw new ZipRejectException(Reason.BAD_ENTRY_NAME, "비어 있는 zip 파일입니다.");
        }
        return new ExtractResult(entryCount, extractedBytes);
    }

    /**
     * Unity WebGL 빌드 산출물 4종(loader / framework / data / wasm)이 각각 ≥1개 존재하는지
     * 검증한다. {@code .br}/{@code .gz} 압축 변형은 마커 substring 포함으로 허용한다. 하나라도
     * 없으면 {@link Reason#INCOMPLETE_BUILD}.
     *
     * <p>index.html 검증은 컨트롤러(NO_INDEX) 책임이며 여기서는 다루지 않는다.
     */
    public static void assertUnityBuild(Path extractedRoot) throws IOException {
        Set<String> fileNames = new HashSet<>();
        try (Stream<Path> walk = Files.walk(extractedRoot)) {
            walk.filter(Files::isRegularFile)
                    .map(p -> p.getFileName().toString().toLowerCase(Locale.ROOT))
                    .forEach(fileNames::add);
        }

        boolean hasLoader = false;
        boolean hasFramework = false;
        boolean hasData = false;
        boolean hasWasm = false;
        for (String fileName : fileNames) {
            if (fileName.contains("loader")) {
                hasLoader = true;
            }
            if (fileName.contains("framework")) {
                hasFramework = true;
            }
            if (fileName.contains(".data")) {
                hasData = true;
            }
            if (fileName.contains(".wasm")) {
                hasWasm = true;
            }
        }

        if (!hasLoader || !hasFramework || !hasData || !hasWasm) {
            throw new ZipRejectException(Reason.INCOMPLETE_BUILD, "Unity WebGL 빌드 산출물이 누락되었습니다.");
        }
    }

    /**
     * 엔트리명을 정규화 전 단계에서 사전 거부한다(zip-slip 보조 + 경로 위장 차단). 위반 시
     * {@link Reason#BAD_ENTRY_NAME}.
     */
    private static void validateEntryName(String name) {
        if (name == null || name.isBlank()) {
            throw new ZipRejectException(Reason.BAD_ENTRY_NAME, "잘못된 항목 이름이 포함되어 있습니다.");
        }
        if (name.indexOf('\0') >= 0) {
            throw new ZipRejectException(Reason.BAD_ENTRY_NAME, "잘못된 항목 이름이 포함되어 있습니다.");
        }
        // 백슬래시: 윈도우 경로 우회 차단(UNC \\..\\ 형태도 동시 차단).
        if (name.indexOf('\\') >= 0) {
            throw new ZipRejectException(Reason.BAD_ENTRY_NAME, "잘못된 항목 이름이 포함되어 있습니다.");
        }
        // 절대경로 / UNC(//) / 드라이브(C:) 명시 거부.
        if (name.startsWith("/")) {
            throw new ZipRejectException(Reason.BAD_ENTRY_NAME, "잘못된 항목 이름이 포함되어 있습니다.");
        }
        if (WINDOWS_DRIVE.matcher(name).find()) {
            throw new ZipRejectException(Reason.BAD_ENTRY_NAME, "잘못된 항목 이름이 포함되어 있습니다.");
        }
        if (name.length() > MAX_ENTRY_NAME_LEN) {
            throw new ZipRejectException(Reason.BAD_ENTRY_NAME, "잘못된 항목 이름이 포함되어 있습니다.");
        }

        String[] segments = name.split("/");
        if (segments.length > MAX_ENTRY_DEPTH) {
            throw new ZipRejectException(Reason.BAD_ENTRY_NAME, "잘못된 항목 이름이 포함되어 있습니다.");
        }
        for (String segment : segments) {
            if (segment.equals("..")) {
                throw new ZipRejectException(Reason.BAD_ENTRY_NAME, "잘못된 항목 이름이 포함되어 있습니다.");
            }
        }
    }

    /**
     * target 의 조상 디렉터리(targetDir 제외) 중 심링크가 있으면 {@link Reason#SYMLINK}.
     * 쓰기 직전 호출해 심링크가 가리키는 외부로 파일이 새어 나가는 것을 차단한다.
     */
    private static void assertParentNotSymlink(Path target, Path targetDir) {
        Path p = target.getParent();
        while (p != null && p.startsWith(targetDir) && !p.equals(targetDir)) {
            if (Files.exists(p) && Files.isSymbolicLink(p)) {
                throw new ZipRejectException(Reason.SYMLINK, "심볼릭 링크 항목은 허용되지 않습니다.");
            }
            p = p.getParent();
        }
    }

    /**
     * 엔트리 본문을 target 으로 복사하며 엔트리당/누적 해제 상한을 강제한다. 압축비/선언 크기는
     * 신뢰하지 않고 실제 read 바이트만 누적 카운트한다.
     *
     * @param bytesBefore 이번 엔트리 이전까지의 누적 해제 바이트
     * @return 이번 엔트리에서 해제한 바이트
     */
    private static long copyEntry(InputStream in, Path target, long bytesBefore) throws IOException {
        byte[] buf = new byte[COPY_BUFFER];
        long copied = 0;
        try (OutputStream out = Files.newOutputStream(target)) {
            int read;
            while ((read = in.read(buf)) != -1) {
                copied += read;
                if (copied > ENTRY_MAX_BYTES) {
                    throw new ZipRejectException(Reason.ENTRY_TOO_LARGE, "개별 항목의 크기가 너무 큽니다.");
                }
                if (bytesBefore + copied > EXTRACTED_MAX_BYTES) {
                    throw new ZipRejectException(Reason.EXTRACTED_TOO_LARGE, "압축 해제 크기가 허용 한도를 초과했습니다.");
                }
                out.write(buf, 0, read);
            }
        }
        return copied;
    }
}
