package com.pandoli365.bibimbap.security;

import static org.junit.jupiter.api.Assertions.assertDoesNotThrow;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.junit.jupiter.api.Assertions.assertTrue;

import java.io.ByteArrayOutputStream;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.LinkedHashMap;
import java.util.UUID;
import java.util.zip.ZipEntry;
import java.util.zip.ZipOutputStream;

import org.junit.jupiter.api.Assumptions;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
import org.springframework.mock.web.MockMultipartFile;

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

/**
 * {@link ZipSecurity} 보안 핵심 단위 테스트.
 *
 * <p>설계 VP-1(zip-slip/심링크/엔트리명), VP-2(zip bomb 3중 상한), VP-3/VP-4(포맷·magic·
 * Unity 빌드 완전성)를 거부코드 단위로 커버한다. 순수 단위 테스트(@SpringBootTest 미사용).
 */
class ZipSecurityTest {

    private static final int ONE_MB = 1024 * 1024;

    // ---------------------------------------------------------------------
    // VP-1: zip-slip / 엔트리명 사전거부 (BAD_ENTRY_NAME)
    // ---------------------------------------------------------------------

    @Test
    void extractZip_rejectsParentTraversal(@TempDir Path tempDir) throws IOException {
        LinkedHashMap<String, byte[]> entries = new LinkedHashMap<>();
        entries.put("../escape.txt", "x".getBytes());
        MockMultipartFile file = zipFile("build.zip", makeZip(entries));

        ZipRejectException ex = assertThrows(ZipRejectException.class, () -> ZipSecurity.extractZip(file, tempDir));
        assertEquals(Reason.BAD_ENTRY_NAME, ex.reason());

        // 대상 디렉터리 밖 파일 0 검증: 상위 디렉터리에 escape.txt 가 생성되지 않아야 한다.
        assertFalse(Files.exists(tempDir.getParent().resolve("escape.txt")));
    }

    @Test
    void extractZip_rejectsAbsolutePath(@TempDir Path tempDir) throws IOException {
        LinkedHashMap<String, byte[]> entries = new LinkedHashMap<>();
        entries.put("/etc/passwd", "x".getBytes());
        MockMultipartFile file = zipFile("build.zip", makeZip(entries));

        ZipRejectException ex = assertThrows(ZipRejectException.class, () -> ZipSecurity.extractZip(file, tempDir));
        assertEquals(Reason.BAD_ENTRY_NAME, ex.reason());
    }

    @Test
    void extractZip_rejectsBackslash(@TempDir Path tempDir) throws IOException {
        LinkedHashMap<String, byte[]> entries = new LinkedHashMap<>();
        entries.put("..\\win.txt", "x".getBytes());
        MockMultipartFile file = zipFile("build.zip", makeZip(entries));

        ZipRejectException ex = assertThrows(ZipRejectException.class, () -> ZipSecurity.extractZip(file, tempDir));
        assertEquals(Reason.BAD_ENTRY_NAME, ex.reason());
    }

    @Test
    void extractZip_rejectsDriveLetter(@TempDir Path tempDir) throws IOException {
        LinkedHashMap<String, byte[]> entries = new LinkedHashMap<>();
        entries.put("C:evil.txt", "x".getBytes());
        MockMultipartFile file = zipFile("build.zip", makeZip(entries));

        ZipRejectException ex = assertThrows(ZipRejectException.class, () -> ZipSecurity.extractZip(file, tempDir));
        assertEquals(Reason.BAD_ENTRY_NAME, ex.reason());
    }

    @Test
    void extractZip_rejectsNulByte(@TempDir Path tempDir) throws IOException {
        LinkedHashMap<String, byte[]> entries = new LinkedHashMap<>();
        entries.put("a\0b.txt", "x".getBytes());
        MockMultipartFile file = zipFile("build.zip", makeZip(entries));

        ZipRejectException ex = assertThrows(ZipRejectException.class, () -> ZipSecurity.extractZip(file, tempDir));
        assertEquals(Reason.BAD_ENTRY_NAME, ex.reason());
    }

