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.PermissionGate;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
import org.mockito.ArgumentCaptor;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.mock.web.MockHttpServletRequest;
import org.springframework.mock.web.MockHttpSession;
import org.springframework.mock.web.MockMultipartFile;
import org.springframework.test.util.ReflectionTestUtils;
import org.springframework.web.multipart.MultipartFile;

import java.io.ByteArrayOutputStream;
import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.nio.file.Path;
import java.util.Map;
import java.util.UUID;
import java.util.zip.ZipEntry;
import java.util.zip.ZipOutputStream;

import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;

/**
 * GameUploadController 보안 게이트(VP-4/5/6, AC-5/6/8/9/T3/T4) L1 단위 테스트.
 *
 * <p>CsrfTokens.isValid 는 static 이라 mock 불가 → 실제 MockHttpServletRequest +
 * MockHttpSession 으로 세션 csrfToken 과 헤더 X-CSRF-Token 을 일치시켜 충족시킨다.
 * PermissionGate / GameUploadAuditMapper 는 Mockito mock 이다. @SpringBootTest 없이
 * 컨트롤러를 직접 인스턴스화하고 uploadStoragePath 만 임시 디렉터리로 주입한다.
 */
class GameUploadControllerSecurityTest {

    private static final String TOKEN = "tok";

    private PermissionGate gate;
    private GameUploadAuditMapper auditMapper;
    private GameUploadController controller;

    @TempDir
    Path tempDir;

    @BeforeEach
    void setUp() {
        gate = mock(PermissionGate.class);
        auditMapper = mock(GameUploadAuditMapper.class);
        controller = new GameUploadController(gate, auditMapper);
        ReflectionTestUtils.setField(controller, "uploadStoragePath", tempDir.toString());
    }

    // ---- 헬퍼: 세션/요청 ----

    private MockHttpSession authedSession() {
        MockHttpSession session = new MockHttpSession();
        session.setAttribute("csrfToken", TOKEN);
        session.setAttribute("userId", 1L);
        return session;
    }

    /** CSRF 헤더가 세션 토큰과 일치하는 유효 요청. */
    private MockHttpServletRequest validCsrfRequest(MockHttpSession session) {
        MockHttpServletRequest request = new MockHttpServletRequest();
        request.setSession(session);
        request.addHeader("X-CSRF-Token", TOKEN);
        return request;
    }

    /** CSRF 헤더가 없는(불충족) 요청. */
    private MockHttpServletRequest noCsrfRequest(MockHttpSession session) {
        MockHttpServletRequest request = new MockHttpServletRequest();
        request.setSession(session);
        return request;
    }

    // ---- 헬퍼: zip 바이트 ----

    /**
     * assertUnityBuild 4종(loader/framework/data/wasm) + index.html 을 모두 포함한
     * 통과 가능한 Unity WebGL 빌드 zip 을 메모리에 생성한다.
     */
    private byte[] validUnityZip() {
        try (ByteArrayOutputStream baos = new ByteArrayOutputStream();
             ZipOutputStream zip = new ZipOutputStream(baos)) {
            putEntry(zip, "index.html", "<html><body>game</body></html>");
            putEntry(zip, "Build/x.loader.js", "// loader");
            putEntry(zip, "Build/x.framework.js.br", "framework");
            putEntry(zip, "Build/x.data.br", "data");
            putEntry(zip, "Build/x.wasm.br", "wasm");
            zip.finish();
            return baos.toByteArray();
        } catch (IOException e) {
            throw new IllegalStateException("test zip build failed", e);
        }
    }

    private void putEntry(ZipOutputStream zip, String name, String content) throws IOException {
        zip.putNextEntry(new ZipEntry(name));
        zip.write(content.getBytes(StandardCharsets.UTF_8));
        zip.closeEntry();
    }

    private MockMultipartFile zipFile(byte[] bytes) {
        return new MockMultipartFile("file", "build.zip", "application/zip", bytes);
    }

    // ==================================================================
    // 게이트 (VP-5, AC-8)
    // ==================================================================

    @Test
    void webglZip_returns403_whenCsrfInvalid() throws IOException {
        MockHttpSession session = authedSession();
        MockHttpServletRequest request = noCsrfRequest(session);

        ResponseEntity<Map<String, Object>> response = controller.uploadWebglZip(
                zipFile(validUnityZip()), null, null, request, session);

        assertThat(response.getStatusCode()).isEqualTo(HttpStatus.FORBIDDEN);
        assertThat(response.getBody()).containsEntry("status", 403);
        verify(auditMapper, never()).insertAudit(any());
    }

    @Test
    void webglZip_returns401_whenNotAuthenticated() throws IOException {
        MockHttpSession session = new MockHttpSession();
        session.setAttribute("csrfToken", TOKEN);
        // userId 미설정 → 컨트롤러 sessionUserId == null → 401
        when(gate.isAuthenticated(any())).thenReturn(false);
        MockHttpServletRequest request = validCsrfRequest(session);

        ResponseEntity<Map<String, Object>> response = controller.uploadWebglZip(
                zipFile(validUnityZip()), null, null, request, session);

        assertThat(response.getStatusCode()).isEqualTo(HttpStatus.UNAUTHORIZED);
        assertThat(response.getBody()).containsEntry("message", "로그인이 필요합니다.");
        verify(auditMapper, never()).insertAudit(any());
    }

    @Test
    void webglZip_returns403_whenJamModeWithoutPermission() throws IOException {
        MockHttpSession session = authedSession();
        when(gate.isAuthenticated(any())).thenReturn(true);
        when(gate.has(any(), eq("GAME_JAM_MANAGE"))).thenReturn(false);
        MockHttpServletRequest request = validCsrfRequest(session);

        ResponseEntity<Map<String, Object>> response = controller.uploadWebglZip(
                zipFile(validUnityZip()), null, "jam", request, session);

        assertThat(response.getStatusCode()).isEqualTo(HttpStatus.FORBIDDEN);
        assertThat(response.getBody()).containsEntry("status", 403);
        verify(auditMapper, never()).insertAudit(any());
    }

    @Test
    void webglZip_passesGate_whenAuthenticatedNonJam() throws IOException {
        MockHttpSession session = authedSession();
        when(gate.isAuthenticated(any())).thenReturn(true);
        MockHttpServletRequest request = validCsrfRequest(session);

        ResponseEntity<Map<String, Object>> response = controller.uploadWebglZip(
                zipFile(validUnityZip()), null, null, request, session);

        // 개방 유지 회귀: 비-jam 로그인 사용자는 게이트를 통과해 추출까지 진행되어 200.
        assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK);
        Map<String, Object> body = response.getBody();
        assertThat(body).isNotNull();
        assertThat(body.get("gameUuid")).isNotNull();
        assertThat((String) body.get("webglPath")).startsWith("/game/");