    @Test
    void extractZip_rejectsTooLongName(@TempDir Path tempDir) throws IOException {
        String longName = "a".repeat(256) + ".txt"; // 255자 초과
        LinkedHashMap<String, byte[]> entries = new LinkedHashMap<>();
        entries.put(longName, "x".getBytes());
        MockMultipartFile file = zipFile("build.zip", makeZip(entries));

        ZipRejectException ex = assertThrows(ZipRejectException.class, () -> ZipSecurity.extractZip(file, tempDir));
        assertEquals(Reason.BAD_ENTRY_NAME, ex.reason());
    }

    @Test
    void extractZip_rejectsTooDeepName(@TempDir Path tempDir) throws IOException {
        // "a/" x 33 + "f.txt" → split("/") 분절 수 34 > 32.
        String deepName = "a/".repeat(33) + "f.txt";
        LinkedHashMap<String, byte[]> entries = new LinkedHashMap<>();
        entries.put(deepName, "x".getBytes());
        MockMultipartFile file = zipFile("build.zip", makeZip(entries));

        ZipRejectException ex = assertThrows(ZipRejectException.class, () -> ZipSecurity.extractZip(file, tempDir));
        assertEquals(Reason.BAD_ENTRY_NAME, ex.reason());
    }

    @Test
    void extractZip_rejectsEmptyZip(@TempDir Path tempDir) throws IOException {
        // 엔트리 0 → 빈 zip(PK\x05\x06). extractZip 은 BAD_ENTRY_NAME.
        MockMultipartFile file = zipFile("build.zip", makeZip(new LinkedHashMap<>()));

        ZipRejectException ex = assertThrows(ZipRejectException.class, () -> ZipSecurity.extractZip(file, tempDir));
        assertEquals(Reason.BAD_ENTRY_NAME, ex.reason());
    }

    // ---------------------------------------------------------------------
    // VP-1: 심링크 추적 탈출 (SYMLINK) — 플랫폼 가드 적용
    // ---------------------------------------------------------------------

    @Test
    void extractZip_rejectsSymlinkParent(@TempDir Path tempDir) throws IOException {
        Path outside = null;
        try {
            outside = Files.createDirectory(tempDir.getParent().resolve("outside-" + UUID.randomUUID()));
            Path linkDir = tempDir.resolve("link");
            try {
                Files.createSymbolicLink(linkDir, outside);
            } catch (UnsupportedOperationException | IOException e) {
                // 심링크 미지원/권한 부족 플랫폼 → skip.
                Assumptions.assumeTrue(false, "symlink not supported on this platform");
            }

            LinkedHashMap<String, byte[]> entries = new LinkedHashMap<>();
            entries.put("link/evil.txt", "x".getBytes());
            MockMultipartFile file = zipFile("build.zip", makeZip(entries));

            ZipRejectException ex = assertThrows(ZipRejectException.class,
                    () -> ZipSecurity.extractZip(file, tempDir));
            assertEquals(Reason.SYMLINK, ex.reason());

            // 심링크가 가리키는 외부 디렉터리에 파일이 새어 나가지 않아야 한다.
            assertFalse(Files.exists(outside.resolve("evil.txt")));
        } finally {
            // @TempDir 는 tempDir 만 정리하므로 상위에 만든 outside 는 직접 정리한다.
            if (outside != null) {
                Files.deleteIfExists(outside);
            }
        }
    }

    // ---------------------------------------------------------------------
    // VP-2: zip bomb 3중 상한
    // ---------------------------------------------------------------------

    @Test
    void extractZip_rejectsTooManyEntries(@TempDir Path tempDir) throws IOException {
        // entryCount++ 후 > 8000 검사 → 8001개째에서 트리거.
        byte[] zipBytes;
        try (ByteArrayOutputStream baos = new ByteArrayOutputStream();
                ZipOutputStream zos = new ZipOutputStream(baos)) {
            for (int i = 0; i < 8001; i++) {
                zos.putNextEntry(new ZipEntry("f" + i + ".txt"));
                zos.closeEntry();
            }
            zos.finish();
            zipBytes = baos.toByteArray();
        }
        MockMultipartFile file = zipFile("build.zip", zipBytes);

        ZipRejectException ex = assertThrows(ZipRejectException.class, () -> ZipSecurity.extractZip(file, tempDir));
        assertEquals(Reason.TOO_MANY_ENTRIES, ex.reason());
    }

    @Test
    void extractZip_rejectsEntryTooLarge(@TempDir Path tempDir) throws IOException {
        // 단일 엔트리 257MB(0 채움) → 256MB 시점에서 ENTRY_TOO_LARGE.
        byte[] zipBytes = makeLargeSingleEntryZip("big.bin", 257);
        MockMultipartFile file = zipFile("build.zip", zipBytes);

        ZipRejectException ex = assertThrows(ZipRejectException.class, () -> ZipSecurity.extractZip(file, tempDir));
        assertEquals(Reason.ENTRY_TOO_LARGE, ex.reason());
    }

    @Test
    void extractZip_rejectsExtractedTooLarge(@TempDir Path tempDir) throws IOException {
        // 200MB 엔트리 3개 = 600MB. 각각은 256MB 미만이라 ENTRY_TOO_LARGE 안 걸리고
        // 3번째 쓰는 중 누적 512MB 초과 → EXTRACTED_TOO_LARGE.
        byte[] zipBytes = makeMultiLargeEntryZip(200, 3);
        MockMultipartFile file = zipFile("build.zip", zipBytes);

        ZipRejectException ex = assertThrows(ZipRejectException.class, () -> ZipSecurity.extractZip(file, tempDir));
        assertEquals(Reason.EXTRACTED_TOO_LARGE, ex.reason());
    }

    // ---------------------------------------------------------------------
    // VP-3 / VP-4: 포맷 (NOT_ZIP / MAGIC_FAIL / INCOMPLETE_BUILD)
    // ---------------------------------------------------------------------

    @Test
    void assertZipType_rejectsNonZipExtension() {
        MockMultipartFile file = new MockMultipartFile("file", "build.txt", "application/zip", "x".getBytes());

        ZipRejectException ex = assertThrows(ZipRejectException.class, () -> ZipSecurity.assertZipType(file));
        assertEquals(Reason.NOT_ZIP, ex.reason());
    }

    @Test
    void assertZipType_rejectsBadMime() {
        MockMultipartFile file = new MockMultipartFile("file", "build.zip", "text/html", "x".getBytes());

        ZipRejectException ex = assertThrows(ZipRejectException.class, () -> ZipSecurity.assertZipType(file));
        assertEquals(Reason.NOT_ZIP, ex.reason());
    }

    @Test
    void assertZipType_acceptsNullMimeWithZipExt() {
        MockMultipartFile file = new MockMultipartFile("file", "build.zip", null, "x".getBytes());

        assertDoesNotThrow(() -> ZipSecurity.assertZipType(file));
    }

    @Test
    void assertMagic_rejectsNonZipMagic() {
        MockMultipartFile file = zipFile("build.zip", "not a zip".getBytes());

        ZipRejectException ex = assertThrows(ZipRejectException.class, () -> ZipSecurity.assertMagic(file));
        assertEquals(Reason.MAGIC_FAIL, ex.reason());
    }

    @Test
    void assertMagic_acceptsValidZip() throws IOException {
        MockMultipartFile file = zipFile("build.zip", validUnityZip());

        assertDoesNotThrow(() -> ZipSecurity.assertMagic(file));
    }

    @Test
    void assertUnityBuild_rejectsIndexOnly(@TempDir Path tempDir) throws IOException {
        Files.writeString(tempDir.resolve("index.html"), "<html></html>");

        ZipRejectException ex = assertThrows(ZipRejectException.class, () -> ZipSecurity.assertUnityBuild(tempDir));
        assertEquals(Reason.INCOMPLETE_BUILD, ex.reason());
    }

    @Test
    void assertUnityBuild_acceptsFullBuildWithBrVariants(@TempDir Path tempDir) throws IOException {
        Path build = Files.createDirectory(tempDir.resolve("Build"));
        Files.writeString(tempDir.resolve("index.html"), "<html></html>");
        Files.writeString(build.resolve("x.loader.js"), "loader");
        Files.writeString(build.resolve("x.framework.js.br"), "framework");
        Files.writeString(build.resolve("x.data.br"), "data");
        Files.writeString(build.resolve("x.wasm.br"), "wasm");

        assertDoesNotThrow(() -> ZipSecurity.assertUnityBuild(tempDir));
    }

    // ---------------------------------------------------------------------
    // 정상 통과 (회귀 기준)
    // ---------------------------------------------------------------------