        ArgumentCaptor<GameUploadAudit> captor = ArgumentCaptor.forClass(GameUploadAudit.class);
        verify(auditMapper).insertAudit(captor.capture());
        assertThat(captor.getValue().getOutcome()).isEqualTo("SUCCESS");
    }

    // ==================================================================
    // 포맷/크기 (VP-4, AC-5/6)
    // ==================================================================

    @Test
    void webglZip_returns400_whenNotZipExtension() throws IOException {
        MockHttpSession session = authedSession();
        when(gate.isAuthenticated(any())).thenReturn(true);
        MockHttpServletRequest request = validCsrfRequest(session);
        MultipartFile notZip = new MockMultipartFile(
                "file", "build.txt", "text/plain", validUnityZip());

        ResponseEntity<Map<String, Object>> response = controller.uploadWebglZip(
                notZip, null, null, request, session);

        assertThat(response.getStatusCode()).isEqualTo(HttpStatus.BAD_REQUEST);
        ArgumentCaptor<GameUploadAudit> captor = ArgumentCaptor.forClass(GameUploadAudit.class);
        verify(auditMapper).insertAudit(captor.capture());
        assertThat(captor.getValue().getOutcome()).isEqualTo("REJECTED");
        assertThat(captor.getValue().getRejectReason()).isEqualTo("NOT_ZIP");
    }

    @Test
    void webglZip_returns400_whenBadMagic() throws IOException {
        MockHttpSession session = authedSession();
        when(gate.isAuthenticated(any())).thenReturn(true);
        MockHttpServletRequest request = validCsrfRequest(session);
        // 확장자/MIME 는 zip 이지만 내용 magic 이 PK\x03\x04 가 아님 → MAGIC_FAIL
        MultipartFile badMagic = new MockMultipartFile(
                "file", "build.zip", "application/zip", "not a zip".getBytes(StandardCharsets.UTF_8));

        ResponseEntity<Map<String, Object>> response = controller.uploadWebglZip(
                badMagic, null, null, request, session);

        assertThat(response.getStatusCode()).isEqualTo(HttpStatus.BAD_REQUEST);
        ArgumentCaptor<GameUploadAudit> captor = ArgumentCaptor.forClass(GameUploadAudit.class);
        verify(auditMapper).insertAudit(captor.capture());
        assertThat(captor.getValue().getOutcome()).isEqualTo("REJECTED");
        assertThat(captor.getValue().getRejectReason()).isEqualTo("MAGIC_FAIL");
    }

    @Test
    void webglZip_returns413_whenOriginalTooLarge() throws IOException {
        MockHttpSession session = authedSession();
        when(gate.isAuthenticated(any())).thenReturn(true);
        MockHttpServletRequest request = validCsrfRequest(session);
        // 내용은 validUnityZip(magic OK) 이되 getSize() 만 한도 초과로 override → 413 도달.
        MultipartFile big = new MockMultipartFile(
                "file", "build.zip", "application/zip", validUnityZip()) {
            @Override
            public long getSize() {
                return 513L * 1024 * 1024;
            }
        };

        ResponseEntity<Map<String, Object>> response = controller.uploadWebglZip(
                big, null, null, request, session);

        assertThat(response.getStatusCode()).isEqualTo(HttpStatus.PAYLOAD_TOO_LARGE);
        ArgumentCaptor<GameUploadAudit> captor = ArgumentCaptor.forClass(GameUploadAudit.class);
        verify(auditMapper).insertAudit(captor.capture());
        assertThat(captor.getValue().getOutcome()).isEqualTo("REJECTED");
        assertThat(captor.getValue().getRejectReason()).isEqualTo("TOO_LARGE");
    }

    // ==================================================================
    // replaceUuid 소유검증 (VP-6, AC-9)
    // ==================================================================

    @Test
    void webglZip_returns403_whenReplaceUuidNotOwned() throws IOException {
        MockHttpSession session = authedSession(); // userId=1L, role 미설정(non-ADMIN)
        when(gate.isAuthenticated(any())).thenReturn(true);
        MockHttpServletRequest request = validCsrfRequest(session);
        String replaceUuid = UUID.randomUUID().toString();

        GameAssetOwner owner = new GameAssetOwner();
        owner.setGameId(10L);
        owner.setUserId(2L); // 다른 소유자
        when(auditMapper.findGameByWebglUuid(eq(replaceUuid))).thenReturn(owner);

        ResponseEntity<Map<String, Object>> response = controller.uploadWebglZip(
                zipFile(validUnityZip()), replaceUuid, null, request, session);

        assertThat(response.getStatusCode()).isEqualTo(HttpStatus.FORBIDDEN);
        assertThat(response.getBody()).containsEntry("status", 403);
        ArgumentCaptor<GameUploadAudit> captor = ArgumentCaptor.forClass(GameUploadAudit.class);
        verify(auditMapper).insertAudit(captor.capture());
        assertThat(captor.getValue().getOutcome()).isEqualTo("REJECTED");
        assertThat(captor.getValue().getGameUuid()).isEqualTo(replaceUuid);
    }

    @Test
    void webglZip_allowsReplace_whenOwner() throws IOException {
        MockHttpSession session = authedSession(); // userId=1L
        when(gate.isAuthenticated(any())).thenReturn(true);
        MockHttpServletRequest request = validCsrfRequest(session);
        String replaceUuid = UUID.randomUUID().toString();

        GameAssetOwner owner = new GameAssetOwner();
        owner.setGameId(10L);
        owner.setUserId(1L); // 세션과 동일 소유자
        when(auditMapper.findGameByWebglUuid(eq(replaceUuid))).thenReturn(owner);

        ResponseEntity<Map<String, Object>> response = controller.uploadWebglZip(
                zipFile(validUnityZip()), replaceUuid, null, request, session);

        assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK);
        Map<String, Object> body = response.getBody();
        assertThat(body).isNotNull();
        assertThat(body.get("gameUuid")).isEqualTo(replaceUuid); // 멱등: 동일 uuid 재사용
        ArgumentCaptor<GameUploadAudit> captor = ArgumentCaptor.forClass(GameUploadAudit.class);
        verify(auditMapper).insertAudit(captor.capture());
        assertThat(captor.getValue().getOutcome()).isEqualTo("SUCCESS");
    }

    // ==================================================================
    // CSRF 전수 (AC-T3) — thumbnail / root 핸들러
    // ==================================================================

    @Test
    void thumbnail_returns403_whenCsrfInvalid() throws IOException {
        MockHttpSession session = authedSession();
        MockHttpServletRequest request = noCsrfRequest(session);
        MultipartFile image = new MockMultipartFile(
                "file", "thumb.png", "image/png", new byte[]{1, 2, 3});

        ResponseEntity<Map<String, Object>> response = controller.uploadThumbnail(
                image, UUID.randomUUID().toString(), request, session);

        assertThat(response.getStatusCode()).isEqualTo(HttpStatus.FORBIDDEN);
        assertThat(response.getBody()).containsEntry("status", 403);
    }

    @Test
    void root_returns403_whenCsrfInvalid() throws IOException {
        MockHttpSession session = authedSession();
        MockHttpServletRequest request = noCsrfRequest(session);
        MultipartFile[] files = {
                new MockMultipartFile("files", "a.txt", "text/plain", new byte[]{1})
        };

        ResponseEntity<Map<String, Object>> response = controller.uploadGameFiles(
                files, null, request, session);

        assertThat(response.getStatusCode()).isEqualTo(HttpStatus.FORBIDDEN);
        assertThat(response.getBody()).containsEntry("status", 403);
    }
}