    @Test
    void extractZip_acceptsValidUnityBuild(@TempDir Path tempDir) throws IOException {
        MockMultipartFile file = zipFile("build.zip", validUnityZip());

        ZipSecurity.ExtractResult result = ZipSecurity.extractZip(file, tempDir);
        assertTrue(result.entryCount() > 0);

        assertDoesNotThrow(() -> ZipSecurity.assertUnityBuild(tempDir));
    }

    // ---------------------------------------------------------------------
    // 헬퍼
    // ---------------------------------------------------------------------

    /**
     * (엔트리명 → 바이트) 맵으로 메모리 zip 을 생성한다. 이름이 "/" 로 끝나면 디렉터리 엔트리로
     * putNextEntry 만 수행한다.
     */
    private byte[] makeZip(LinkedHashMap<String, byte[]> entries) throws IOException {
        try (ByteArrayOutputStream baos = new ByteArrayOutputStream();
                ZipOutputStream zos = new ZipOutputStream(baos)) {
            for (var e : entries.entrySet()) {
                zos.putNextEntry(new ZipEntry(e.getKey()));
                if (!e.getKey().endsWith("/") && e.getValue() != null) {
                    zos.write(e.getValue());
                }
                zos.closeEntry();
            }
            zos.finish();
            return baos.toByteArray();
        }
    }

    /** contentType "application/zip" 의 MockMultipartFile 생성. */
    private MockMultipartFile zipFile(String filename, byte[] content) {
        return new MockMultipartFile("file", filename, "application/zip", content);
    }

    /**
     * 단일 엔트리 zip 을 메모리에 만들되, 본문은 1MB 0 채움 버퍼를 {@code sizeMb}회 write 한다.
     * 0 바이트는 압축이 잘 되어 zip 자체는 작지만, 해제 시 sizeMb MB 를 read 하게 된다.
     */
    private byte[] makeLargeSingleEntryZip(String name, int sizeMb) throws IOException {
        byte[] buf = new byte[ONE_MB];
        try (ByteArrayOutputStream baos = new ByteArrayOutputStream();
                ZipOutputStream zos = new ZipOutputStream(baos)) {
            zos.putNextEntry(new ZipEntry(name));
            for (int i = 0; i < sizeMb; i++) {
                zos.write(buf);
            }
            zos.closeEntry();
            zos.finish();
            return baos.toByteArray();
        }
    }

    /**
     * 각 {@code sizeMb} MB 의 0 채움 엔트리를 {@code count}개 담은 zip 을 만든다. 누적 해제
     * 상한(512MB) 검증용.
     */
    private byte[] makeMultiLargeEntryZip(int sizeMb, int count) throws IOException {
        byte[] buf = new byte[ONE_MB];
        try (ByteArrayOutputStream baos = new ByteArrayOutputStream();
                ZipOutputStream zos = new ZipOutputStream(baos)) {
            for (int n = 0; n < count; n++) {
                zos.putNextEntry(new ZipEntry("big" + n + ".bin"));
                for (int i = 0; i < sizeMb; i++) {
                    zos.write(buf);
                }
                zos.closeEntry();
            }
            zos.finish();
            return baos.toByteArray();
        }
    }

    /**
     * 정상 Unity WebGL 빌드 zip(index.html + Build/x.loader.js + x.framework.js.br +
     * x.data.br + x.wasm.br). assertMagic / assertUnityBuild / extractZip 모두 통과.
     */
    private byte[] validUnityZip() throws IOException {
        try (ByteArrayOutputStream baos = new ByteArrayOutputStream();
                ZipOutputStream zos = new ZipOutputStream(baos)) {
            writeEntry(zos, "index.html", "<html></html>".getBytes());
            writeEntry(zos, "Build/x.loader.js", "loader".getBytes());
            writeEntry(zos, "Build/x.framework.js.br", "framework".getBytes());
            writeEntry(zos, "Build/x.data.br", "data".getBytes());
            writeEntry(zos, "Build/x.wasm.br", "wasm".getBytes());
            zos.finish();
            return baos.toByteArray();
        }
    }

    private void writeEntry(ZipOutputStream zos, String name, byte[] content) throws IOException {
        zos.putNextEntry(new ZipEntry(name));
        zos.write(content);
        zos.closeEntry();
    }
}
